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:
parent
6f6d6b8047
commit
9f666538c3
10
app.py
10
app.py
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user