- fundlab/narrative.py: data-driven English prose per fund (performance, drivers tiered by fit, explicit 'what we do NOT know', bottom line) - fundlab/reportdata.py: static build -> reports/report_data.json - app.py Fund Lab Summary: at-a-glance table + per-fund expanders (narrative, equity curve, period table with fund-ref gap, drivers, reference mix, tax, cluster peers) - fundlab/report.py: narrative in the HTML report; forward-selected reference (weak-fit funds anchor to cash); SLEEVE_DESC exposure explanations - BUG: mix_series() never applied the betas (reference curves were raw sleeve sums; JLPSX 'reference' +407% vs fund +123%) - fixed and all reference curves/tables regenerated - reports/fund_report.html + report_data.json regenerated
445 lines
19 KiB
Python
445 lines
19 KiB
Python
"""Per-fund narrative: English prose explaining the performance and its
|
|
drivers, built from the analysis we have on file.
|
|
|
|
The prose is data-driven (every number comes from the computed results),
|
|
and it is required to be honest about limits: when the return drivers
|
|
are unknown or only weakly identified, the text says so explicitly.
|
|
|
|
Paragraphs:
|
|
1. what the fund is (name, stated strategy if on file, what its
|
|
N-PORT actually holds, which return-driver cluster it sits in)
|
|
2. the performance in plain terms (full history, 5y, recent year,
|
|
behaviour in the defined market episodes, calendar-year pattern)
|
|
3. what is driving it (fit quality -> whose exposures vs idiosyncratic
|
|
alpha; the economic meaning of the top loadings; weight stability)
|
|
4. what we do NOT know (unexplained share, alpha steadiness, sample
|
|
size, holdings opacity) - stated plainly
|
|
5. bottom line (account placement, best cluster peers, role)
|
|
|
|
Run: .venv/bin/python -m fundlab.narrative (prints all narratives)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from fundlab import report as _r
|
|
from fundlab.searchlist import BROAD_SLEEVES
|
|
from fundlab import decompose as _dc
|
|
|
|
HERE = Path(__file__).parent
|
|
|
|
_XC = json.loads((HERE / "xcheck_report.json").read_text()) \
|
|
if (HERE / "xcheck_report.json").exists() else {}
|
|
|
|
|
|
def _pct(x, nd=1) -> str:
|
|
if x is None or (isinstance(x, float) and np.isnan(x)):
|
|
return "no data"
|
|
return f"{100 * x:+.{nd}f}%"
|
|
|
|
|
|
def _num(x, nd=2) -> str:
|
|
if x is None or (isinstance(x, float) and np.isnan(x)):
|
|
return "n/a"
|
|
return f"{x:.{nd}f}"
|
|
|
|
|
|
# short prose names for sleeves whose SLEEVE_DESC lead-in references a
|
|
# sibling ticker ("same HY exposure as VWEAX via a different fund")
|
|
_PROSE_NAME = {
|
|
"vea": "international developed markets",
|
|
"efa": "international developed markets",
|
|
"hyg": "high-yield corporate bonds",
|
|
"vweax": "high-yield corporate bonds",
|
|
"dbb": "broad commodities",
|
|
"gsg": "broad commodities",
|
|
"vblix": "crash vol (VIX futures)",
|
|
"bil": "cash (T-bills)",
|
|
"shv": "cash (T-bills)",
|
|
}
|
|
|
|
|
|
def _sleeve_prose(sym: str, beta: float) -> str:
|
|
nm, desc = _r.sleeve_desc(sym)
|
|
short = _PROSE_NAME.get(sym)
|
|
head = short.split(";")[0].strip() if short else \
|
|
desc.split(";")[0].strip()
|
|
head = head[0].upper() + head[1:] if head else nm
|
|
if abs(beta) < 0.15:
|
|
return f"a small tilt to {head.lower()}"
|
|
if beta > 0:
|
|
return f"exposure to {head.lower()}"
|
|
return f"short-like exposure to {head.lower()} (it rises when that " \
|
|
f"asset falls)"
|
|
|
|
|
|
def _top_sleeves(fr: dict, window: str, n: int = 3) -> list[tuple[str, float]]:
|
|
f = fr.get(window) or {}
|
|
betas = f.get("betas") or {}
|
|
top = sorted(betas.items(), key=lambda kv: -abs(kv[1]))[:n]
|
|
return [(s, b) for s, b in top if abs(b) >= 0.08]
|
|
|
|
|
|
def _fit(fr: dict, srow: dict) -> tuple[float | None, float | None,
|
|
float | None]:
|
|
"""(r2, alpha_ann_5y, t5) preferring the reference-model numbers."""
|
|
r2 = srow.get("r2_5y")
|
|
a5 = srow.get("alpha_ann_5y")
|
|
t5 = srow.get("alpha_t_5y")
|
|
if not isinstance(r2, (int, float)):
|
|
r2 = (fr.get("rec5") or fr.get("full") or {}).get("r2")
|
|
if not isinstance(a5, (int, float)):
|
|
a5 = (fr.get("rec5") or fr.get("full") or {}).get("alpha_ann")
|
|
if not isinstance(t5, (int, float)):
|
|
t5 = (fr.get("rec5") or fr.get("full") or {}).get("alpha_t")
|
|
return (r2 if isinstance(r2, (int, float)) else None,
|
|
a5 if isinstance(a5, (int, float)) else None,
|
|
t5 if isinstance(t5, (int, float)) else None)
|
|
|
|
|
|
def narrate(sym: str) -> list[str]:
|
|
sym = sym.lower()
|
|
srow = _r.search_row(sym)
|
|
fr = _r.factor_row(sym)
|
|
dr = _r.decomp_row(sym)
|
|
tax = _r.tax_row(sym)
|
|
ddr = _r.dd_row(sym)
|
|
xcr = _XC.get(sym, {})
|
|
p = _r.price(sym)
|
|
name = (srow.get("name") or _r.FONDS.get(sym, {}).get("name")
|
|
or (fr or {}).get("name") or sym.upper())
|
|
out: list[str] = []
|
|
|
|
# ------------------------------------------------------------ 1. what
|
|
who = []
|
|
who.append(f"{sym.upper()} is the {name}.")
|
|
obj = (_r.FONDS.get(sym) or {}).get("objective")
|
|
if obj:
|
|
first = obj.split(". ")[0].strip().rstrip(".")
|
|
who.append(f"Stated strategy: {first}.")
|
|
if xcr and not xcr.get("error"):
|
|
b = [x["name"] for x in xcr.get("buckets", [])[:2]]
|
|
if b:
|
|
who.append(f"Per its latest N-PORT its actual holdings are "
|
|
f"mostly {b[0]}"
|
|
+ (f" and {b[1]}" if len(b) > 1 else "") + ".")
|
|
out.append(" ".join(who))
|
|
|
|
if p is None:
|
|
# no return history: do NOT claim a cluster - the loading vector
|
|
# is all zeros and would park the fund in the cash cluster
|
|
out.append("There is no local return history for this share "
|
|
"class yet (new class), so performance and drivers "
|
|
"cannot be measured from returns. The N-PORT shows "
|
|
"what it holds: " + ((dr or {}).get("note")
|
|
or "see holdings"))
|
|
return out
|
|
if sym in _r.MEMBER:
|
|
_cid, clabel, cn = _r.MEMBER[sym]
|
|
who = out[0]
|
|
out[0] = (who + f" In the return-driver analysis it sits in the "
|
|
f"'{clabel}' cluster with {cn - 1} other funds.")
|
|
else:
|
|
try:
|
|
load = _r.fund_loading_row(fr)
|
|
_cid, clabel = _r.cluster_of_row(load)
|
|
out[0] = (out[0] + f" Its return-driver signature is closest "
|
|
f"to the '{clabel}' cluster.")
|
|
except Exception:
|
|
pass
|
|
|
|
st = _r.perf_stats(p)
|
|
r2, a5, t5 = _fit(fr, srow)
|
|
tf = (fr.get("full") or {}).get("alpha_t")
|
|
a_f = (fr.get("full") or {}).get("alpha_ann")
|
|
refs = _r.ref_components(sym, fr)
|
|
r2f_ = (fr.get("rec5") or fr.get("full") or {}).get("r2")
|
|
fit = r2 if r2 is not None else r2f_
|
|
r25 = srow.get("r2_5y") if isinstance(srow.get("r2_5y"), (int, float)) \
|
|
else r2f_
|
|
refs_curve = refs if (fit is not None and fit >= 0.5) else []
|
|
mix = _r.mix_series(refs_curve) if refs_curve else _r.price("bil")
|
|
ivv = _r.price("ivv")
|
|
t5y = _r.window_ret(p, "2021-01-01", st["end"])
|
|
t1y = _r.window_ret(p, "2025-09-01", st["end"])
|
|
ref5y = _r.window_ret(mix, "2021-01-01", st["end"]) if mix is not None \
|
|
else None
|
|
iv5y = _r.window_ret(ivv, "2021-01-01", st["end"]) if ivv is not None \
|
|
else None
|
|
|
|
# --------------------------------------------------------- 2. performance
|
|
perf = []
|
|
tot = p.iloc[-1] / p.iloc[0] - 1
|
|
perf.append(
|
|
f"Since {st['start'][:4]} it has compounded at "
|
|
f"{_pct(st['cagr'])} per year (a {_pct(tot, 0)} total return) "
|
|
f"with {_pct(st['vol'], 0)} annualized volatility and a maximum "
|
|
f"drawdown of {100 * abs(st['mdd']):.0f}%.")
|
|
perf.append(
|
|
f"Over the last five years it returned {_pct(t5y)} versus "
|
|
f"{_pct(ref5y)} for its reference"
|
|
+ (f" and {_pct(iv5y)} for the S&P 500" if iv5y is not None else "")
|
|
+ f". The last twelve months have done {_pct(t1y)}.")
|
|
yrs = _r.annual_table(p, 7)
|
|
pos = sum(1 for _y, v in yrs if v > 0)
|
|
perf.append(f"It was positive in {pos} of the last {len(yrs)} "
|
|
f"calendar years (including the partial current year).")
|
|
# episodes
|
|
rets = ddr.get("rets", {})
|
|
if rets:
|
|
best = max(rets.items(), key=lambda kv: kv[1])
|
|
worst = min(rets.items(), key=lambda kv: kv[1])
|
|
perf.append(
|
|
f"Across the defined market episodes it did best in "
|
|
f"'{best[0]}' ({_pct(best[1])}) and worst in '{worst[0]}' "
|
|
f"({_pct(worst[1])}) - the peak-to-trough windows when "
|
|
f"equities fell hardest.")
|
|
_dv = ddr.get("verdict") or ""
|
|
if _dv and not _dv.startswith("CANDIDATE"):
|
|
perf.append(f"Its drawdown screen verdict: {_dv}.")
|
|
out.append(" ".join(perf))
|
|
|
|
# --------------------------------------------------------- 3. drivers
|
|
drv = []
|
|
tops = _top_sleeves(fr, "rec5", 3)
|
|
if fit is not None and fit >= 0.7:
|
|
tops_txt = ", ".join(_sleeve_prose(s, b) for s, b in tops)
|
|
drv.append(
|
|
f"The fit is strong: {100 * fit:.0f}% of its excess returns "
|
|
f"over the last five years are explained by its measured "
|
|
f"exposures"
|
|
+ (f" - {tops_txt}" if tops else "") + ".")
|
|
drv.append(
|
|
f"In other words, most of what this fund does is charge you "
|
|
f"for those exposures in the form of a fund; what is left - "
|
|
f"an alpha of {_pct(a5)} per year (t = {_num(t5, 1)}) - is "
|
|
+ ("statistically indistinguishable from zero, i.e. the "
|
|
"strategy is not clearly adding anything on top of the "
|
|
"mix it owns."
|
|
if (t5 or 0) < 1.75 else
|
|
"small relative to the beta, but it is what separates "
|
|
"this fund from the equivalent index mix.")
|
|
)
|
|
elif fit is not None and fit >= 0.5:
|
|
tops_txt = ", ".join(_sleeve_prose(s, b) for s, b in tops)
|
|
drv.append(
|
|
f"The measured exposures explain a good share but not all "
|
|
f"({100 * fit:.0f}% R²) of its excess returns."
|
|
+ (f" The main ones: {tops_txt}." if tops else ""))
|
|
drv.append(
|
|
f"The residual is {_pct(a5)} per year (t = {_num(t5, 1)}): "
|
|
+ ("a real edge on top of the mix, but a material part of "
|
|
"the performance IS the mix - judge the exposures and "
|
|
"the skill separately."
|
|
if (t5 or 0) >= 1.75 else
|
|
"not statistically distinct from noise at the five-year "
|
|
"horizon, so treat the outperformance as part beta, part "
|
|
"luck until it accumulates more history.")
|
|
)
|
|
else:
|
|
tops_txt = ", ".join(_sleeve_prose(s, b) for s, b in tops)
|
|
drv.append(
|
|
f"The broad sleeve framework explains little of its excess "
|
|
f"returns (R² = {_num(fit) if fit is not None else 'n/a'}). "
|
|
+ (f"The loadings that do show up - {tops_txt} - are minor "
|
|
"tilts, not the story."
|
|
if tops else
|
|
"No benchmark loading is even large."))
|
|
long_ctx = ""
|
|
if isinstance(a_f, (int, float)):
|
|
long_ctx = (f" Over the full history the same estimate is "
|
|
f"{_pct(a_f)} per year (t = {_num(tf, 1)}).")
|
|
if (t5 or 0) >= 2:
|
|
drv.append(
|
|
f"The bulk of the performance is therefore "
|
|
f"idiosyncratic: it comes from the fund's own holdings "
|
|
f"and decisions, which nothing in our 34-sleeve space "
|
|
f"replicates. The estimate of that idiosyncratic return "
|
|
f"is {_pct(a5)} per year over the last five years "
|
|
f"(t = {_num(t5, 1)}) - statistically significant, so "
|
|
f"the outperformance is real; what the returns alone "
|
|
f"cannot tell us is its source.{long_ctx}")
|
|
elif (a5 or 0) >= 0:
|
|
drv.append(
|
|
f"The bulk of the performance is therefore "
|
|
f"idiosyncratic: it comes from the fund's own holdings "
|
|
f"and decisions, which nothing in our 34-sleeve space "
|
|
f"replicates. The estimate of that idiosyncratic return "
|
|
f"is {_pct(a5)} per year over the last five years "
|
|
f"(t = {_num(t5, 1)}) - at that t-stat it is not yet "
|
|
f"clearly different from a lucky streak.{long_ctx}")
|
|
else:
|
|
drv.append(
|
|
f"Most of the performance is therefore idiosyncratic "
|
|
f"rather than benchmark-like. The honest five-year read "
|
|
f"of its excess return is {_pct(a5)} per year "
|
|
f"(t = {_num(t5, 1)}): over the recent window this fund "
|
|
f"is NOT measurably adding to its fitted reference.{long_ctx}")
|
|
if (a5 or 0) < 0 and isinstance(tf, (int, float)) and tf >= 1.25:
|
|
drv.append(
|
|
f"The longer record is better: over the full history the "
|
|
f"alpha is {_pct(a_f)} per year (t = {_num(tf, 1)}), "
|
|
f"which IS significant - so the five-year estimate "
|
|
f"looks like a flat patch in a longer positive trend, "
|
|
f"not evidence the edge has gone.")
|
|
if xcr and not xcr.get("error"):
|
|
b = [x["name"] for x in xcr.get("buckets", [])[:2]]
|
|
if b:
|
|
drv.append(
|
|
f"The N-PORT is consistent with that: the alpha is "
|
|
f"plausibly coming from {b[0]}"
|
|
+ (f" and {b[1]}" if len(b) > 1 else "")
|
|
+ " - asset classes our sleeve set either does not "
|
|
"model directly or models too coarsely. That is an "
|
|
"interpretation from the holdings, not something "
|
|
"the returns prove.")
|
|
if obj and (a5 or 0) > 0:
|
|
_o = obj.split(". ")[0].rstrip(".")
|
|
if len(_o) > 110:
|
|
_o = _o[:110].rsplit(" ", 1)[0] + "..."
|
|
drv.append(
|
|
f"The stated strategy ('{_o}') is a plausible mechanism "
|
|
f"as well, but that is a hypothesis - the returns only "
|
|
f"tell us the outperformance exists, not why.")
|
|
# stability (curated funds)
|
|
roll = (dr or {}).get("rolling") or {}
|
|
if "verdict" in (dr or {}) and roll.get("max_drift") is not None:
|
|
md = roll["max_drift"]
|
|
drv.append(
|
|
f"The curated decomposition's read: {dr['verdict']}. "
|
|
f"Weight stability: the one-year rolling betas move by at "
|
|
f"most {md:.2f} (relative to the full-sample weights) - "
|
|
+ ("so the weights are roughly stable over time."
|
|
if md < 0.5 else
|
|
"so treat this as a tactically active fund, not a "
|
|
"static mix."))
|
|
out.append(" ".join(drv))
|
|
|
|
# ------------------------------------------------- 4. what we do NOT know
|
|
unk = []
|
|
if fit is not None:
|
|
if (t5 or 0) >= 2:
|
|
unk.append(
|
|
f"To be explicit about the limits: "
|
|
f"{100 * (1 - fit):.0f}% of this fund's excess return "
|
|
f"is NOT attributed to any measured exposure by our "
|
|
f"analysis - we know the outperformance is real, we do "
|
|
f"not know from returns what produces it; the source "
|
|
f"has to be read from the filings before sizing up.")
|
|
else:
|
|
unk.append(
|
|
f"To be explicit about the limits: the excess return is "
|
|
f"not measurable against noise at the current sample "
|
|
f"size, so there is not even a reliable alpha to "
|
|
f"attribute; {100 * (1 - fit):.0f}% of the return is "
|
|
f"outside our sleeve model either way.")
|
|
pf = srow.get("alpha_pos_frac")
|
|
if isinstance(pf, (int, float)) and pf < 0.60:
|
|
unk.append(
|
|
f"The alpha is also not steady: in {100 * (1 - pf):.0f}% of "
|
|
f"rolling six-month windows the fund trailed its reference, "
|
|
f"so the five-year number is carrying periods of "
|
|
f"underperformance.")
|
|
if (t5 or 0) >= 2 and isinstance(tf, (int, float)) and tf < 1.25:
|
|
unk.append(
|
|
"The significance is recent - over the full history the "
|
|
"alpha is not significant, so this is a provisional read on "
|
|
"a ~5-year sample.")
|
|
if st["years"] < 3:
|
|
unk.append(
|
|
f"The whole sample is short ({st['years']:.1f} years), so "
|
|
f"every number above has a wide error bar.")
|
|
if xcr.get("note"):
|
|
unk.append(xcr["note"].rstrip(".") + ".")
|
|
note = (dr or {}).get("note")
|
|
if note and "N-PORT" in note:
|
|
# holdings cross-check sentence (shortlist)
|
|
for sentence in note.split(". "):
|
|
if "N-PORT" in sentence or "holdings" in sentence.lower():
|
|
unk.append(sentence.strip().rstrip(".") +
|
|
" - the holdings check is the ground truth "
|
|
"here, and the return model is only a "
|
|
"description of it.")
|
|
break
|
|
if not unk:
|
|
unk.append("No major caveats beyond the usual: the past fit may "
|
|
"not persist, and the sleeves are models of the "
|
|
"fund, not the fund itself.")
|
|
out.append(" ".join(unk))
|
|
|
|
# --------------------------------------------------------- 5. bottom line
|
|
bot = []
|
|
loc = tax.get("location")
|
|
if loc:
|
|
basis = "N-PORT holdings" if tax.get("basis") == "N-PORT" \
|
|
else "the sleeve model"
|
|
if "MIXED" in loc:
|
|
bot.append(
|
|
f"Account placement: not settled by the model - the "
|
|
f"sleeve model calls it MIXED, so the last 1099-DIV is "
|
|
f"the arbiter before choosing taxable vs IRA.")
|
|
else:
|
|
bot.append(
|
|
f"Account placement: "
|
|
f"{'taxable' if 'TAXABLE' in loc else 'IRA'} "
|
|
f"({basis} says {loc}).")
|
|
# peers
|
|
if sym in _r.MEMBER:
|
|
cid = _r.MEMBER[sym][0]
|
|
members = _r.KMEANS["clusters"][cid]["syms"]
|
|
peers = []
|
|
for s in members:
|
|
if s == sym:
|
|
continue
|
|
v = _r.search_row(s)
|
|
if isinstance(v.get("alpha_t_5y"), (int, float)) and \
|
|
v["alpha_t_5y"] > 0:
|
|
pp = _r.price(s)
|
|
if pp is None:
|
|
continue
|
|
peers.append((v["alpha_t_5y"], v.get("alpha_ann_5y"), s))
|
|
peers.sort(reverse=True)
|
|
if peers:
|
|
b_t, b_a, b_s = peers[0]
|
|
bp = _r.price(b_s)
|
|
bt5 = _r.window_ret(bp, "2021-01-01",
|
|
_r.perf_stats(bp)["end"])
|
|
bot.append(
|
|
f"Within its cluster the strongest alternative is "
|
|
f"{b_s.upper()} (5y {_pct(bt5)}, alpha t = {b_t:+.1f}); "
|
|
f"the choice between them should turn on the conviction "
|
|
f"in the strategy, the tax fit and the price paid, not "
|
|
f"on the statistics - they are the same kind of "
|
|
f"position.")
|
|
cp = srow.get("corr_portfolio")
|
|
if isinstance(cp, (int, float)):
|
|
bot.append(
|
|
f"Its correlation with your current portfolio is "
|
|
f"{cp:+.2f} - "
|
|
+ ("a genuine diversifier" if cp < 0.3
|
|
else "not very diversified vs what you already own".rstrip("."))
|
|
+ ".")
|
|
out.append(" ".join(bot))
|
|
return out
|
|
|
|
|
|
def main() -> None:
|
|
from fundlab.nport import FUND_TOKENS
|
|
from fundlab.decompose import ALIAS
|
|
cand = [v["sym"] for v in _r.SEARCH.values()
|
|
if isinstance(v, dict)
|
|
and str(v.get("verdict", "")).startswith("CANDIDATE")]
|
|
short = [s for s in FUND_TOKENS if s not in ALIAS]
|
|
for s in cand + short:
|
|
print(f"=== {s.upper()} ===")
|
|
for para in narrate(s):
|
|
print(para)
|
|
print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|