f/fundlab/refback.py
Greg Pomerantz 89674c24dd 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).
2026-08-30 15:50:15 -04:00

77 lines
2.6 KiB
Python

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