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()
|
_syms, _V = _cl.loading_matrix()
|
||||||
_fr = json.loads((_dc.RESULTS.parent /
|
_fr = json.loads((_dc.RESULTS.parent /
|
||||||
"factor_results.json").read_text())
|
"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 = []
|
_sumrows = []
|
||||||
for c in range(_ck):
|
for c in range(_ck):
|
||||||
idx = [i for i, l in enumerate(_lab) if l == c]
|
idx = [i for i, l in enumerate(_lab) if l == c]
|
||||||
|
|
@ -699,7 +703,7 @@ with tab_fundlab:
|
||||||
continue
|
continue
|
||||||
med = _np.median(_V[idx], 0)
|
med = _np.median(_V[idx], 0)
|
||||||
top = _np.argsort(-_np.abs(med))[:4]
|
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 abs(med[t]) >= 0.08}
|
||||||
lab = _cl.label(prof)
|
lab = _cl.label(prof)
|
||||||
t5s = [_fr[_syms[i]].get("alpha_t_5y") for i in idx]
|
t5s = [_fr[_syms[i]].get("alpha_t_5y") for i in idx]
|
||||||
|
|
@ -736,7 +740,7 @@ with tab_fundlab:
|
||||||
continue
|
continue
|
||||||
med = _np.median(_V[idx], 0)
|
med = _np.median(_V[idx], 0)
|
||||||
top = _np.argsort(-_np.abs(med))[:4]
|
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 abs(med[t]) >= 0.08}
|
||||||
if _cl.label(prof) == _target:
|
if _cl.label(prof) == _target:
|
||||||
_members = [(i, idx[0]) for i in idx]
|
_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
|
**App:** Fund Lab tab shows the CEF shortlist table (character
|
||||||
actual-vs-model flag, discount, 5y, vol, scenarios, tenders, score).
|
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"
|
RESULTS = HERE / "factor_results.json"
|
||||||
TREE = HERE / "cluster_tree.json"
|
TREE = HERE / "cluster_tree.json"
|
||||||
KMEANS = HERE / "cluster_kmeans.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:
|
def log(msg: str) -> None:
|
||||||
|
|
@ -39,6 +39,7 @@ def log(msg: str) -> None:
|
||||||
|
|
||||||
def loading_matrix() -> tuple[list[str], np.ndarray]:
|
def loading_matrix() -> tuple[list[str], np.ndarray]:
|
||||||
d = json.loads(RESULTS.read_text())
|
d = json.loads(RESULTS.read_text())
|
||||||
|
yld = cash_yield()
|
||||||
syms, rows = [], []
|
syms, rows = [], []
|
||||||
for sym, v in d.items():
|
for sym, v in d.items():
|
||||||
# full window preferred; funds without 250 complete-case rows
|
# full window preferred; funds without 250 complete-case rows
|
||||||
|
|
@ -50,6 +51,13 @@ def loading_matrix() -> tuple[list[str], np.ndarray]:
|
||||||
continue
|
continue
|
||||||
row = np.array([f["betas"].get(s, 0.0) or 0.0 for s in DRIVERS],
|
row = np.array([f["betas"].get(s, 0.0) or 0.0 for s in DRIVERS],
|
||||||
dtype=float)
|
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
|
# 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
|
# (R2 ~ 0.01, |t| < 2 - e.g. fyhtx shv +63.8), never a real
|
||||||
# exposure (no open-end fund runs 400% of a sleeve)
|
# 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]
|
idx = [i for i, s in assign.items() if s == c]
|
||||||
med = np.median(V[idx], axis=0)
|
med = np.median(V[idx], axis=0)
|
||||||
top = np.argsort(-np.abs(med))[:4]
|
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}
|
if abs(med[t]) >= 0.05}
|
||||||
out.append({"n": len(idx), "top": prof,
|
out.append({"n": len(idx), "top": prof,
|
||||||
"syms": [syms[i] for i in idx]})
|
"syms": [syms[i] for i in idx]})
|
||||||
|
|
@ -140,15 +148,41 @@ SLEEVE_NAME = {
|
||||||
"xlv": "healthcare", "xlp": "staples", "xlu": "utilities",
|
"xlv": "healthcare", "xlp": "staples", "xlu": "utilities",
|
||||||
"xly": "consumer disc", "xlb": "materials", "dbmf": "CTA/futures",
|
"xly": "consumer disc", "xlb": "materials", "dbmf": "CTA/futures",
|
||||||
"dbb": "commodities",
|
"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:
|
def label(prof: dict) -> str:
|
||||||
# a real driver shows up as >=0.3 on a median loading; below that the
|
# a real driver shows up as >=0.3 on a median loading; below that the
|
||||||
# cluster is balanced mixes with no single dominant driver
|
# cluster is balanced mixes with no single dominant driver. The CASH
|
||||||
if not prof or max(abs(v) for v in prof.values()) < 0.30:
|
# 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)"
|
return "no dominant driver (balanced/idio)"
|
||||||
names = sorted(prof, key=lambda s: -abs(prof[s]))
|
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}"
|
return " + ".join(f"{SLEEVE_NAME.get(s, s)} {prof[s]:+.2f}"
|
||||||
for s in names[:3])
|
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:
|
def run_kmeans(k: int = 30) -> dict:
|
||||||
syms, V = loading_matrix()
|
syms, V = loading_matrix()
|
||||||
lab = kmeans(V, k)
|
lab = kmeans(emphasized(V, AXES.index("cash")), k)
|
||||||
out: dict[int, dict] = {}
|
out: dict[int, dict] = {}
|
||||||
for c in range(k):
|
for c in range(k):
|
||||||
idx = [i for i, l in enumerate(lab) if l == c]
|
idx = [i for i, l in enumerate(lab) if l == c]
|
||||||
|
|
@ -190,7 +224,7 @@ def run_kmeans(k: int = 30) -> dict:
|
||||||
continue
|
continue
|
||||||
med = np.median(V[idx], axis=0)
|
med = np.median(V[idx], axis=0)
|
||||||
top = np.argsort(-np.abs(med))[:4]
|
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}
|
if abs(med[t]) >= 0.08}
|
||||||
out[c] = {"n": len(idx), "top": prof, "label": label(prof),
|
out[c] = {"n": len(idx), "top": prof, "label": label(prof),
|
||||||
"syms": [syms[i] for i in idx]}
|
"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")
|
log(f"cluster: {len(syms)} funds x {V.shape[1]} drivers")
|
||||||
merges = hclust(V)
|
merges = hclust(V)
|
||||||
TREE.write_text(json.dumps({"syms": syms, "merges": merges,
|
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}")
|
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
|
# 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
|
# 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"}
|
DROP_FROM_REGRESSION = {"vea", "vug", "finux", "bil"}
|
||||||
DRIVERS = [s for s in SLEEVES_V2 if s not in DROP_FROM_REGRESSION]
|
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:
|
def _resid(y: pd.Series, X: pd.DataFrame) -> pd.Series:
|
||||||
m = y.notna() & X.notna().all(axis=1)
|
m = y.notna() & X.notna().all(axis=1)
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
2026-08-28 14:52:00
|
2026-08-28 14:52:00
|
||||||
server_watchdog: no answer on :8599 - relaunching
|
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