Compute all alphas in excess of the 3-mo T-bill rate (BIL)
Raw-intercept alphas absorbed the T-bill yield on uninvested/levered portions (582 well-fitted funds >2%/yr off; sum-of-betas polluted by level-matching). Now fund AND sleeves are netted against BIL daily total return before every regression; a cash position contributes exactly zero. - decompose: rf_series()/excess(); shv+bil dropped from regressors (~0 columns in excess space); FULL_WINDOW -> 2007-06-01 (BIL inception; mixing raw pre-2007 with excess breaks the fit). - factors: same excess treatment; shv out of DRIVERS. - CASH axis redefined: alpha/cash_yield -> net cash position = 1 - sum(betas) (label 'cash (net posn)'). - CANDIDATE list 250 -> 11: the old list was mostly under-invested funds whose 'alpha' was cash yield, not skill. - refback.py: per-fund fitted reference (forward-selected sleeves) stored as ref_5y/ref_full in search_all.json; app alpha-search table gains a 'reference (5y)' column - the answer to 'what is alpha computed against' (the fund's OWN fitted sleeve mix, not one index). - App captions updated; raw-alpha-era results backed up as *_rawalpha.json (not deleted).
This commit is contained in:
parent
9f666538c3
commit
89674c24dd
41
app.py
41
app.py
|
|
@ -660,6 +660,7 @@ with tab_fundlab:
|
||||||
else "—"),
|
else "—"),
|
||||||
"alpha 5y": (f"{_a5*100:+.1f}% (t={_v['alpha_t_5y']:+.1f})"
|
"alpha 5y": (f"{_a5*100:+.1f}% (t={_v['alpha_t_5y']:+.1f})"
|
||||||
if isinstance(_a5, (int, float)) else "—"),
|
if isinstance(_a5, (int, float)) else "—"),
|
||||||
|
"reference (5y)": _v.get("ref_5y") or "—",
|
||||||
"corr port": (f"{_v['corr_portfolio']:.2f}"
|
"corr port": (f"{_v['corr_portfolio']:.2f}"
|
||||||
if isinstance(_v.get("corr_portfolio"),
|
if isinstance(_v.get("corr_portfolio"),
|
||||||
(int, float)) else "—"),
|
(int, float)) else "—"),
|
||||||
|
|
@ -670,14 +671,18 @@ with tab_fundlab:
|
||||||
})
|
})
|
||||||
st.dataframe(pd.DataFrame(_tbl), width="stretch")
|
st.dataframe(pd.DataFrame(_tbl), width="stretch")
|
||||||
st.caption(
|
st.caption(
|
||||||
"Screen: daily total returns vs 21 broad sleeve axes (same "
|
"Screen: daily total returns IN EXCESS OF THE 3-MO T-BILL "
|
||||||
"set for every fund); 'alpha 5y' = OLS intercept over the "
|
"RATE (BIL) vs 21 broad sleeve axes (same set for every "
|
||||||
"last 5 years (t-stat); 'corr port' = correlation with your "
|
"fund); 'alpha 5y' = OLS intercept of the excess returns "
|
||||||
"current qspnx/pmaix portfolio; '6m + %' = share of rolling "
|
"over the last 5 years (t-stat) — i.e. outperformance vs "
|
||||||
"6-month windows where the fund beat its fitted sleeve mix. "
|
"the fund's OWN fitted sleeve mix (the 'reference' column), "
|
||||||
"CANDIDATE = R²5y < 0.6 (or < 0.85 with strong residual "
|
"not vs one index; a cash position earns exactly the t-bill "
|
||||||
"alpha), t5y ≥ 2, t-full ≥ 1.25, ≥ 45% positive windows, "
|
"rate and adds zero alpha. 'corr port' = correlation with "
|
||||||
"portfolio correlation < 0.3.")
|
"your current qspnx/pmaix portfolio; '6m + %' = share of "
|
||||||
|
"rolling 6-month windows where the fund beat its fitted "
|
||||||
|
"sleeve mix. CANDIDATE = R²5y < 0.6 (or < 0.85 with strong "
|
||||||
|
"residual alpha), t5y ≥ 2, t-full ≥ 1.25, ≥ 45% positive "
|
||||||
|
"windows, portfolio correlation < 0.3.")
|
||||||
|
|
||||||
# --- return-driver clusters: k-means on 35-sleeve loading vectors ---
|
# --- return-driver clusters: k-means on 35-sleeve loading vectors ---
|
||||||
with st.expander(
|
with st.expander(
|
||||||
|
|
@ -771,11 +776,14 @@ with tab_fundlab:
|
||||||
r.pop("_t")
|
r.pop("_t")
|
||||||
st.dataframe(pd.DataFrame(_rows), width="stretch")
|
st.dataframe(pd.DataFrame(_rows), width="stretch")
|
||||||
st.caption(
|
st.caption(
|
||||||
"Each fund's daily returns are regressed on 35 sleeve axes "
|
"Each fund's daily total returns IN EXCESS OF THE 3-MO "
|
||||||
"(equity styles/sizes/intl/EM, the duration ladder, IG/HY/"
|
"T-BILL RATE (BIL) are regressed on 34 sleeve axes (equity "
|
||||||
"muni/MBS/preferred/EM-debt credit, sectors, gold/oil/"
|
"styles/sizes/intl/EM, the duration ladder, IG/HY/muni/MBS/"
|
||||||
"commodities, CTA); the 35-d loading vector is the fund's "
|
"preferred/EM-debt credit, sectors, gold/oil/commodities, "
|
||||||
"'return-driver signature' and k-means groups similar "
|
"CTA); the loading vector is the fund's 'return-driver "
|
||||||
|
"signature' — the betas are its NET INVESTED mix — plus a "
|
||||||
|
"virtual CASH axis = 1 − Σβ (net cash/T-bill position; 1 = "
|
||||||
|
"fully in cash, < 0 = levered). k-means groups similar "
|
||||||
"signatures. 'corr port' is shown for reference only - "
|
"signatures. 'corr port' is shown for reference only - "
|
||||||
"high-corr funds are REPLACEMENTS for current holdings, "
|
"high-corr funds are REPLACEMENTS for current holdings, "
|
||||||
"not rejections.")
|
"not rejections.")
|
||||||
|
|
@ -1096,9 +1104,10 @@ with tab_fundlab:
|
||||||
if _dr.get("note"):
|
if _dr.get("note"):
|
||||||
st.markdown("**Holdings cross-check:** " + _dr["note"])
|
st.markdown("**Holdings cross-check:** " + _dr["note"])
|
||||||
st.caption(
|
st.caption(
|
||||||
"Method: daily total returns; greedy forward selection on BIC "
|
"Method: daily total returns IN EXCESS OF THE 3-MO T-BILL RATE "
|
||||||
"(add only if ΔBIC ≥ 2 and |t| > 2); candidate sleeves curated per "
|
"(BIL); greedy forward selection on BIC (add only if ΔBIC ≥ 2 and "
|
||||||
"fund from the strategy text + N-PORT. β = exposure, not a literal "
|
"|t| > 2); candidate sleeves curated per fund from the strategy "
|
||||||
|
"text + N-PORT. β = exposure, not a literal "
|
||||||
"holding weight; 'drift' = mean |rolling 1y β − full β| relative to "
|
"holding weight; 'drift' = mean |rolling 1y β − full β| relative to "
|
||||||
"the full β. High-R² + low drift ≈ static mix; low R² with positive "
|
"the full β. High-R² + low drift ≈ static mix; low R² with positive "
|
||||||
"alpha ≈ market-neutral/alpha strategy.")
|
"alpha ≈ market-neutral/alpha strategy.")
|
||||||
|
|
|
||||||
|
|
@ -981,3 +981,95 @@ kills nohup'd processes (the CEF batch and the first watchdog both
|
||||||
died that way; the batch was resumable so no data was lost).
|
died that way; the batch was resumable so no data was lost).
|
||||||
server_watchdog.sh had to be relaunched manually; consider a
|
server_watchdog.sh had to be relaunched manually; consider a
|
||||||
boot-time starter (systemd/cron @reboot) if reboots repeat.
|
boot-time starter (systemd/cron @reboot) if reboots repeat.
|
||||||
|
- 2026-08-30 15:09 === stage screen ===
|
||||||
|
- 2026-08-30 15:09 screen: 2384 funds, 2384 to do
|
||||||
|
- 2026-08-30 15:10 screen: 100/2384
|
||||||
|
- 2026-08-30 15:11 screen: 200/2384
|
||||||
|
- 2026-08-30 15:11 screen: 300/2384
|
||||||
|
- 2026-08-30 15:12 screen: 400/2384
|
||||||
|
- 2026-08-30 15:12 screen: 500/2384
|
||||||
|
- 2026-08-30 15:13 screen: 600/2384
|
||||||
|
- 2026-08-30 15:13 screen: 700/2384
|
||||||
|
- 2026-08-30 15:14 screen: 800/2384
|
||||||
|
- 2026-08-30 15:14 screen: 900/2384
|
||||||
|
- 2026-08-30 15:15 screen: 1000/2384
|
||||||
|
- 2026-08-30 15:15 screen: 1100/2384
|
||||||
|
- 2026-08-30 15:16 screen: 1200/2384
|
||||||
|
- 2026-08-30 15:17 screen: 1300/2384
|
||||||
|
- 2026-08-30 15:17 screen: 1400/2384
|
||||||
|
- 2026-08-30 15:18 screen: 1500/2384
|
||||||
|
- 2026-08-30 15:18 screen: 1600/2384
|
||||||
|
- 2026-08-30 15:19 screen: 1700/2384
|
||||||
|
- 2026-08-30 15:19 screen: 1800/2384
|
||||||
|
- 2026-08-30 15:20 screen: 1900/2384
|
||||||
|
- 2026-08-30 15:20 screen: 2000/2384
|
||||||
|
- 2026-08-30 15:21 screen: 2100/2384
|
||||||
|
- 2026-08-30 15:21 screen: 2200/2384
|
||||||
|
- 2026-08-30 15:22 screen: 2300/2384
|
||||||
|
- 2026-08-30 15:22 screen done: 2384 funds
|
||||||
|
- 2026-08-30 15:22 === stage finalize ===
|
||||||
|
- 2026-08-30 15:22 finalize: 2384 funds screened
|
||||||
|
- 2026-08-30 15:22 1339 sleeve mix
|
||||||
|
- 2026-08-30 15:22 1001 weak/unstable alpha
|
||||||
|
- 2026-08-30 15:22 17 no 5y window
|
||||||
|
- 2026-08-30 15:22 11 CANDIDATE
|
||||||
|
- 2026-08-30 15:22 10 alpha, but correlated with current portfolio
|
||||||
|
- 2026-08-30 15:22 6 alpha in 5y window, but not persistent
|
||||||
|
- 2026-08-30 15:22 candidates v1 name-filter would have missed: ['aguax', 'etsix', 'femdx', 'hicox', 'prfrx', 'puls', 'rctix', 'rpifx', 'scfzx']
|
||||||
|
- 2026-08-30 15:22 === overnight run finished in 0.2h ===
|
||||||
|
|
||||||
|
### Alphas recomputed in EXCESS of the 3-mo T-bill rate (risk-free netting)
|
||||||
|
|
||||||
|
**Question that triggered it:** "Are we using cash return as a risk-free
|
||||||
|
rate in the alpha calculations?" — No. Alpha was the OLS intercept of
|
||||||
|
RAW daily total returns. For a fund whose net sleeve exposure is not
|
||||||
|
~100% (under-invested or levered), the raw intercept absorbs the T-bill
|
||||||
|
yield on the uninvested/levered part, so "alpha" mixed skill with cash
|
||||||
|
drag. Magnitude (5y, R²>0.5 funds): 582 funds >2%/yr apart, 533 in
|
||||||
|
1-2%, 345 in 0.5-1%. Sum-of-betas was also polluted by level-matching
|
||||||
|
(median 1.15; a plain growth fund like bggsx showed 3.7 with a −10%/yr
|
||||||
|
"alpha" that was pure level-splitting).
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- decompose.py: `rf_series()` = BIL (SPDR 1-3mo T-Bill) daily total
|
||||||
|
return — the local stand-in for the 3-mo T-bill yield; `excess()`
|
||||||
|
nets the fund AND the sleeves against it before every regression.
|
||||||
|
Cash positions now contribute exactly zero (cash IS the rf).
|
||||||
|
- CASH_SLEEVES {shv, bil} dropped from the regressors: in excess space
|
||||||
|
they are ~0 columns (ill-conditioning); the cash position is captured
|
||||||
|
by the residual instead. Same for factors.py (shv added to
|
||||||
|
DROP_FROM_REGRESSION).
|
||||||
|
- **FULL_WINDOW moved 1990-01-01 → 2007-06-01** (BIL inception):
|
||||||
|
mixing raw pre-2007 with excess post-2007 returns breaks the
|
||||||
|
full-window fit (cosix: R²≈0, zero components selected).
|
||||||
|
- **CASH axis redefined** from alpha_ann/cash_yield (the old version
|
||||||
|
was measuring the yield level that no longer lives in the intercept)
|
||||||
|
to **net cash position = 1 − Σβ**: the betas are the net-invested
|
||||||
|
mix, the leftover is cash/T-bills. Pure MMF → 1.0; levered → < 0.
|
||||||
|
Labeled "cash (net posn)". `factors.cash_yield()` removed.
|
||||||
|
- Alphas in the app are now true excess-return alphas: intercept of
|
||||||
|
(fund − rf) on (sleeves − rf).
|
||||||
|
|
||||||
|
**Verification (probes):** qcmmrx (money market): alpha −0.17%/yr
|
||||||
|
(t=−1.3, noise), cash 1.00. vsbsx (short-T index): alpha 0.03%, cash
|
||||||
|
0.77 + short duration. bggsx: Σβ 1.25 (was 3.7), alpha −3.6%
|
||||||
|
(t=−0.6). IVV: alpha 0.00% (t=+36.7). cosix full-window fixed
|
||||||
|
(R² 0.60, vweax 0.45/agg 0.15/ief 0.15).
|
||||||
|
|
||||||
|
**Consequence — the CANDIDATE list shrank 250 → 11.** Most of the old
|
||||||
|
250 were under-invested funds (bond funds with Σβ<1) whose "alpha" was
|
||||||
|
the T-bill yield on their uninvested portion. The 11 that survive are
|
||||||
|
funds whose excess alpha is real. Verdict mix now: 1339 sleeve mix,
|
||||||
|
1001 weak/unstable alpha, 11 CANDIDATE, 10 correlated, 6 not
|
||||||
|
persistent, 17 no 5y window.
|
||||||
|
|
||||||
|
**Reference visibility (the "what is alpha computed vs" question):**
|
||||||
|
alpha is NOT vs one index — it is vs the fund's own fitted sleeve mix
|
||||||
|
(BIC forward selection). fundlab/refback.py recomputes the forward
|
||||||
|
selection per fund and stores `ref_5y`/`ref_full` strings in
|
||||||
|
search_all.json; the app's alpha-search table now has a "reference
|
||||||
|
(5y)" column.
|
||||||
|
|
||||||
|
**Backups of the raw-alpha era (NOT deleted):**
|
||||||
|
factor_results_rawalpha.json, decompose_results_rawalpha.json,
|
||||||
|
search_all_rawalpha.json, cluster_kmeans_rawalpha.json.
|
||||||
|
|
|
||||||
|
|
@ -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 AXES, DRIVERS, cash_yield # noqa: E402
|
from fundlab.factors import AXES, DRIVERS # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def log(msg: str) -> None:
|
def log(msg: str) -> None:
|
||||||
|
|
@ -39,7 +39,6 @@ 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
|
||||||
|
|
@ -51,13 +50,11 @@ 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
|
# virtual CASH axis = net cash position = 1 - sum of the betas.
|
||||||
# its yield capture - for a cash fund that IS the exposure,
|
# Alphas are in excess of the T-bill rate, so the betas are the
|
||||||
# because OLS puts a near-constant yield level in the intercept
|
# NET INVESTED mix; whatever is left over sits in cash/T-bills.
|
||||||
# rather than in the (small) shv/bil slope. Normalize by the
|
# Pure money-market fund: all betas ~ 0 -> cash ~ 1.
|
||||||
# recent cash yield so 1.0 == "earns the cash rate".
|
row = np.append(row, 1.0 - row.sum())
|
||||||
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)
|
||||||
|
|
@ -148,11 +145,11 @@ 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)",
|
"cash": "cash (net posn)",
|
||||||
}
|
}
|
||||||
|
|
||||||
# the CASH axis is a yield LEVEL (alpha/cash-yield, 1.0 = earns the cash
|
# the CASH axis is a POSITION (net cash, 1.0 = fully in cash), not a
|
||||||
# rate), not a return SENSITIVITY like the betas. In raw Euclidean space 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
|
# 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
|
# balanced/target-date funds and k-means swallows it. Emphasize the axis
|
||||||
# for DISTANCE only (labels still use the raw values).
|
# for DISTANCE only (labels still use the raw values).
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
1
fundlab/cluster_kmeans_rawalpha.json
Normal file
1
fundlab/cluster_kmeans_rawalpha.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -22,6 +22,7 @@ intercept; alphas are annualized (x252); t-stats use the OLS covariance.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -49,6 +50,29 @@ def adj_close(sym: str, root: Path = DATA) -> pd.Series | None:
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def rf_series() -> pd.Series | None:
|
||||||
|
"""Daily risk-free rate: BIL (SPDR 1-3 Month T-Bill) total return —
|
||||||
|
the local stand-in for the 3-month T-bill yield. Alphas are computed
|
||||||
|
in EXCESS of this rate (both the fund and the sleeves are netted
|
||||||
|
against it), so the intercept is a true excess-return alpha and a
|
||||||
|
cash position contributes zero (cash IS the risk-free rate). 0 where
|
||||||
|
BIL has no history (pre-2007)."""
|
||||||
|
a = adj_close("bil")
|
||||||
|
if a is None:
|
||||||
|
return None
|
||||||
|
return a.pct_change().fillna(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def excess(panel: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Net every column against the daily risk-free rate."""
|
||||||
|
rf = rf_series()
|
||||||
|
if rf is None:
|
||||||
|
return panel
|
||||||
|
rf = rf.reindex(panel.index).fillna(0.0)
|
||||||
|
return panel.sub(rf, axis=0)
|
||||||
|
|
||||||
|
|
||||||
def returns_panel(symbols: list[str], start: str | None = None) -> pd.DataFrame:
|
def returns_panel(symbols: list[str], start: str | None = None) -> pd.DataFrame:
|
||||||
"""Daily simple returns, outer-joined on dates (NaN where a series
|
"""Daily simple returns, outer-joined on dates (NaN where a series
|
||||||
has no data). Callers must handle missing values per regressor —
|
has no data). Callers must handle missing values per regressor —
|
||||||
|
|
@ -207,9 +231,17 @@ CANDIDATES: dict[str, list[str]] = {
|
||||||
}
|
}
|
||||||
# share classes: same underlying fund
|
# share classes: same underlying fund
|
||||||
ALIAS = {"pmfkx": "pmaix", "lcrix": "lcorx", "egrsx": "eagmx"}
|
ALIAS = {"pmfkx": "pmaix", "lcrix": "lcorx", "egrsx": "eagmx"}
|
||||||
FULL_WINDOW = "1990-01-01" # effectively all history
|
FULL_WINDOW = "2007-06-01" # from BIL inception: alphas are in excess of
|
||||||
|
# the T-bill rate, which needs the rf series (raw pre-2007 returns
|
||||||
|
# mixed with excess post-2007 returns breaks the full-window fit)
|
||||||
RECENT_WINDOW = "2021-01-01" # last ~5 years
|
RECENT_WINDOW = "2021-01-01" # last ~5 years
|
||||||
|
|
||||||
|
# sleeves that ARE the risk-free rate. Alphas are now computed in excess of
|
||||||
|
# the T-bill rate, so these columns are ~0 in excess space and would make
|
||||||
|
# the regression ill-conditioned. Cash exposure is captured by the residual
|
||||||
|
# (1 - sum of betas) instead, so they are dropped from the regressors.
|
||||||
|
CASH_SLEEVES = {"shv", "bil"}
|
||||||
|
|
||||||
|
|
||||||
def _drift(rb: pd.DataFrame, full_beta: np.ndarray) -> dict[str, float]:
|
def _drift(rb: pd.DataFrame, full_beta: np.ndarray) -> dict[str, float]:
|
||||||
"""Mean |rolling beta - full-sample beta| per component, as a fraction
|
"""Mean |rolling beta - full-sample beta| per component, as a fraction
|
||||||
|
|
@ -225,13 +257,14 @@ def _drift(rb: pd.DataFrame, full_beta: np.ndarray) -> dict[str, float]:
|
||||||
def decompose(fund: str, start: str = FULL_WINDOW,
|
def decompose(fund: str, start: str = FULL_WINDOW,
|
||||||
candidates: dict[str, list[str]] | None = None) -> dict:
|
candidates: dict[str, list[str]] | None = None) -> dict:
|
||||||
cand_syms = (candidates or CANDIDATES).get(fund, [])
|
cand_syms = (candidates or CANDIDATES).get(fund, [])
|
||||||
r = returns_panel([fund] + cand_syms, start=start)
|
r = excess(returns_panel([fund] + cand_syms, start=start))
|
||||||
if fund not in r.columns:
|
if fund not in r.columns:
|
||||||
return {"fund": fund, "error": "no return history in the data set",
|
return {"fund": fund, "error": "no return history in the data set",
|
||||||
"verdict": "no return history", "start": start}
|
"verdict": "no return history", "start": start}
|
||||||
y = r[fund].to_numpy()
|
y = r[fund].to_numpy()
|
||||||
dates = r.index
|
dates = r.index
|
||||||
cand = {s: r[s].to_numpy() for s in r.columns if s != fund}
|
cand = {s: r[s].to_numpy() for s in r.columns
|
||||||
|
if s != fund and s not in CASH_SLEEVES}
|
||||||
y_ok = ~np.isnan(y)
|
y_ok = ~np.isnan(y)
|
||||||
if y_ok.sum() < 252:
|
if y_ok.sum() < 252:
|
||||||
return {"fund": fund, "error": "insufficient return history",
|
return {"fund": fund, "error": "insufficient return history",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
1340
fundlab/decompose_results_rawalpha.json
Normal file
1340
fundlab/decompose_results_rawalpha.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
1
fundlab/factor_results_rawalpha.json
Normal file
1
fundlab/factor_results_rawalpha.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -69,33 +69,20 @@ SLEEVES_V2 = list(dict.fromkeys(BROAD_SLEEVES + EXTRA_SLEEVES))
|
||||||
# short-rate exposure as huge offsetting shv/bil coefficients (shv +64 /
|
# short-rate exposure as huge offsetting shv/bil coefficients (shv +64 /
|
||||||
# bil -54) - shv (1-3m) alone carries the cash/ultra-short axis, shy
|
# bil -54) - shv (1-3m) alone carries the cash/ultra-short axis, shy
|
||||||
# keeps the 1-3y axis.
|
# keeps the 1-3y axis.
|
||||||
DROP_FROM_REGRESSION = {"vea", "vug", "finux", "bil"}
|
DROP_FROM_REGRESSION = {"vea", "vug", "finux", "bil", "shv"}
|
||||||
|
# (bil/shv are the risk-free sleeves - alphas are now computed in
|
||||||
|
# excess of the T-bill rate, where they are ~0 columns; the cash
|
||||||
|
# position shows up in the residual / the virtual CASH axis instead)
|
||||||
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.
|
# 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
|
# Alphas are computed in EXCESS of the T-bill rate (decompose.excess), so
|
||||||
# the INTERCEPT (alpha_ann), not in any beta - BIL/SHV betas only measure
|
# the beta vector is the fund's NET INVESTED mix and the leftover
|
||||||
# the fund's response to rate CHANGES. The CASH axis makes the yield
|
# (1 - sum of betas) is its cash/T-bill position: a pure money-market
|
||||||
# capture an explicit driver: alpha_ann normalized by the recent cash
|
# fund has all betas ~ 0 -> CASH ~ 1, a levered fund -> CASH < 0.
|
||||||
# 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"]
|
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)
|
||||||
Xa = np.column_stack([np.ones(m.sum())] + [X[c][m].to_numpy()
|
Xa = np.column_stack([np.ones(m.sum())] + [X[c][m].to_numpy()
|
||||||
|
|
@ -116,6 +103,7 @@ def _panel() -> pd.DataFrame:
|
||||||
log(f"building driver panel ({len(DRIVERS)} axes)")
|
log(f"building driver panel ({len(DRIVERS)} axes)")
|
||||||
p = decompose.returns_panel(SLEEVES_V2).sort_index()
|
p = decompose.returns_panel(SLEEVES_V2).sort_index()
|
||||||
p = p[~p.index.duplicated(keep="last")][DRIVERS]
|
p = p[~p.index.duplicated(keep="last")][DRIVERS]
|
||||||
|
p = decompose.excess(p) # alphas are in excess of the T-bill rate
|
||||||
# pure vol axis: vblix residualized on the core equity/duration axes
|
# pure vol axis: vblix residualized on the core equity/duration axes
|
||||||
core = [c for c in ("ivv", "tlt") if c in p.columns]
|
core = [c for c in ("ivv", "tlt") if c in p.columns]
|
||||||
if "vblix" in p.columns and core:
|
if "vblix" in p.columns and core:
|
||||||
|
|
@ -165,11 +153,16 @@ def _beta_window(y: pd.Series, X: pd.DataFrame, start: str) -> dict | None:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def factor_screen(sym: str, start: str = "1990-01-01") -> dict | None:
|
def factor_screen(sym: str, start: str = None) -> dict | None:
|
||||||
|
if start is None:
|
||||||
|
start = decompose.FULL_WINDOW
|
||||||
fund = decompose.adj_close(sym)
|
fund = decompose.adj_close(sym)
|
||||||
if fund is None or len(fund) < 250:
|
if fund is None or len(fund) < 250:
|
||||||
return None
|
return None
|
||||||
y = fund.pct_change().dropna()
|
y = fund.pct_change().dropna()
|
||||||
|
rf = decompose.rf_series()
|
||||||
|
if rf is not None:
|
||||||
|
y = y.sub(rf.reindex(y.index).fillna(0.0))
|
||||||
X = _panel().reindex(y.index)
|
X = _panel().reindex(y.index)
|
||||||
# sleeves must have history for most of the fund's window
|
# sleeves must have history for most of the fund's window
|
||||||
avail = [c for c in X.columns if X[c].notna().mean() > 0.3]
|
avail = [c for c in X.columns if X[c].notna().mean() > 0.3]
|
||||||
|
|
|
||||||
14
fundlab/factors_run.log
Normal file
14
fundlab/factors_run.log
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
15:03:47 factor screen: 2384 funds, 2384 to do
|
||||||
|
15:03:47 building driver panel (34 axes)
|
||||||
|
15:03:55 200/2384
|
||||||
|
15:04:02 400/2384
|
||||||
|
15:04:10 600/2384
|
||||||
|
15:04:18 800/2384
|
||||||
|
15:04:30 1000/2384
|
||||||
|
15:04:43 1200/2384
|
||||||
|
15:04:52 1400/2384
|
||||||
|
15:04:59 1600/2384
|
||||||
|
15:05:07 1800/2384
|
||||||
|
15:05:14 2000/2384
|
||||||
|
15:05:21 2200/2384
|
||||||
|
15:05:28 factor screen done: 2384 funds -> factor_results.json
|
||||||
1
fundlab/overnight.pid
Normal file
1
fundlab/overnight.pid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
5847
|
||||||
36
fundlab/overnight_rescreen.log
Normal file
36
fundlab/overnight_rescreen.log
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
15:09:56 === stage screen ===
|
||||||
|
15:09:56 screen: 2384 funds, 2384 to do
|
||||||
|
15:10:29 screen: 100/2384
|
||||||
|
15:11:01 screen: 200/2384
|
||||||
|
15:11:33 screen: 300/2384
|
||||||
|
15:12:04 screen: 400/2384
|
||||||
|
15:12:36 screen: 500/2384
|
||||||
|
15:13:09 screen: 600/2384
|
||||||
|
15:13:42 screen: 700/2384
|
||||||
|
15:14:15 screen: 800/2384
|
||||||
|
15:14:45 screen: 900/2384
|
||||||
|
15:15:20 screen: 1000/2384
|
||||||
|
15:15:56 screen: 1100/2384
|
||||||
|
15:16:31 screen: 1200/2384
|
||||||
|
15:17:07 screen: 1300/2384
|
||||||
|
15:17:38 screen: 1400/2384
|
||||||
|
15:18:14 screen: 1500/2384
|
||||||
|
15:18:45 screen: 1600/2384
|
||||||
|
15:19:16 screen: 1700/2384
|
||||||
|
15:19:47 screen: 1800/2384
|
||||||
|
15:20:19 screen: 1900/2384
|
||||||
|
15:20:51 screen: 2000/2384
|
||||||
|
15:21:23 screen: 2100/2384
|
||||||
|
15:21:54 screen: 2200/2384
|
||||||
|
15:22:26 screen: 2300/2384
|
||||||
|
15:22:53 screen done: 2384 funds
|
||||||
|
15:22:53 === stage finalize ===
|
||||||
|
15:22:53 finalize: 2384 funds screened
|
||||||
|
15:22:53 1339 sleeve mix
|
||||||
|
15:22:53 1001 weak/unstable alpha
|
||||||
|
15:22:53 17 no 5y window
|
||||||
|
15:22:53 11 CANDIDATE
|
||||||
|
15:22:53 10 alpha, but correlated with current portfolio
|
||||||
|
15:22:53 6 alpha in 5y window, but not persistent
|
||||||
|
15:22:53 candidates v1 name-filter would have missed: ['aguax', 'etsix', 'femdx', 'hicox', 'prfrx', 'puls', 'rctix', 'rpifx', 'scfzx']
|
||||||
|
15:22:53 === overnight run finished in 0.2h ===
|
||||||
98
fundlab/refback.log
Normal file
98
fundlab/refback.log
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
[2026-08-30 15:40:42] rows=2384 todo=2384
|
||||||
|
[2026-08-30 15:40:48] 25/2384 done (saved)
|
||||||
|
[2026-08-30 15:40:53] 50/2384 done (saved)
|
||||||
|
[2026-08-30 15:40:59] 75/2384 done (saved)
|
||||||
|
[2026-08-30 15:41:04] 100/2384 done (saved)
|
||||||
|
[2026-08-30 15:41:10] 125/2384 done (saved)
|
||||||
|
[2026-08-30 15:41:16] 150/2384 done (saved)
|
||||||
|
[2026-08-30 15:41:22] 175/2384 done (saved)
|
||||||
|
[2026-08-30 15:41:28] 200/2384 done (saved)
|
||||||
|
[2026-08-30 15:41:33] 225/2384 done (saved)
|
||||||
|
[2026-08-30 15:41:39] 250/2384 done (saved)
|
||||||
|
[2026-08-30 15:41:45] 275/2384 done (saved)
|
||||||
|
[2026-08-30 15:41:51] 300/2384 done (saved)
|
||||||
|
[2026-08-30 15:41:57] 325/2384 done (saved)
|
||||||
|
[2026-08-30 15:42:03] 350/2384 done (saved)
|
||||||
|
[2026-08-30 15:42:08] 375/2384 done (saved)
|
||||||
|
[2026-08-30 15:42:14] 400/2384 done (saved)
|
||||||
|
[2026-08-30 15:42:20] 425/2384 done (saved)
|
||||||
|
[2026-08-30 15:42:26] 450/2384 done (saved)
|
||||||
|
[2026-08-30 15:42:32] 475/2384 done (saved)
|
||||||
|
[2026-08-30 15:42:37] 500/2384 done (saved)
|
||||||
|
[2026-08-30 15:42:42] 525/2384 done (saved)
|
||||||
|
[2026-08-30 15:42:48] 550/2384 done (saved)
|
||||||
|
[2026-08-30 15:42:54] 575/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:00] 600/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:06] 625/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:11] 650/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:18] 675/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:24] 700/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:29] 725/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:36] 750/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:42] 775/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:48] 800/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:53] 825/2384 done (saved)
|
||||||
|
[2026-08-30 15:43:59] 850/2384 done (saved)
|
||||||
|
[2026-08-30 15:44:05] 875/2384 done (saved)
|
||||||
|
[2026-08-30 15:44:10] 900/2384 done (saved)
|
||||||
|
[2026-08-30 15:44:16] 925/2384 done (saved)
|
||||||
|
[2026-08-30 15:44:22] 950/2384 done (saved)
|
||||||
|
[2026-08-30 15:44:28] 975/2384 done (saved)
|
||||||
|
[2026-08-30 15:44:34] 1000/2384 done (saved)
|
||||||
|
[2026-08-30 15:44:39] 1025/2384 done (saved)
|
||||||
|
[2026-08-30 15:44:45] 1050/2384 done (saved)
|
||||||
|
[2026-08-30 15:44:51] 1075/2384 done (saved)
|
||||||
|
[2026-08-30 15:44:56] 1100/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:02] 1125/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:08] 1150/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:14] 1175/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:19] 1200/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:25] 1225/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:31] 1250/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:36] 1275/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:42] 1300/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:47] 1325/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:53] 1350/2384 done (saved)
|
||||||
|
[2026-08-30 15:45:59] 1375/2384 done (saved)
|
||||||
|
[2026-08-30 15:46:04] 1400/2384 done (saved)
|
||||||
|
[2026-08-30 15:46:10] 1425/2384 done (saved)
|
||||||
|
[2026-08-30 15:46:16] 1450/2384 done (saved)
|
||||||
|
[2026-08-30 15:46:22] 1475/2384 done (saved)
|
||||||
|
[2026-08-30 15:46:28] 1500/2384 done (saved)
|
||||||
|
[2026-08-30 15:46:33] 1525/2384 done (saved)
|
||||||
|
[2026-08-30 15:46:38] 1550/2384 done (saved)
|
||||||
|
[2026-08-30 15:46:44] 1575/2384 done (saved)
|
||||||
|
[2026-08-30 15:46:49] 1600/2384 done (saved)
|
||||||
|
[2026-08-30 15:46:55] 1625/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:01] 1650/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:06] 1675/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:12] 1700/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:18] 1725/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:24] 1750/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:30] 1775/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:35] 1800/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:41] 1825/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:47] 1850/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:53] 1875/2384 done (saved)
|
||||||
|
[2026-08-30 15:47:59] 1900/2384 done (saved)
|
||||||
|
[2026-08-30 15:48:05] 1925/2384 done (saved)
|
||||||
|
[2026-08-30 15:48:10] 1950/2384 done (saved)
|
||||||
|
[2026-08-30 15:48:16] 1975/2384 done (saved)
|
||||||
|
[2026-08-30 15:48:22] 2000/2384 done (saved)
|
||||||
|
[2026-08-30 15:48:28] 2025/2384 done (saved)
|
||||||
|
[2026-08-30 15:48:33] 2050/2384 done (saved)
|
||||||
|
[2026-08-30 15:48:39] 2075/2384 done (saved)
|
||||||
|
[2026-08-30 15:48:45] 2100/2384 done (saved)
|
||||||
|
[2026-08-30 15:48:51] 2125/2384 done (saved)
|
||||||
|
[2026-08-30 15:48:57] 2150/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:03] 2175/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:09] 2200/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:15] 2225/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:21] 2250/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:27] 2275/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:33] 2300/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:38] 2325/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:44] 2350/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:50] 2375/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:52] 2384/2384 done (saved)
|
||||||
|
[2026-08-30 15:49:52] done: 2384 rows carry a reference
|
||||||
76
fundlab/refback.py
Normal file
76
fundlab/refback.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Backfill the per-fund alpha REFERENCE into search_all.json.
|
||||||
|
|
||||||
|
The alpha search verdict (fundlab/search.py) computes alpha as the OLS
|
||||||
|
intercept of the fund's daily total returns against the sleeves BIC
|
||||||
|
forward-selection picked from the 21 broad sleeve axes (the same set for
|
||||||
|
every fund). The picked sleeves ARE the fund's reference - a fitted
|
||||||
|
sleeve mix, not a single index - but the screen never stored them, so
|
||||||
|
the app couldn't show them.
|
||||||
|
|
||||||
|
This script recomputes the forward selection (full + last-5y) for every
|
||||||
|
screened fund and stores a compact, human-readable reference string on
|
||||||
|
each row:
|
||||||
|
|
||||||
|
ref_5y "ivv +0.62, tlt +0.31" (sleeves picked for the 5y model)
|
||||||
|
ref_full "ivv +0.38, efa +0.15, ..."
|
||||||
|
"pure alpha (no sleeve selected)" when the model has no components
|
||||||
|
|
||||||
|
Resumable: rows that already carry ref_5y are skipped.
|
||||||
|
|
||||||
|
Run: .venv/bin/python -m fundlab.refback (logs to fundlab/refback.log)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fundlab import decompose
|
||||||
|
from fundlab.searchlist import BROAD_SLEEVES
|
||||||
|
|
||||||
|
HERE = __import__("pathlib").Path(__file__).parent
|
||||||
|
SRC = HERE / "search_all.json"
|
||||||
|
LOG = HERE / "refback.log"
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
||||||
|
print(line, flush=True)
|
||||||
|
with LOG.open("a") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(m: dict) -> str:
|
||||||
|
comps = m.get("components") or []
|
||||||
|
if not comps:
|
||||||
|
return "pure alpha (no sleeve selected)"
|
||||||
|
return ", ".join(f"{c['sym']} {c['beta']:+.2f}" for c in comps)
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
d = json.loads(SRC.read_text())
|
||||||
|
todo = [k for k, v in d.items()
|
||||||
|
if isinstance(v, dict)
|
||||||
|
and isinstance(v.get("r2_5y"), (int, float))
|
||||||
|
and "ref_5y" not in v]
|
||||||
|
log(f"rows={len(d)} todo={len(todo)}")
|
||||||
|
for i, k in enumerate(todo, 1):
|
||||||
|
sym = d[k]["sym"]
|
||||||
|
try:
|
||||||
|
rec = decompose.decompose(sym, start=decompose.RECENT_WINDOW,
|
||||||
|
candidates={sym: BROAD_SLEEVES})
|
||||||
|
full = decompose.decompose(sym, candidates={sym: BROAD_SLEEVES})
|
||||||
|
d[k]["ref_5y"] = _fmt(rec)
|
||||||
|
d[k]["ref_full"] = _fmt(full)
|
||||||
|
except Exception as e: # noqa: BLE001 - batch must not die
|
||||||
|
d[k]["ref_5y"] = f"(backfill failed: {e})"
|
||||||
|
if i % 25 == 0 or i == len(todo):
|
||||||
|
SRC.write_text(json.dumps(d, indent=1))
|
||||||
|
log(f"{i}/{len(todo)} done (saved)")
|
||||||
|
SRC.write_text(json.dumps(d, indent=1))
|
||||||
|
n = sum(1 for v in d.values()
|
||||||
|
if isinstance(v, dict) and v.get("ref_5y"))
|
||||||
|
log(f"done: {n} rows carry a reference")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
47683
fundlab/search_all.json
47683
fundlab/search_all.json
File diff suppressed because one or more lines are too long
1
fundlab/search_all_rawalpha.json
Normal file
1
fundlab/search_all_rawalpha.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user