Style-tilt battery + commentary in the fund report pipeline

fundlab/styletilt.py: 22 style/asset sleeves regressed on excess-of-T-bill
returns (full history + 5y); BIC forward selection identifies the tilt
stack; residual-alpha verdict ('factor exposure, not skill' when t<1.75);
data-driven English commentary with sign-specific phrasing. Rendered as a
'Style tilts' block (factor table + prose) in both the app and the HTML
report. All 24 pre-built + 8 ad-hoc fund reports rebuilt.
This commit is contained in:
Greg Pomerantz 2026-08-30 20:36:04 -04:00
parent d2ff607c79
commit 2bbe4e58ec
7 changed files with 395 additions and 25 deletions

23
app.py
View File

@ -644,6 +644,29 @@ def render_fund_report(_f: dict) -> None:
"itself.") "itself.")
if _f.get("drivers"): if _f.get("drivers"):
st.markdown("\n\n".join(_f["drivers"])) st.markdown("\n\n".join(_f["drivers"]))
_sty = _f.get("style")
if _sty and _sty.get("full"):
_f5 = {x["sym"]: x for x in _sty.get("rec5", {}).get("factors", [])}
_srows = []
for x in _sty["full"]["factors"]:
y5 = _f5.get(x["sym"], {})
if abs(x["t"]) < 2 and abs(y5.get("t", 0) or 0) < 2:
continue
_srows.append([
x["name"], f"{x['beta']:+.2f}", f"{x['t']:+.1f}",
(f"{y5['beta']:+.2f}" if y5 else ""),
(f"{y5['t']:+.1f}" if y5 else ""),
"" if x["sym"] in _sty.get("identified", []) else ""])
st.markdown("**Style tilts** (22 style/asset sleeves regressed "
"on the fund's excess returns; US funds proxy the "
"global factors; 'identified' = survived the BIC "
"forward-selection gate on the full history)")
st.dataframe(pd.DataFrame(
_srows,
columns=["factor", "β full", "t full", "β 5y", "t 5y",
"identified"]),
width="stretch", hide_index=True)
st.markdown("\n\n".join(_sty.get("commentary", [])))
_ref = _f.get("reference") _ref = _f.get("reference")
if _ref and _ref.get("rows"): if _ref and _ref.get("rows"):
st.markdown(f"**Reference mix** ({_ref['netcash']})") st.markdown(f"**Reference mix** ({_ref['netcash']})")

View File

@ -1163,3 +1163,23 @@ pre-built 24), the per-fund rendering was extracted into render_fund_report()
shared by both, and a pointer at the top of the page tells the user where shared by both, and a pointer at the top of the page tells the user where
the generated reports are. ETFs with no name on file render as "A01 · TLT" the generated reports are. ETFs with no name on file render as "A01 · TLT"
(no duplicated name). (no duplicated name).
**Style-tilt battery in the report pipeline (2026-08-30, 3rd pass).**
`fundlab/styletilt.py`: 22 style/asset sleeves (EFA/VWO, growth/value,
IWM/RSP size+equal-weight, USMV/DFLVX low-vol, QUAL, MTUM, HDV/VYM
dividend, TLT, GLD, FXE/FXY/FXU, the five big sectors) regressed on the
fund's excess-of-T-bill returns, full history + 5y. Plain OLS gives every
factor's β + t (exploratory); BIC forward selection gives the IDENTIFIED
tilt stack (strict gate); residual alpha after the identified stack says
"skill left or pure factor exposure". Data-driven commentary: tilt
sentences with strength words (t≥10 strong / ≥5 clear / ≥2 modest),
sign-specific phrasing for the tricky ones (short-euro = USD carry,
anti-quality = flip side of high payout, anti-momentum = contrarian),
5y-only factors called out separately, and an explicit residual-verdict
paragraph. Wired as a "Style tilts" block (factor table + commentary)
into both report builders (app + HTML); ~0.1-0.8 s per fund after the
one-time battery-panel build. All 24 pre-built + 8 ad-hoc reports
rebuilt (23/24 have the block; lcorx has no history). Worked example
(lvhi): identified stack = intl-dev 0.78 + short EUR 0.42 + HDV +0.28
(t+20) + anti-QUAL 0.21 (t-12) + short FXY + short VWO; residual alpha
+2.3%/y t=+1.1 -> "factor exposure, not skill".

View File

@ -663,6 +663,33 @@ def strategy_block(sym: str) -> str:
return out return out
def style_block(sym: str) -> str:
from fundlab import styletilt
prof = styletilt.style_profile(sym)
if not prof or not prof.get("full"):
return ""
f5 = {x["sym"]: x for x in (prof.get("rec5") or {}).get("factors", [])}
rows = []
for x in prof["full"]["factors"]:
y5 = f5.get(x["sym"], {})
if abs(x["t"]) < 2 and abs(y5.get("t", 0) or 0) < 2:
continue
rows.append(
f"<tr><td>{html.escape(x['name'])}</td>"
f"<td>{x['beta']:+.2f}</td><td>{x['t']:+.1f}</td>"
f"<td>{y5['beta']:+.2f}</td><td>{y5['t']:+.1f}</td>"
f"<td>{'&#10003;' if x['sym'] in prof.get('identified', []) else ''}</td></tr>")
tbl = ('<table><tr><th>factor</th><th>β full</th><th>t full</th>'
'<th>β 5y</th><th>t 5y</th><th>identified</th></tr>'
+ "".join(rows) + "</table>")
para = "".join(f"<p>{html.escape(p)}</p>" for p in prof.get("commentary", []))
return ('<h4>Style tilts</h4>'
'<p class="small">22 style/asset sleeves regressed on the '
'excess returns of the fund (US funds proxy the global '
'factors); "identified" = survived the BIC forward-selection '
'gate on the full history.</p>' + tbl + para)
def narrative_block(sym: str) -> str: def narrative_block(sym: str) -> str:
from fundlab.narrative import narrate from fundlab.narrative import narrate
return "".join(f"<p>{html.escape(par)}</p>" for par in narrate(sym)) return "".join(f"<p>{html.escape(par)}</p>" for par in narrate(sym))
@ -698,6 +725,7 @@ def fund_section(sym: str, title: str, subtitle: str) -> str:
{perf_table(price(sym), refs_curve) if price(sym) is not None else '<p>no price data</p>'} {perf_table(price(sym), refs_curve) if price(sym) is not None else '<p>no price data</p>'}
<h4>What drove the returns</h4> <h4>What drove the returns</h4>
{drivers_section(fr, dr, srow)} {drivers_section(fr, dr, srow)}
{style_block(sym)}
<h4>The reference mix - and what it exposes you to</h4> <h4>The reference mix - and what it exposes you to</h4>
{reference_section(refs, refs_curve, sym)} {reference_section(refs, refs_curve, sym)}
<h4>Tax character &amp; placement</h4> <h4>Tax character &amp; placement</h4>

View File

@ -24,6 +24,7 @@ import pandas as pd
from fundlab import report as _r from fundlab import report as _r
from fundlab import decompose from fundlab import decompose
from fundlab import styletilt
from fundlab.narrative import narrate from fundlab.narrative import narrate
HERE = Path(__file__).parent HERE = Path(__file__).parent
@ -348,6 +349,10 @@ def build_fund(sym: str, group: str, order: int) -> dict:
"tax": _tax(sym), "tax": _tax(sym),
"peers": _peers(sym, fr), "peers": _peers(sym, fr),
} }
try:
d["style"] = styletilt.style_profile(sym)
except Exception:
d["style"] = None
print(f" {sym:8s} {time.time() - t0:5.1f}s", flush=True) print(f" {sym:8s} {time.time() - t0:5.1f}s", flush=True)
return d return d
@ -374,6 +379,19 @@ def main() -> None:
data["funds"][s] = build_fund(s, "shortlist", i) data["funds"][s] = build_fund(s, "shortlist", i)
OUT.parent.mkdir(exist_ok=True) OUT.parent.mkdir(exist_ok=True)
OUT.write_text(json.dumps(data)) OUT.write_text(json.dumps(data))
# rebuild any on-demand (Symbol-box) reports so they gain the new
# sections too; keep their "adhoc" group
if ADHOC_FILE.exists():
try:
_old = json.loads(ADHOC_FILE.read_text())["funds"]
except Exception:
_old = {}
for j, s in enumerate(_old, 1):
if s in cand or s in short:
continue
_old[s] = build_fund(s, "adhoc", order=-j)
ADHOC_FILE.write_text(json.dumps({"funds": _old}))
print(f"rebuilt {len(_old)} ad-hoc reports")
print(f"wrote {OUT} in {time.time() - t0:.0f}s " print(f"wrote {OUT} in {time.time() - t0:.0f}s "
f"({OUT.stat().st_size / 1e6:.1f} MB, {len(data['funds'])} funds)") f"({OUT.stat().st_size / 1e6:.1f} MB, {len(data['funds'])} funds)")

257
fundlab/styletilt.py Normal file
View File

@ -0,0 +1,257 @@
"""Style-tilt battery: mathematical factor identification for one fund.
Regresses the fund's daily excess-of-T-bill returns on a battery of 22
style/asset sleeves (US funds proxying the global style factors; a fund
expresses them in its own universe, so betas are the right sign and
magnitude but approximate), over the full history and the last 5 years:
* plain OLS -> every factor's beta + t-stat (exploratory view)
* BIC forward selection -> the IDENTIFIED tilt stack (the strict gate:
a factor is only in if it measurably improves the model)
* residual alpha after the identified stack -> "is there skill left?"
Plus data-driven English commentary (fundlab.styletilt.commentary).
Caveats stated in the commentary, not hidden: US sleeves proxy global
factors; style sleeves are mutually collinear, so single-OLS t-stats are
indicative and the BIC set is the identification.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from fundlab import decompose
# factor label -> sleeve ticker (US proxies for the global factors)
BATTERY: dict[str, str] = {
"int-dev (EFA)": "efa", "EM (VWO)": "vwo", "growth (VUG)": "vug",
"value (VTV)": "vtv", "small (IWM)": "iwm", "equalwt (RSP)": "rsp",
"lowvol (USMV)": "usmv", "lowvol (DFLVX)": "dflvx",
"quality (QUAL)": "qual", "momentum (MTUM)": "mtum",
"dividend (HDV)": "hdv", "div-apprec (VYM)": "vym",
"longdur (TLT)": "tlt", "gold (GLD)": "gld",
"EUR (FXE)": "fxe", "JPY (FXY)": "fxy", "AUD (FXU)": "fxu",
"utilities (XLU)": "xlu", "staples (XLP)": "xlp",
"health (XLV)": "xlv", "banks (XLF)": "xlf", "energy (XLE)": "xle",
}
SYMS = list(dict.fromkeys(BATTERY.values()))
INV = {v: k for k, v in BATTERY.items()}
_PANEL: pd.DataFrame | None = None
def _panel() -> pd.DataFrame:
"""Battery sleeves' daily EXCESS-of-T-bill returns, cached per process."""
global _PANEL
if _PANEL is None:
_PANEL = decompose.excess(
decompose.returns_panel(SYMS, start=decompose.FULL_WINDOW))
return _PANEL
def _fund_excess(sym: str) -> pd.Series:
p = decompose.adj_close(sym)
if p is None:
return pd.Series(dtype=float)
y = p.pct_change()
rf = decompose.rf_series()
if rf is not None:
y = y.sub(rf.reindex(y.index).fillna(0.0))
return y
def _fit(y: pd.Series, a: str | None, b: str | None) -> dict:
x = _panel()
idx = x.index
msk = idx >= (a or idx[0])
if b is not None:
msk &= idx <= b
x = x[msk]
y = y.reindex(x.index)
ok = y.notna() & x.notna().all(axis=1)
if ok.sum() < 252:
return {}
X = np.column_stack([np.ones(int(ok.sum()))] +
[x[s][ok].to_numpy() for s in SYMS])
yn = y[ok].to_numpy()
beta, *_ = np.linalg.lstsq(X, yn, rcond=None)
res = yn - X @ beta
dof = max(int(ok.sum()) - len(SYMS) - 1, 1)
sigma2 = float(res @ res) / dof
se = np.sqrt(np.diag(np.linalg.inv(X.T @ X)) * sigma2)
t = np.where(se > 0, beta / se, 0.0)
r2 = 1 - float(res @ res) / float((yn - yn.mean()) @ (yn - yn.mean()))
return {
"n": int(ok.sum()),
"start": str(x.index[ok][0].date()),
"end": str(x.index[ok][-1].date()),
"r2": float(r2),
"alpha_ann": float(beta[0] * 252),
"alpha_t": float(t[0]),
"factors": [
{"name": INV[s], "sym": s, "beta": float(beta[k + 1]),
"t": float(t[k + 1])}
for k, s in enumerate(SYMS)],
}
def _forward(y: pd.Series, a: str | None, b: str | None) -> list[str]:
"""BIC-gated forward selection over the battery -> identified sleeves."""
x = _panel()
idx = x.index
msk = idx >= (a or idx[0])
if b is not None:
msk &= idx <= b
x = x[msk]
y = y.reindex(x.index)
ok = y.notna()
if ok.sum() < 252:
return []
cand = {s: x[s].to_numpy() for s in SYMS}
chosen, _m, _okk = decompose.forward_select(y.to_numpy(), cand, y_ok=ok)
return list(chosen)
# ------------------------------------------------------------- commentary
# human meaning of each sleeve (used for the prose)
MEANING: dict[str, str] = {
"efa": "international developed markets", "vwo": "emerging markets",
"vug": "US growth equities", "vtv": "US value equities",
"iwm": "US small caps", "rsp": "equal-weight (small-tilted) US",
"usmv": "low volatility", "dflvx": "low volatility",
"qual": "quality (high-ROE, low-debt, stable earnings)",
"mtum": "momentum (recent winners)", "hdv": "high dividend",
"vym": "high dividend (appreciation-tilted)",
"tlt": "long-duration Treasuries", "gld": "gold",
"fxe": "the euro", "fxy": "the yen", "fxu": "the Australian dollar",
"xlu": "utilities (defensive bond-proxy)",
"xlp": "consumer staples (defensive)",
"xlv": "healthcare (defensive)", "xlf": "banks/financials",
"xle": "energy",
}
# sign-specific phrasing where the generic 'tilt toward/away from' is wrong
SPECIAL: dict[tuple[str, bool], str] = {
("fxe", False): ("short-euro position that carries the USD/EUR "
"interest-rate differential"),
("fxy", False): ("short-yen position that carries the USD/JPY "
"rate differential"),
("fxu", False): "short-AUD position (USD carry)",
("fxu", True): "long-AUD position (AUD carry)",
("fxe", True): "long-euro position (exposed to EUR moves)",
("fxy", True): "long-yen position (safe-haven/carry)",
("qual", False): ("anti-quality tilt - systematically underweight "
"high-ROE, low-debt names; the mechanical flip side "
"of a high-payout dividend mandate"),
("qual", True): "quality tilt (high-ROE, low-debt, stable earnings)",
("mtum", False): ("anti-momentum character - it does not chase "
"recent winners (low-turnover/contrarian)"),
("mtum", True): "momentum tilt (preference for recent winners)",
("hdv", True): ("high-dividend tilt - a systematic preference for "
"income-paying names"),
("hdv", False): "underweight to high-dividend names",
("vym", True): "dividend-appreciation tilt (growing payers)",
("rsp", False): "cap-weight concentration (tilt away from "
"equal-weight/small)",
("rsp", True): "equal-weight (small-tilted) exposure",
("vwo", False): "developed-only tilt (away from emerging markets)",
("vwo", True): "an emerging-market tilt",
("usmv", True): "low-volatility (defensive beta) tilt",
("dflvx", True): "low-volatility (defensive beta) tilt",
("dflvx", False): "underweight to low-volatility names",
("usmv", False): "underweight to low-volatility names",
}
def _strength(t: float) -> str:
a = abs(t)
return "strong" if a >= 10 else "clear" if a >= 5 else "modest"
def _tilt_sentence(f: dict) -> str:
s = f["sym"]
key = (s, f["beta"] > 0)
if key in SPECIAL:
body = SPECIAL[key]
else:
m = MEANING.get(s, f["name"])
body = (f"tilt toward {m}" if f["beta"] > 0
else f"tilt away from {m}")
return (f"{_strength(f['t'])} {body} "
f"{f['beta']:+.2f}, t {f['t']:+.0f})")
def commentary(sym: str, prof: dict) -> list[str]:
"""English prose for the style-tilt block (data-driven)."""
if not prof or not prof.get("full"):
return []
full, rec5 = prof["full"], prof.get("rec5") or {}
sig_full = {f["sym"]: f for f in full["factors"] if abs(f["t"]) >= 2}
sig_5 = {f["sym"]: f for f in rec5.get("factors", [])
if abs(f["t"]) >= 2}
out = []
# paragraph 1: the identified stack
parts = []
core = sig_full.get("efa")
if core:
parts.append(
f"the core exposure is {abs(core['beta']):.0%} of "
f"international developed equities (t {core['t']:+.0f})")
for f in sorted(sig_full.values(), key=lambda x: -abs(x["t"])):
if f["sym"] == "efa":
continue
parts.append(_tilt_sentence(f))
if parts:
out.append(
f"Style-tilt analysis (22 style/asset sleeves regressed on "
f"the fund's excess returns; US funds proxying the global "
f"factors): over {full['start'][:4]}-to-{full['end'][:4]} the "
f"fit explains {full['r2']:.0%} of the excess return (R² "
f"{full['r2']:.2f}). The statistically identified tilt stack: "
+ "; ".join(parts) + ".")
# factors that only appear in the 5y window
recent_only = [s for s in sig_5
if s not in sig_full and s != "efa"]
if recent_only:
out.append(
"Visible only in the recent 5-year window: "
+ "; ".join(_tilt_sentence(sig_5[s]) for s in recent_only)
+ " - newer behavior, or a factor the longer sample dilutes.")
# paragraph 2: the residual - is there skill left?
a, t = full["alpha_ann"], full["alpha_t"]
if abs(t) < 1.75:
out.append(
f"After stripping the identified tilts, the residual excess "
f"return is {a * 100:+.1f}%/year (t {t:+.1f}) - NOT "
f"statistically significant: the fund's outperformance is "
f"factor exposure, not skill alpha.")
else:
out.append(
f"After stripping the identified tilts, a significant "
f"residual of {a * 100:+.1f}%/year (t {t:+.1f}) remains - "
f"genuine alpha on top of the factor stack.")
out.append(
"Caveat: the sleeves are US funds proxying global factors; the "
"fund expresses them in its own holdings, so the betas are the "
"right sign and magnitude but approximate. An index-matched "
"benchmark (the fund's own published index, when one exists) "
"would absorb part of the residual as well.")
return out
def style_profile(sym: str) -> dict | None:
"""Full + 5y battery fits, the BIC-identified stack, and commentary.
Returns None when the fund has no usable local history."""
y = _fund_excess(sym)
if len(y.dropna()) < 252:
return None
full = _fit(y, None, None)
if not full:
return None
rec5 = _fit(y, "2021-01-01", None)
prof = {"full": full,
"rec5": rec5 or {},
"identified": _forward(y, None, None)}
prof["commentary"] = commentary(sym, prof)
return prof

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long