Add CASH axis: money-market/cash-equivalents as a return driver

A fund's yield is a near-constant in return space, so OLS puts it in
the intercept (alpha_ann), not in the shv/bil betas (which only
measure rate-CHANGE sensitivity). Cash funds therefore showed no
dominant driver.

- factors: cash_yield() (shv trailing-1y total return = local
  stand-in for the 13-wk T-bill / 0-3m Treasury index), AXES =
  DRIVERS + ['cash'] (alpha_ann / cash_yield, 1.0 = earns the cash
  rate); a display/clustering axis, not a regressor.
- cluster: cash column in the loading matrix; k-means distances use
  the cash-2x-emphasized matrix (a pure cash fund's level axis was
  otherwise swallowed by the low-exposure cloud); labels use raw
  values; label() cash-aware (0.25 threshold, 1.5x runner-up).
- app: cluster view over AXES with the emphasized distance.

The 331-fund 'no dominant driver' grab-bag now splits into
'cash (yield) +0.64' (n=187, MM + ultra-short) and 'cash (yield)
+1.26' (n=59 pure). 123/123 tests.
This commit is contained in:
Greg Pomerantz 2026-08-30 13:27:41 -04:00
parent 6f6d6b8047
commit 9f666538c3
7 changed files with 123 additions and 12 deletions

10
app.py
View File

@ -691,7 +691,11 @@ with tab_fundlab:
_syms, _V = _cl.loading_matrix()
_fr = json.loads((_dc.RESULTS.parent /
"factor_results.json").read_text())
_lab = _cl.kmeans(_V, _ck)
# distance on the cash-emphasized matrix (a pure cash fund's
# level axis is otherwise swallowed by the low-exposure cloud);
# labels below use the raw _V values
_lab = _cl.kmeans(_cl.emphasized(_V, _fac.AXES.index("cash")),
_ck)
_sumrows = []
for c in range(_ck):
idx = [i for i, l in enumerate(_lab) if l == c]
@ -699,7 +703,7 @@ with tab_fundlab:
continue
med = _np.median(_V[idx], 0)
top = _np.argsort(-_np.abs(med))[:4]
prof = {_fac.DRIVERS[t]: float(med[t]) for t in top
prof = {_fac.AXES[t]: float(med[t]) for t in top
if abs(med[t]) >= 0.08}
lab = _cl.label(prof)
t5s = [_fr[_syms[i]].get("alpha_t_5y") for i in idx]
@ -736,7 +740,7 @@ with tab_fundlab:
continue
med = _np.median(_V[idx], 0)
top = _np.argsort(-_np.abs(med))[:4]
prof = {_fac.DRIVERS[t]: float(med[t]) for t in top
prof = {_fac.AXES[t]: float(med[t]) for t in top
if abs(med[t]) >= 0.08}
if _cl.label(prof) == _target:
_members = [(i, idx[0]) for i in idx]

View File

@ -933,3 +933,51 @@ the overnight screen and had exited with it.
**App:** Fund Lab tab shows the CEF shortlist table (character
actual-vs-model flag, discount, 5y, vol, scenarios, tenders, score).
### CASH axis: money-market / cash-equivalent funds as a driver
**Problem:** money-market and ultra-short funds fell into the "no
dominant driver" cluster. There WAS a cash sleeve (shv; bil was dropped
from the regression for shv/bil collinearity), but cash funds still
didn't load on it: a fund's yield is a near-CONSTANT in return space,
so OLS puts it in the INTERCEPT (alpha_ann), not in any beta. shv/bil
betas only measure the response to rate CHANGES — tiny for cash-like
funds. Evidence: qcmmrx (Money Market Account) had shv 0.13, bil 0.12,
R² 0.08, but alpha_ann 1.91%/yr = the average cash level over its
history.
**Benchmark:** the canonical cash benchmark is the 13-week T-bill /
0-3m Treasury index (SIXM). Local stand-ins: shv/sgov/bil/gbil
total-return (Adj Close), trailing 1y ≈ 3.7% in the current regime.
**Fix (factors.py + cluster.py + app.py):**
- `factors.cash_yield()`: shv trailing-1y total return (fallback sgov,
bil) — the normalizer.
- `factors.AXES = DRIVERS + ["cash"]`: a VIRTUAL cash axis per fund =
alpha_ann / cash_yield (1.0 = "earns the cash rate"), from the full
window (rec5 fallback when the row falls back to rec5). It is a
clustering/display axis, NOT a regressor (the level is already in
the intercept; regressing on a constant is collinear by design).
- `cluster.loading_matrix()` appends the cash column (winsorized ±4
like the betas). SLEEVE_NAME["cash"] = "cash (yield)".
- `cluster.emphasized(V)`: the cash axis is a LEVEL, not a sensitivity
— in raw Euclidean space a pure cash fund (0,...,0,~0.5) sits inside
the low-exposure balanced/target-date cloud and k-means swallows it.
K-means (app + run_kmeans) runs on the cash-2x-emphasized matrix;
LABELS still use the raw values. The hclust tree stays raw.
- `cluster.label()`: cash-aware — the median cash in a mixed
low-exposure cluster is diluted, so cash counts as the driver at
>=0.25 (vs 0.30 for betas) and must be >=1.5x the runner-up.
**Result (k=30):** the grab-bag 331-fund "no dominant driver" cluster
splits into "cash (yield) +0.64" (n=187: qcmmrx + the ultra-short
government/bond funds), "cash (yield) +1.26" (n=59 pure), and the
genuinely balanced remainder. 637 funds now have CASH as their
dominant axis (>=0.30 and > every beta) — mostly the short-duration
complex, which is correct: their return IS mostly the coupon level.
**Note:** the machine rebooted during this work (12:50) — it silently
kills nohup'd processes (the CEF batch and the first watchdog both
died that way; the batch was resumable so no data was lost).
server_watchdog.sh had to be relaunched manually; consider a
boot-time starter (systemd/cron @reboot) if reboots repeat.

View File

@ -30,7 +30,7 @@ HERE = Path(__file__).parent
RESULTS = HERE / "factor_results.json"
TREE = HERE / "cluster_tree.json"
KMEANS = HERE / "cluster_kmeans.json"
from fundlab.factors import DRIVERS # noqa: E402
from fundlab.factors import AXES, DRIVERS, cash_yield # noqa: E402
def log(msg: str) -> None:
@ -39,6 +39,7 @@ def log(msg: str) -> None:
def loading_matrix() -> tuple[list[str], np.ndarray]:
d = json.loads(RESULTS.read_text())
yld = cash_yield()
syms, rows = [], []
for sym, v in d.items():
# full window preferred; funds without 250 complete-case rows
@ -50,6 +51,13 @@ def loading_matrix() -> tuple[list[str], np.ndarray]:
continue
row = np.array([f["betas"].get(s, 0.0) or 0.0 for s in DRIVERS],
dtype=float)
# virtual CASH axis: the fund's intercept (annualized alpha) is
# its yield capture - for a cash fund that IS the exposure,
# because OLS puts a near-constant yield level in the intercept
# rather than in the (small) shv/bil slope. Normalize by the
# recent cash yield so 1.0 == "earns the cash rate".
alpha = f.get("alpha_ann")
row = np.append(row, (alpha / yld if alpha is not None and yld else 0.0))
# winsorize at +/-4: everything beyond that is an OLS noise fit
# (R2 ~ 0.01, |t| < 2 - e.g. fyhtx shv +63.8), never a real
# exposure (no open-end fund runs 400% of a sleeve)
@ -120,7 +128,7 @@ def cluster_means(syms: list[str], V: np.ndarray,
idx = [i for i, s in assign.items() if s == c]
med = np.median(V[idx], axis=0)
top = np.argsort(-np.abs(med))[:4]
prof = {DRIVERS[t]: float(med[t]) for t in top
prof = {AXES[t]: float(med[t]) for t in top
if abs(med[t]) >= 0.05}
out.append({"n": len(idx), "top": prof,
"syms": [syms[i] for i in idx]})
@ -140,15 +148,41 @@ SLEEVE_NAME = {
"xlv": "healthcare", "xlp": "staples", "xlu": "utilities",
"xly": "consumer disc", "xlb": "materials", "dbmf": "CTA/futures",
"dbb": "commodities",
"cash": "cash (yield)",
}
# the CASH axis is a yield LEVEL (alpha/cash-yield, 1.0 = earns the cash
# rate), not a return SENSITIVITY like the betas. In raw Euclidean space a
# pure cash fund (0,...,0,~0.5) sits inside the cloud of low-exposure
# balanced/target-date funds and k-means swallows it. Emphasize the axis
# for DISTANCE only (labels still use the raw values).
CASH_EMPHASIS = 2.0
def emphasized(V: np.ndarray, ci: int) -> np.ndarray:
W = V.copy()
W[:, ci] *= CASH_EMPHASIS
return W
def label(prof: dict) -> str:
# a real driver shows up as >=0.3 on a median loading; below that the
# cluster is balanced mixes with no single dominant driver
if not prof or max(abs(v) for v in prof.values()) < 0.30:
# cluster is balanced mixes with no single dominant driver. The CASH
# axis is a level, not a beta: in a mixed low-exposure cluster the
# median cash is diluted, so it gets a slightly lower threshold and
# must clearly beat the runner-up to count as THE driver.
if not prof:
return "no dominant driver (balanced/idio)"
names = sorted(prof, key=lambda s: -abs(prof[s]))
top_s, top_v = names[0], prof[names[0]]
runner = abs(prof[names[1]]) if len(names) > 1 else 0.0
if top_s == "cash":
if top_v >= 0.25 and top_v >= 1.5 * runner:
return " + ".join(f"{SLEEVE_NAME.get(s, s)} {prof[s]:+.2f}"
for s in names[:3])
return "no dominant driver (balanced/idio)"
if abs(top_v) < 0.30:
return "no dominant driver (balanced/idio)"
return " + ".join(f"{SLEEVE_NAME.get(s, s)} {prof[s]:+.2f}"
for s in names[:3])
@ -182,7 +216,7 @@ def kmeans(V: np.ndarray, k: int, seed: int = 0,
def run_kmeans(k: int = 30) -> dict:
syms, V = loading_matrix()
lab = kmeans(V, k)
lab = kmeans(emphasized(V, AXES.index("cash")), k)
out: dict[int, dict] = {}
for c in range(k):
idx = [i for i, l in enumerate(lab) if l == c]
@ -190,7 +224,7 @@ def run_kmeans(k: int = 30) -> dict:
continue
med = np.median(V[idx], axis=0)
top = np.argsort(-np.abs(med))[:4]
prof = {DRIVERS[t]: float(med[t]) for t in top
prof = {AXES[t]: float(med[t]) for t in top
if abs(med[t]) >= 0.08}
out[c] = {"n": len(idx), "top": prof, "label": label(prof),
"syms": [syms[i] for i in idx]}
@ -203,7 +237,7 @@ def run() -> None:
log(f"cluster: {len(syms)} funds x {V.shape[1]} drivers")
merges = hclust(V)
TREE.write_text(json.dumps({"syms": syms, "merges": merges,
"n": len(syms), "sleeves": DRIVERS}))
"n": len(syms), "sleeves": AXES}))
log(f"hierarchical tree written in {time.time() - t0:.0f}s -> {TREE.name}")
# NOTE: fixed-k cuts of the tree peel small pure-sleeve clusters off a
# giant blend blob (most funds are multi-sleeve mixes) - the useful

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -72,6 +72,29 @@ SLEEVES_V2 = list(dict.fromkeys(BROAD_SLEEVES + EXTRA_SLEEVES))
DROP_FROM_REGRESSION = {"vea", "vug", "finux", "bil"}
DRIVERS = [s for s in SLEEVES_V2 if s not in DROP_FROM_REGRESSION]
# clustering/display basis = the regression sleeves + a virtual CASH axis.
# A cash fund's yield is a near-constant in return space, so OLS puts it in
# the INTERCEPT (alpha_ann), not in any beta - BIL/SHV betas only measure
# the fund's response to rate CHANGES. The CASH axis makes the yield
# capture an explicit driver: alpha_ann normalized by the recent cash
# yield (shv trailing 1y total return = the local stand-in for the
# 13-week T-bill / 0-3m Treasury index, SIXM-style).
AXES = DRIVERS + ["cash"]
@lru_cache(maxsize=1)
def cash_yield() -> float:
"""Recent cash level: shv trailing 1y total return (fallback sgov,
then bil). ~3.7% in the current regime."""
for s in ("shv", "sgov", "bil"):
a = decompose.adj_close(s)
if a is None:
continue
year_ago = a[a.index <= a.index[-1] - pd.Timedelta(days=365)]
if len(year_ago) >= 1 and year_ago.iloc[-1] > 0:
return float(a.iloc[-1] / year_ago.iloc[-1] - 1.0)
return 0.03
def _resid(y: pd.Series, X: pd.DataFrame) -> pd.Series:
m = y.notna() & X.notna().all(axis=1)

View File

@ -1,2 +1,4 @@
2026-08-28 14:52:00
server_watchdog: no answer on :8599 - relaunching
2026-08-30 13:26:18
server_watchdog: no answer on :8599 - relaunching