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:
Greg Pomerantz 2026-08-30 15:50:15 -04:00
parent 9f666538c3
commit 89674c24dd
19 changed files with 50111 additions and 629 deletions

41
app.py
View File

@ -660,6 +660,7 @@ with tab_fundlab:
else ""),
"alpha 5y": (f"{_a5*100:+.1f}% (t={_v['alpha_t_5y']:+.1f})"
if isinstance(_a5, (int, float)) else ""),
"reference (5y)": _v.get("ref_5y") or "",
"corr port": (f"{_v['corr_portfolio']:.2f}"
if isinstance(_v.get("corr_portfolio"),
(int, float)) else ""),
@ -670,14 +671,18 @@ with tab_fundlab:
})
st.dataframe(pd.DataFrame(_tbl), width="stretch")
st.caption(
"Screen: daily total returns vs 21 broad sleeve axes (same "
"set for every fund); 'alpha 5y' = OLS intercept over the "
"last 5 years (t-stat); 'corr port' = correlation with 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.")
"Screen: daily total returns IN EXCESS OF THE 3-MO T-BILL "
"RATE (BIL) vs 21 broad sleeve axes (same set for every "
"fund); 'alpha 5y' = OLS intercept of the excess returns "
"over the last 5 years (t-stat) — i.e. outperformance vs "
"the fund's OWN fitted sleeve mix (the 'reference' column), "
"not vs one index; a cash position earns exactly the t-bill "
"rate and adds zero alpha. 'corr port' = correlation with "
"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 ---
with st.expander(
@ -771,11 +776,14 @@ with tab_fundlab:
r.pop("_t")
st.dataframe(pd.DataFrame(_rows), width="stretch")
st.caption(
"Each fund's daily returns are regressed on 35 sleeve axes "
"(equity styles/sizes/intl/EM, the duration ladder, IG/HY/"
"muni/MBS/preferred/EM-debt credit, sectors, gold/oil/"
"commodities, CTA); the 35-d loading vector is the fund's "
"'return-driver signature' and k-means groups similar "
"Each fund's daily total returns IN EXCESS OF THE 3-MO "
"T-BILL RATE (BIL) are regressed on 34 sleeve axes (equity "
"styles/sizes/intl/EM, the duration ladder, IG/HY/muni/MBS/"
"preferred/EM-debt credit, sectors, gold/oil/commodities, "
"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 - "
"high-corr funds are REPLACEMENTS for current holdings, "
"not rejections.")
@ -1096,9 +1104,10 @@ with tab_fundlab:
if _dr.get("note"):
st.markdown("**Holdings cross-check:** " + _dr["note"])
st.caption(
"Method: daily total returns; greedy forward selection on BIC "
"(add only if ΔBIC ≥ 2 and |t| > 2); candidate sleeves curated per "
"fund from the strategy text + N-PORT. β = exposure, not a literal "
"Method: daily total returns IN EXCESS OF THE 3-MO T-BILL RATE "
"(BIL); greedy forward selection on BIC (add only if ΔBIC ≥ 2 and "
"|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 "
"the full β. High-R² + low drift ≈ static mix; low R² with positive "
"alpha ≈ market-neutral/alpha strategy.")

View File

@ -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).
server_watchdog.sh had to be relaunched manually; consider a
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.

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 AXES, DRIVERS, cash_yield # noqa: E402
from fundlab.factors import AXES, DRIVERS # noqa: E402
def log(msg: str) -> None:
@ -39,7 +39,6 @@ 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
@ -51,13 +50,11 @@ 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))
# virtual CASH axis = net cash position = 1 - sum of the betas.
# Alphas are in excess of the T-bill rate, so the betas are the
# NET INVESTED mix; whatever is left over sits in cash/T-bills.
# Pure money-market fund: all betas ~ 0 -> cash ~ 1.
row = np.append(row, 1.0 - row.sum())
# 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)
@ -148,11 +145,11 @@ SLEEVE_NAME = {
"xlv": "healthcare", "xlp": "staples", "xlu": "utilities",
"xly": "consumer disc", "xlb": "materials", "dbmf": "CTA/futures",
"dbb": "commodities",
"cash": "cash (yield)",
"cash": "cash (net posn)",
}
# 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
# the CASH axis is a POSITION (net cash, 1.0 = fully in cash), 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).

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -22,6 +22,7 @@ intercept; alphas are annualized (x252); t-stats use the OLS covariance.
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
import numpy as np
@ -49,6 +50,29 @@ def adj_close(sym: str, root: Path = DATA) -> pd.Series | None:
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:
"""Daily simple returns, outer-joined on dates (NaN where a series
has no data). Callers must handle missing values per regressor
@ -207,9 +231,17 @@ CANDIDATES: dict[str, list[str]] = {
}
# share classes: same underlying fund
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
# 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]:
"""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,
candidates: dict[str, list[str]] | None = None) -> dict:
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:
return {"fund": fund, "error": "no return history in the data set",
"verdict": "no return history", "start": start}
y = r[fund].to_numpy()
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)
if y_ok.sum() < 252:
return {"fund": fund, "error": "insufficient return history",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -69,33 +69,20 @@ SLEEVES_V2 = list(dict.fromkeys(BROAD_SLEEVES + EXTRA_SLEEVES))
# short-rate exposure as huge offsetting shv/bil coefficients (shv +64 /
# bil -54) - shv (1-3m) alone carries the cash/ultra-short axis, shy
# 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]
# 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).
# Alphas are computed in EXCESS of the T-bill rate (decompose.excess), so
# the beta vector is the fund's NET INVESTED mix and the leftover
# (1 - sum of betas) is its cash/T-bill position: a pure money-market
# fund has all betas ~ 0 -> CASH ~ 1, a levered fund -> CASH < 0.
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)
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)")
p = decompose.returns_panel(SLEEVES_V2).sort_index()
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
core = [c for c in ("ivv", "tlt") if c in p.columns]
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)
if fund is None or len(fund) < 250:
return None
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)
# 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]

14
fundlab/factors_run.log Normal file
View 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
View File

@ -0,0 +1 @@
5847

View 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
View 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
View 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()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long