"""Fund report: candidates + shortlist, self-contained HTML.
One section per fund:
- equity curve over the maximum available period (fund vs its fitted
reference mix vs IVV),
- performance: full history, calendar years, and the defined market
episodes (2022 bear, 2023 rate shock, 2024 vol spike, 2025 tariff
crash, 2026 Q1) - fund vs reference mix vs IVV, with the fund-vs-
reference gap (period alpha/timing),
- what drove it: the excess-of-T-bill sleeve loadings (full + 5y),
alpha + t, R^2, rolling drift, verdict,
- the reference mix explained: each picked sleeve with what it
actually exposes you to (not just the ticker),
- tax character + taxable/IRA placement,
- cluster peers: the 3-4 best funds of the same k=30 return-driver
cluster, compared, with this fund's advantages/disadvantages.
Run: .venv/bin/python -m fundlab.report -> reports/fund_report.html
"""
from __future__ import annotations
import html
import json
import time
from pathlib import Path
import numpy as np
import pandas as pd
from fundlab import cluster as _cl
from fundlab import decompose
from fundlab import factors
from fundlab.searchlist import BROAD_SLEEVES
HERE = Path(__file__).parent
REPORTS = HERE.parent / "reports"
REPORTS.mkdir(exist_ok=True)
OUT = REPORTS / "fund_report.html"
TAX = json.loads((HERE / "taxplan_results.json").read_text())
DD = json.loads((HERE / "drawdown_results.json").read_text())
FACTOR = json.loads((HERE / "factor_results.json").read_text())
SEARCH = json.loads((HERE / "search_all.json").read_text())
DECOMP = json.loads((HERE / "decompose_results.json").read_text())
FONDS = json.loads((HERE.parent / "funds.json").read_text())
KMEANS = json.loads((HERE / "cluster_kmeans.json").read_text())
# ------------------------------------------------------------------ sleeves
# what each sleeve actually EXPOSES you to (the report must explain the
# exposure, not just name the ticker)
SLEEVE_DESC = {
"qqq": ("US large growth (Nasdaq-100)",
"growth/tech-heavy US equities; high sensitivity to earnings "
"surprises and long-end rates (duration of growth cash flows)"),
"ivv": ("US large blend (S&P 500)",
"core US equity market; the default 'own the economy' exposure"),
"iwm": ("US small cap (Russell 2000)",
"small-cap cycle: domestic credit, margin pressure, IPO window"),
"vea": ("Intl developed ex-US (Vanguard)",
"developed-market equities outside the US (EU, Japan, UK); FX-"
"hedged-off, currency moves matter"),
"efa": ("Intl developed ex-US (MSCI EAFE)",
"developed-market equities outside the US; same exposure as VEA "
"via a different index provider"),
"vwo": ("Emerging-market equity",
"EM corporate profits + EM currency + China/FX flows; "
"high-vol, high-carry, dollar-sensitive"),
"vnq": ("US REITs",
"physical real estate: rents vs rates, leverage in the property "
"sector; equity-like income"),
"bil": ("1-3 month T-bills (cash)",
"the risk-free rate itself; ~zero volatility, pure carry"),
"shv": ("0-3 month T-bills (ultra-short cash)",
"the risk-free rate itself; netted out of the excess-return "
"regression, shown only as part of a fund's cash position"),
"shy": ("1-3 year Treasuries (short duration)",
"short duration: modest rate sensitivity, ~cash-plus-carry"),
"ief": ("7-10 year Treasuries (core duration)",
"the core rate bet: price moves when the Fed path changes"),
"tlt": ("20+ year Treasuries (long duration)",
"levered duration: big moves on rate expectations, steepener/"
"bull-steepener exposure"),
"vblix": ("VIX futures (pure vol axis)",
"crash insurance / short-vol funding; positive loading = "
"long-vol (rises in panic), negative = short-vol carry"),
"agg": ("Aggregate bonds (Treasuries + IG credit)",
"the core bond market: ~60% Treasuries, IG corporates, MBS; "
"moderate duration"),
"vweax": ("High-yield corporate bonds",
"credit spread cycle: HY junk yields, default risk in "
"recessions, strong carry in stable times"),
"hyg": ("High-yield corporate bonds (iShares)",
"same HY credit-spread exposure as VWEAX via a different fund"),
"vmbix": ("Agency RMBS (mortgage-backed)",
"mortgage credit + prepayment/extension risk; the refi cycle"),
"lqd": ("Investment-grade corporate bonds",
"IG credit spreads over Treasuries; milder default risk than HY"),
"finux": ("Intl bonds (Fidelity; TERMINATED 2017)",
"non-US credit/duration; data ends 2017 so recent windows "
"never use it"),
"pff": ("Preferred stocks",
"hybrid: fixed-rate equity-like instruments; rate + credit + "
"equity risk in one"),
"emb": ("Emerging-market debt",
"EM sovereign/corporate carry; EM currency + dollar cycle"),
"tip": ("TIPS (inflation-linked Treasuries)",
"breakeven inflation exposure: rises when inflation "
"expectations rise"),
"vtv": ("US value",
"cheap/asset-rich US names (financials, energy, cyclicals); "
"value-vs-growth style cycle"),
"dbmf": ("CTA / managed futures",
"trend-following across futures; long in crises, earns "
"un-correlated carry otherwise"),
"dbb": ("Broad commodities",
"commodity basket: inflation hedge + global growth proxy"),
"djp": ("Natural gas",
"a single volatile commodity: winter/hedging cycles"),
"gsg": ("Broad commodities (SPDR)",
"same commodity exposure as DBB via a different fund"),
"gld": ("Gold",
"crisis/inflation hedge; real-rate sensitive, no yield"),
"fxe": ("Long euros vs the dollar",
"EUR/USD: carries the euro interest-rate differential"),
"fxy": ("Long yen vs the dollar",
"USD/JPY: carries the Japan rate differential; carry-trade "
"crowding risk"),
"xlk": ("US tech sector", "the tech sector index"),
"xlf": ("US financials sector", "banks/insurance: rate + credit cycle"),
"xle": ("US energy sector", "oil prices + US drilling"),
"xlv": ("US healthcare sector", "defensive pharma/providers"),
"xlp": ("US staples sector", "defensive consumer"),
"xlu": ("US utilities sector", "bond-proxy utilities; rate sensitive"),
"xly": ("US consumer discretionary", "cyclicals: autos, retail, "
"leisure; earnings cycle"),
"xlb": ("US materials sector", "industrial materials: capex cycle"),
}
def sleeve_desc(sym: str) -> tuple[str, str]:
return SLEEVE_DESC.get(sym, (sym.upper(), "no description on file"))
# ------------------------------------------------------------------ data
def price(sym: str) -> pd.Series | None:
f = decompose.DATA / f"{sym.lower()}-history.csv"
if not f.exists():
return None
try:
s = (pd.read_csv(f, parse_dates=["Date"], index_col="Date")
["Adj Close"].dropna())
s = s[~s.index.duplicated(keep="last")].sort_index()
except Exception:
return None
return s if len(s) > 40 else None
EPISODES = [] # (label, start, end) from the drawdown engine
for _e in DD["episodes"]:
EPISODES.append((_e["label"], pd.Timestamp(_e["peak"]),
pd.Timestamp(_e["trough"])))
# k=30 cluster membership (sym -> cluster id/label)
MEMBER = {}
for _c, _v in KMEANS["clusters"].items():
for _s in _v["syms"]:
MEMBER[_s] = (_c, _v["label"], _v["n"])
def loading_matrix_cached():
return _cl.loading_matrix()
CENTROIDS = {} # sym -> (cluster, centroid) for NEW points
def _centroids(syms: list[str], V: np.ndarray) -> dict[int, np.ndarray]:
lab = _cl.kmeans(_cl.emphasized(V, _cl.AXES.index("cash")), 30)
out = {}
for c in range(30):
idx = [i for i, l in enumerate(lab) if l == c]
if idx:
out[c] = np.median(V[idx], 0)
return out
def cluster_of_row(row: np.ndarray) -> tuple[str, str]:
"""Assign a new loading row to the k=30 scheme (distance to cluster
medians in the emphasized space)."""
global CENTROIDS
if not CENTROIDS:
syms, V = loading_matrix_cached()
CENTROIDS = _centroids(syms, V)
r = row.copy()
r[_cl.AXES.index("cash")] *= _cl.CASH_EMPHASIS
best, bd = None, None
for c, cent in CENTROIDS.items():
d = float(np.sum((r - cent) ** 2))
if bd is None or d < bd:
best, bd = c, d
for c, v in KMEANS["clusters"].items():
if int(c) == best:
return (c, v["label"])
return (str(best), "?")
def factor_row(sym: str) -> dict:
"""Full + 5y excess-of-T-bill loadings for ANY fund (shortlist funds
are not in the 2,384 factor file)."""
if sym in FACTOR:
return FACTOR[sym]
r = factors.factor_screen(sym)
return r or {}
_FWD: dict = {}
def ref_components(sym: str, fr: dict, window: str = "rec5") -> list[dict]:
"""The fund's FORWARD-SELECTED reference (the mix its alpha is
measured against): BIC-gated selection from the 21 broad sleeves,
excess-of-T-bill. Recomputed (cached) because the screen stored only
the display strings. Falls back to the 34-sleeve OLS loadings when
the fund is not in the search universe."""
key = (sym, window)
if key in _FWD:
return _FWD[key]
out = None
try:
m = decompose.decompose(sym, start=decompose.RECENT_WINDOW
if window == "rec5" else None,
candidates={sym: BROAD_SLEEVES})
if m.get("components"):
out = [{"sym": c["sym"], "beta": c["beta"]}
for c in m["components"]]
except Exception:
out = None
if out is None:
comps = (fr.get(window) or fr.get("full") or {}).get("betas") or {}
out = [{"sym": s, "beta": b} for s, b in sorted(
comps.items(), key=lambda kv: -abs(kv[1])) if abs(b) >= 0.08]
_FWD[key] = out
return out
def search_row(sym: str) -> dict:
return SEARCH.get(sym, {})
def decomp_row(sym: str) -> dict:
return DECOMP.get(sym, {})
def tax_row(sym: str) -> dict:
up = sym.upper()
if up in TAX["shortlist"]:
return TAX["shortlist"][up]
for k in (sym, up, sym.lower()):
if k in TAX["candidates"]:
return TAX["candidates"][k]
return {}
def dd_row(sym: str) -> dict:
return DD["funds"].get(sym, {})
# ------------------------------------------------------------------ stats
def perf_stats(p: pd.Series, rf_annual: float = 0.037) -> dict:
r = p.pct_change().dropna()
years = (p.index[-1] - p.index[0]).days / 365.25
cagr = (p.iloc[-1] / p.iloc[0]) ** (1 / years) - 1 if years > 0.5 else np.nan
vol = r.std() * np.sqrt(252)
roll_max = p.cummax()
mdd = float(((p / roll_max) - 1).min())
sharpe = (cagr - rf_annual) / vol if vol > 0 else np.nan
return {"start": str(p.index[0].date()), "end": str(p.index[-1].date()),
"years": years, "cagr": cagr, "vol": vol, "mdd": mdd,
"sharpe": sharpe}
def window_ret(p: pd.Series, a, b) -> float | None:
s = p[(p.index >= a) & (p.index <= b)]
if len(s) < 2:
return None
return float(s.iloc[-1] / s.iloc[0] - 1)
def annual_table(p: pd.Series, n: int = 8) -> list[tuple[str, float]]:
yr = p.resample("YE").last().dropna()
out = []
for i in range(len(yr) - 1):
lab = str(yr.index[i].year)
if i == 0:
continue
out.append((lab, float(yr.iloc[i] / yr.iloc[i - 1] - 1)))
# partial current year from last full year-end
out.append(("YTD", float(p.iloc[-1] / yr.iloc[-1] - 1)))
return out[-(n + 1):]
def mix_series(refs: list[dict]) -> pd.Series | None:
"""Fitted reference mix: sum of beta * sleeve daily returns, cumulated
to a price path (rebased 100). Pure beta path - the fund minus this
is the alpha path."""
if not refs:
b = price("bil")
if b is None:
return None
return 100 * b / b.iloc[0]
idx = None
for c in refs:
p = price(c["sym"])
if p is None:
continue
r = c["beta"] * p.pct_change()
idx = r if idx is None else idx.combine(r, lambda x, y: x + y,
fill_value=0.0)
if idx is None:
return None
idx = idx.fillna(0.0)
path = (1 + idx).cumprod()
return 100 * path / path.iloc[0]
# ------------------------------------------------------------------ html
PLOTLY_JS = ""
def load_plotly():
global PLOTLY_JS
try:
import plotly
PLOTLY_JS = plotly.offline.get_plotlyjs()
except Exception:
PLOTLY_JS = None
def fig_html(fig, h: int = 420) -> str:
import plotly.io as pio
s = pio.to_html(fig, full_html=False, include_plotlyjs=False,
config={"displayModeBar": False}, div_id="")
return s.replace("
", f'
')
def equity_figure(sym: str, name: str, refs: list[dict]) -> str:
import plotly.graph_objects as go
p = price(sym)
if p is None:
return "
No local price history.
"
fig = go.Figure()
fig.add_trace(go.Scatter(x=p.index, y=100 * p / p.iloc[0],
name=sym.upper(), line=dict(width=2)))
m = mix_series(refs)
if m is not None:
fig.add_trace(go.Scatter(x=m.index, y=m, name="fitted reference",
line=dict(width=1.2, dash="dash")))
iv = price("ivv")
if iv is not None:
fig.add_trace(go.Scatter(
x=iv.index, y=100 * iv / iv.iloc[0], name="IVV (S&P 500)",
line=dict(width=1, dash="dot"), opacity=0.7))
fig.update_layout(height=430, margin=dict(l=10, r=10, t=30, b=10),
title=f"{html.escape(name)} - total return since "
f"{p.index[0].year} (rebased 100)",
legend=dict(orientation="h", y=1.08),
hovermode="x unified")
return fig_html(fig)
def fmt_pct(x, nd=1, sign=True):
if x is None or (isinstance(x, float) and np.isnan(x)):
return "—"
s = f"{100 * x:+.{nd}f}%" if sign else f"{100 * x:.{nd}f}%"
return s
def fmt_r2(x):
return "—" if x is None or (isinstance(x, float) and np.isnan(x)) \
else f"{x:.2f}"
def perf_table(p: pd.Series, refs: list[dict]) -> str:
m = mix_series(refs)
iv = price("ivv")
rows = []
def add(label, a, b, ann=False):
fr_ = window_ret(p, a, b)
mr = window_ret(m, a, b) if m is not None else None
ir = window_ret(iv, a, b) if iv is not None else None
gap = (fr_ - mr) if (fr_ is not None and mr is not None) else None
rows.append(f"
{label}
"
f"
{fmt_pct(fr_)}
{fmt_pct(mr)}
"
f"
{fmt_pct(ir)}
{fmt_pct(gap)}
")
st = perf_stats(p)
add("Full history", st["start"], st["end"])
add("Last 5y", "2021-01-01", st["end"])
add("Last 1y", "2025-09-01", st["end"])
for lab, a, b in EPISODES:
add(lab, a, b)
# calendar years (last 6)
yr = p.resample("YE").last().dropna()
years = [str(yr.index[i].year) for i in range(1, len(yr))][-6:]
for i, y in enumerate(years):
a = f"{y}-01-01"
b = f"{int(y) + 1}-01-01" if i < len(years) - 1 else st["end"]
add(y, a, b)
return ('
period
fund
reference
'
'
IVV
fund − ref
' + "".join(rows)
+ '
fund − reference = period '
'alpha/timing (the part of that period the sleeve mix does '
'not explain). IVV shown for scale - for non-equity funds '
'the IVV column is only context.
')
def drivers_section(fr: dict, dr: dict, srow: dict) -> str:
out = []
if srow:
a5 = srow.get("alpha_ann_5y")
t5 = srow.get("alpha_t_5y")
r25 = srow.get("r2_5y")
if isinstance(r25, (int, float)) or isinstance(a5, (int, float)):
out.append(
f"
Reference model, last 5 years: R² = "
f"{fmt_r2(r25 if isinstance(r25, (int, float)) else None)}, "
f"alpha = {fmt_pct(a5) if isinstance(a5, (int, float)) else '—'}"
f"{' (t = ' + format(t5, '+.1f') + ')' if isinstance(t5, (int, float)) else ''} "
f"vs the fitted reference mix (next section).")
rf = (fr.get("full") or {}).get("alpha_ann")
tf = (fr.get("full") or {}).get("alpha_t")
r2f = srow.get("r2_full")
if isinstance(r2f, (int, float)) or isinstance(rf, (int, float)):
out.append(
f"
Reference model, full history: R² = "
f"{fmt_r2(r2f if isinstance(r2f, (int, float)) else None)}, "
f"alpha = {fmt_pct(rf) if isinstance(rf, (int, float)) else '—'}"
f"{' (t = ' + format(tf, '+.1f') + ')' if isinstance(tf, (int, float)) else ''}.
")
f = fr.get("rec5") or {}
betas = f.get("betas") or {}
top = sorted(betas.items(), key=lambda kv: -abs(kv[1]))[:6]
if top:
out.append(
"
Return-driver signature (34 sleeves, for clustering "
"context): " + ", ".join(
f"{s} {b:+.2f}" for s, b in top) + " - net cash "
f"{1 - sum(betas.values()):+.2f}.
")
# drift + verdict from the curated decomposition, when available
if dr and "verdict" in dr:
out.append(f"
")
roll = dr.get("rolling") or {}
if roll.get("max_drift") is not None:
out.append(
f"
Weight stability: max 1y β-drift = "
f"{roll['max_drift']:.2f} (relative to full-sample β; "
f"0 = perfectly stable, >1 = the weight is unstable).
")
note = dr.get("note")
if note:
out.append(f"
{html.escape(note)}
")
if srow:
v = str(srow.get("verdict", ""))
if v:
out.append(f"
Screen verdict: {html.escape(v)}
")
return "".join(out)
def reference_section(refs: list[dict], refs_curve: list[dict],
sym: str) -> str:
weak = bool(refs) and not refs_curve
caveat = ""
if weak:
caveat = ("
Weak fit - read with care. "
"These loadings are each individually significant but "
"collectively explain little of the excess return; the "
"economically meaningful picture is a mostly-cash "
"vehicle with idiosyncratic alpha (the performance "
"table anchors to cash, not to this mix).
")
if not refs:
srow = search_row(sym)
a = srow.get("alpha_ann_5y")
return caveat + ("
Reference: cash (the T-bill rate itself)."
" No "
"sleeve passed the forward-selection gates, so the fund's "
"excess returns are not explained by any benchmark mix - "
f"its entire excess performance is idiosyncratic (5y alpha "
f"{fmt_pct(a) if isinstance(a, (int, float)) else '—'}). "
"There is no meaningful 'beta' to this fund; it is a "
"standalone position.
")
sb = sum(c["beta"] for c in refs)
cash = 1 - sb
rows = []
for c in refs:
nm, desc = sleeve_desc(c["sym"])
rows.append(f"
{c['sym'].upper()} {c['beta']:+.2f}"
f"
{html.escape(nm)}
"
f"
{html.escape(desc)}
")
cash_line = ""
if weak:
cash_line = caveat + cash_line
if cash > 0.05:
cash_line = (f"
The loadings sum to {sb:.2f}, i.e. the fund is "
f"~{cash:.0%} NET CASH (earns the T-bill rate; adds "
f"zero excess alpha).
")
elif cash < -0.05:
cash_line = (f"The loadings sum to {sb:.2f}, i.e. the fund is ~"
f"{-cash:.0%} NET LEVERED (borrows at ~the T-bill "
f"rate; that financing shows up as negative cash).")
return ('
loading
what it is
'
'
what it exposes you to
' + "".join(rows)
+ "
" + cash_line
+ '
The reference is NOT one index - it is '
'this fitted mix, rebuilt from the fund\'s own returns. '
'"Alpha" everywhere in this report means outperformance vs '
'this mix, in excess of the T-bill rate.
')
def tax_section(sym: str) -> str:
t = tax_row(sym)
if not t:
return "
No tax classification on file.
"
loc = t.get("location", "?")
basis = t.get("basis", "?")
conf = {"N-PORT": "from actual N-PORT holdings (high confidence)",
"sleeves": "from the return-sleeve mix (model, medium "
"confidence)"}.get(basis, basis)
notes = html.escape(t.get("notes") or "")
rec = {"TAXABLE": "Keep in the taxable account.",
"TAXABLE (munis)": "Keep in the taxable account - "
"tax-exempt interest is wasted in an IRA.",
"TAXABLE (defers to LTCG)": "Keep in the taxable "
"account - the income mostly "
"defers to the LTCG/ROC rate."}.get(
loc, f"Recommended account: {html.escape(loc)}.")
return (f"
Character score {t.get('score', '?')} "
f"({conf}). Placement: {rec}
"
+ (f"
{notes}
" if notes else ""))
def cluster_section(sym: str, fr: dict, row: np.ndarray | None) -> str:
srow = search_row(sym)
if sym in MEMBER:
cid, clabel, cn = MEMBER[sym]
members = KMEANS["clusters"][cid]["syms"]
else:
if row is None:
return "
Not in the cluster scheme (no loading vector).
"
cid, clabel = cluster_of_row(row)
cn = KMEANS["clusters"].get(cid, {}).get("n", 0)
members = KMEANS["clusters"].get(cid, {}).get("syms", [])
peers = []
for s in members:
if s == sym:
continue
v = search_row(s)
if not isinstance(v.get("alpha_t_5y"), (int, float)):
continue
peers.append((v["alpha_t_5y"], v.get("r2_5y") or 0, s, v))
# best peers = highest POSITIVE alpha t (a fund with t = -5 is the
# cluster's worst, not a peer worth copying); top-4, positives first
peers.sort(key=lambda p: (p[0] > 0, p[0]), reverse=True)
pos = [p for p in peers if p[0] > 0]
peers = (pos + [p for p in peers if p[0] <= 0])[:4]
peers = [p[2] for p in peers]
if not peers:
return (f"
In cluster {html.escape(clabel)} (n={cn}) but "
"no peer with 5y alpha statistics.
")
rows = []
def statline(s: str) -> str:
p = price(s)
if p is None:
return ("—", "—", "—", "—", "—", "n/a")
st = perf_stats(p)
v = search_row(s)
t5 = window_ret(p, "2021-01-01", st["end"])
r2 = v.get("r2_5y")
a5 = v.get("alpha_ann_5y")
tt = v.get("alpha_t_5y")
tax = (tax_row(s) or {}).get("location") or "n/a"
return (fmt_pct(t5), fmt_pct(st["cagr"]), fmt_pct(st["mdd"]),
fmt_r2(r2),
(f"{fmt_pct(a5)} (t={tt:+.1f})"
if isinstance(a5, (int, float)) else "—"), tax)
p = price(sym)
st = perf_stats(p) if p is not None else {}
my = statline(sym)
rows.append(f"
{sym.upper()} (this fund)
"
+ "".join(f"
{x}
" for x in my) + "
")
for s in peers:
rows.append(f"
{s.upper()} — "
f"{html.escape((search_row(s) or {}).get('name', '')[:40])}"
f"
" + "".join(f"
{x}
" for x in statline(s))
+ "
")
tbl = ('
fund
5y
CAGR
'
'
maxDD
R² 5y
alpha 5y
tax
'
+ "".join(rows) + "
")
# advantages / disadvantages: computed deltas vs the peer set
adv, dis = [], []
if p is not None:
t5 = window_ret(p, "2021-01-01", st["end"])
vals = {}
for s in peers:
pp = price(s)
if pp is None:
continue
ss = perf_stats(pp)
vals[s] = (window_ret(pp, "2021-01-01", ss["end"]), ss["mdd"],
ss["vol"])
if vals:
best_t5 = max(v[0] for v in vals.values() if v[0] is not None)
best_dd = max(v[1] for v in vals.values())
low_vol = min(v[2] for v in vals.values())
if t5 is not None and t5 >= best_t5 - 0.02:
adv.append("5y return at the top of the cluster")
elif t5 is not None and t5 < best_t5 - 0.10:
dis.append(f"5y return trails the best peer by "
f"{100 * (best_t5 - t5):.0f}pp")
if st["mdd"] > best_dd + 0.05:
dis.append(f"deeper drawdown than the calmest peer "
f"({fmt_pct(st['mdd'])} vs {fmt_pct(best_dd)})")
elif st["mdd"] < best_dd - 0.05:
adv.append("sharpest drawdown in the cluster")
if st["vol"] < low_vol - 0.02:
adv.append("lowest volatility in the cluster")
elif st["vol"] > low_vol + 0.05:
dis.append("meaningfully more volatile than the "
"calmest peer")
txt = ""
if adv:
txt += "
Advantages vs peers: " + "; ".join(adv) + ".
"
if dis:
txt += "
Disadvantages vs peers: " + "; ".join(dis) + ".
"
if not txt:
txt = ("
The fund sits in the middle of its cluster - no "
"decisive edge on return, drawdown or volatility vs the "
"peers; the choice among them should come down to alpha "
"quality (t-stat), tax fit and the conviction in the "
"strategy.
")
return (f"
Cluster: {html.escape(clabel)} "
f"(n={cn}, k=30 grouping by return-driver signature).
"
+ tbl + txt)
def fund_loading_row(fr: dict) -> np.ndarray:
f = fr.get("full") or fr.get("rec5") or {}
betas = f.get("betas") or {}
row = np.array([betas.get(s, 0.0) or 0.0 for s in factors.DRIVERS],
dtype=float)
return np.append(row, 1.0 - row.sum())
def strategy_block(sym: str) -> str:
f = FONDS.get(sym)
if not f:
return ""
out = ""
if f.get("strategy"):
out += f"Strategy (excerpt from the filing)"
f"
{html.escape(f['strategy'])}
"
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"
{html.escape(x['name'])}
"
f"
{x['beta']:+.2f}
{x['t']:+.1f}
"
f"
{y5['beta']:+.2f}
{y5['t']:+.1f}
"
f"
{'✓' if x['sym'] in prof.get('identified', []) else ''}
")
tbl = ('
factor
β full
t full
'
'
β 5y
t 5y
identified
'
+ "".join(rows) + "
")
para = "".join(f"
{html.escape(p)}
" for p in prof.get("commentary", []))
return ('
Style tilts
'
'
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.
" for par in narrate(sym))
# ------------------------------------------------------------------ build
def fund_section(sym: str, title: str, subtitle: str) -> str:
fr = factor_row(sym)
dr = decomp_row(sym)
srow = search_row(sym)
name = (srow.get("name") or (FONDS.get(sym) or {}).get("name")
or (fr or {}).get("name") or sym.upper())
refs = ref_components(sym, fr)
load = fund_loading_row(fr) if fr else None
# weak-fit (idiosyncratic) funds: the forward-selected mix is a
# statistically-thin spec combination (offsetting VIX/duration legs);
# anchoring the table to it misleads (a "reference" that loses 79% in
# 2022 for a fund that lost 2.4%). Below the fit gate the honest
# reference is CASH - the fund IS ~net-cash + idiosyncratic alpha.
r25 = srow.get("r2_5y") if isinstance(srow.get("r2_5y"),
(int, float)) else None
r2f = (fr.get("rec5") or fr.get("full") or {}).get("r2")
fit = r25 if r25 is not None else r2f
refs_curve = refs if (fit is not None and fit >= 0.5) else []
return f"""
"""
def main() -> None:
t0 = time.time()
load_plotly()
print("building report ...", flush=True)
cand = [v for v in SEARCH.values()
if isinstance(v, dict)
and str(v.get("verdict", "")).startswith("CANDIDATE")]
cand.sort(key=lambda v: -(v.get("alpha_t_5y")
if isinstance(v.get("alpha_t_5y"),
(int, float)) else -9))
# shortlist: the 16 actively-managed funds (3 are share classes of
# the same fund: pmfkx=pmaix, lcrix=lcorx, egrsx=eagmx)
from fundlab.decompose import ALIAS
from fundlab.nport import FUND_TOKENS
short = [s for s in FUND_TOKENS if s not in ALIAS]
toc = []
body = []
for i, v in enumerate(cand, 1):
s = v["sym"]
toc.append(f'
{s.upper()} — '
f'{html.escape((v.get("name") or "")[:48])}
')
body.append(fund_section(
s, f"C{i:02d} · {s.upper()}",
f"{v.get('name', '')} — {str(v.get('verdict', ''))[:60]}"))
for i, s in enumerate(short, 1):
nm = (FONDS.get(s) or {}).get("name", s.upper())
toc.append(f'
')
body.append(fund_section(s, f"S{i:02d} · {s.upper()}", nm))
doc = f"""
Fund report - candidates & shortlist
{"" if PLOTLY_JS else
''}
Fund report - {len(cand)} alpha candidates & {len(short)}
shortlist funds
Method. Every alpha in this report is computed
in excess of the 3-month T-bill rate (BIL total return as the
local risk-free series): the fund's daily total returns are regressed on
sleeve benchmarks that are netted against the same rate, so a cash
position contributes exactly zero. "Reference" is never one index - it
is each fund's own fitted sleeve mix (BIC forward selection on the
broad axes, or the full 34-sleeve OLS for the loadings). R² measures how
much of the excess return the mix explains; alpha is what is left.
"Net cash" = 1 − (sum of loadings). Equity curves are total-return
(Adj Close) over the maximum local history, rebased to 100.
{''.join(body)}
Generated by fundlab.report -
data as of {time.strftime('%Y-%m-%d')}. Local price histories may lag a
day or two. Cluster = k=30 k-means on the excess-return loading vectors
(34 sleeves + net-cash axis).
"""
OUT.write_text(doc)
print(f"wrote {OUT} in {time.time() - t0:.0f}s "
f"({len(doc) / 1e6:.1f} MB)")
if __name__ == "__main__":
main()