"""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 ('' '' + "".join(rows) + '
periodfundreferenceIVVfund − ref

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"

Decomposition verdict: " f"{html.escape(dr['verdict'])}

") 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 ('' '' + "".join(rows) + "
loadingwhat it iswhat it exposes you to
" + 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 = ('' '' + "".join(rows) + "
fund5yCAGRmaxDDR² 5yalpha 5ytax
") # 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 narrative_block(sym: str) -> str: from fundlab.narrative import narrate return "".join(f"

{html.escape(par)}

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

{html.escape(title)} {html.escape(subtitle)}

{strategy_block(sym)}

Discussion

{narrative_block(sym)} {equity_figure(sym, name, refs_curve)}

Performance

{perf_table(price(sym), refs_curve) if price(sym) is not None else '

no price data

'}

What drove the returns

{drivers_section(fr, dr, srow)}

The reference mix - and what it exposes you to

{reference_section(refs, refs_curve, sym)}

Tax character & placement

{tax_section(sym)}

Peer comparison (same return-driver cluster)

{cluster_section(sym, fr, load)}
""" 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'
  • S{i:02d} · {s.upper()} — ' f'{html.escape(nm[:48])}
  • ') 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()