Fund report: 11 candidates + 13 shortlist funds, self-contained HTML
fundlab/report.py -> reports/fund_report.html (20 MB, plotly inlined, opens offline). Per fund: max-history equity curve (fund vs fitted reference vs IVV); performance table (full/5y/1y, the 5 market episodes, calendar years) with the fund-minus-reference period-alpha column; drivers (reference-model R²/alpha/t + 34-sleeve signature + curated decomposition verdict and N-PORT cross-check notes); the reference mix explained sleeve-by-sleeve (what each exposure actually is, plus net-cash/net-levered read); tax character + taxable/IRA placement; and a peer table of the 4 best funds in the same k=30 return-driver cluster with computed advantages/disadvantages. Weak-fit (R²<0.5) funds anchor their tables to CASH rather than the statistically-thin forward-selected mix (which can be an offsetting VIX/duration spec combination whose path is meaningless); the loadings are still shown with a 'weak fit' caveat.
This commit is contained in:
parent
89674c24dd
commit
328855a926
802
fundlab/report.py
Normal file
802
fundlab/report.py
Normal file
|
|
@ -0,0 +1,802 @@
|
||||||
|
"""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]
|
||||||
|
cols = []
|
||||||
|
for c in refs:
|
||||||
|
p = price(c["sym"])
|
||||||
|
if p is None:
|
||||||
|
continue
|
||||||
|
cols.append((c["beta"], p.pct_change()))
|
||||||
|
if not cols:
|
||||||
|
return None
|
||||||
|
idx = None
|
||||||
|
for _b, r in cols:
|
||||||
|
idx = r if idx is None else idx.combine(r, lambda x, y: x + y,
|
||||||
|
fill_value=0.0)
|
||||||
|
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("<div>", f'<div style="height:{h}px">')
|
||||||
|
|
||||||
|
|
||||||
|
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 "<p>No local price history.</p>"
|
||||||
|
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"<tr><td>{label}</td>"
|
||||||
|
f"<td>{fmt_pct(fr_)}</td><td>{fmt_pct(mr)}</td>"
|
||||||
|
f"<td>{fmt_pct(ir)}</td><td>{fmt_pct(gap)}</td></tr>")
|
||||||
|
|
||||||
|
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 ('<table><tr><th>period</th><th>fund</th><th>reference</th>'
|
||||||
|
'<th>IVV</th><th>fund − ref</th></tr>' + "".join(rows)
|
||||||
|
+ '</table><p class="small">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.</p>')
|
||||||
|
|
||||||
|
|
||||||
|
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"<p><b>Reference model, last 5 years:</b> 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"<p><b>Reference model, full history:</b> 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 ''}.</p>")
|
||||||
|
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(
|
||||||
|
"<p><b>Return-driver signature (34 sleeves, for clustering "
|
||||||
|
"context):</b> " + ", ".join(
|
||||||
|
f"{s} {b:+.2f}" for s, b in top) + " - net cash "
|
||||||
|
f"{1 - sum(betas.values()):+.2f}.</p>")
|
||||||
|
# drift + verdict from the curated decomposition, when available
|
||||||
|
if dr and "verdict" in dr:
|
||||||
|
out.append(f"<p><b>Decomposition verdict:</b> "
|
||||||
|
f"{html.escape(dr['verdict'])}</p>")
|
||||||
|
roll = dr.get("rolling") or {}
|
||||||
|
if roll.get("max_drift") is not None:
|
||||||
|
out.append(
|
||||||
|
f"<p>Weight stability: max 1y β-drift = "
|
||||||
|
f"{roll['max_drift']:.2f} (relative to full-sample β; "
|
||||||
|
f"0 = perfectly stable, >1 = the weight is unstable).</p>")
|
||||||
|
note = dr.get("note")
|
||||||
|
if note:
|
||||||
|
out.append(f"<p class='small'>{html.escape(note)}</p>")
|
||||||
|
if srow:
|
||||||
|
v = str(srow.get("verdict", ""))
|
||||||
|
if v:
|
||||||
|
out.append(f"<p><b>Screen verdict:</b> {html.escape(v)}</p>")
|
||||||
|
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 = ("<p class='small'><b>Weak fit - read with care.</b> "
|
||||||
|
"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).</p>")
|
||||||
|
if not refs:
|
||||||
|
srow = search_row(sym)
|
||||||
|
a = srow.get("alpha_ann_5y")
|
||||||
|
return caveat + ("<p><b>Reference: cash (the T-bill rate itself)."
|
||||||
|
"</b> 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.</p>")
|
||||||
|
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"<tr><td><b>{c['sym'].upper()}</b> {c['beta']:+.2f}"
|
||||||
|
f"</td><td>{html.escape(nm)}</td>"
|
||||||
|
f"<td>{html.escape(desc)}</td></tr>")
|
||||||
|
cash_line = ""
|
||||||
|
if weak:
|
||||||
|
cash_line = caveat + cash_line
|
||||||
|
if cash > 0.05:
|
||||||
|
cash_line = (f"<p>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).</p>")
|
||||||
|
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).</p>")
|
||||||
|
return ('<table><tr><th>loading</th><th>what it is</th>'
|
||||||
|
'<th>what it exposes you to</th></tr>' + "".join(rows)
|
||||||
|
+ "</table>" + cash_line
|
||||||
|
+ '<p class="small">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.</p>')
|
||||||
|
|
||||||
|
|
||||||
|
def tax_section(sym: str) -> str:
|
||||||
|
t = tax_row(sym)
|
||||||
|
if not t:
|
||||||
|
return "<p>No tax classification on file.</p>"
|
||||||
|
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 <b>taxable</b> account.",
|
||||||
|
"TAXABLE (munis)": "Keep in the <b>taxable</b> account - "
|
||||||
|
"tax-exempt interest is wasted in an IRA.",
|
||||||
|
"TAXABLE (defers to LTCG)": "Keep in the <b>taxable</b> "
|
||||||
|
"account - the income mostly "
|
||||||
|
"defers to the LTCG/ROC rate."}.get(
|
||||||
|
loc, f"Recommended account: <b>{html.escape(loc)}</b>.")
|
||||||
|
return (f"<p>Character score {t.get('score', '?')} "
|
||||||
|
f"({conf}). <b>Placement: {rec}</b></p>"
|
||||||
|
+ (f"<p class='small'>{notes}</p>" 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 "<p>Not in the cluster scheme (no loading vector).</p>"
|
||||||
|
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"<p>In cluster <b>{html.escape(clabel)}</b> (n={cn}) but "
|
||||||
|
"no peer with 5y alpha statistics.</p>")
|
||||||
|
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"<tr class='me'><td><b>{sym.upper()} (this fund)</b></td>"
|
||||||
|
+ "".join(f"<td>{x}</td>" for x in my) + "</tr>")
|
||||||
|
for s in peers:
|
||||||
|
rows.append(f"<tr><td>{s.upper()} — "
|
||||||
|
f"{html.escape((search_row(s) or {}).get('name', '')[:40])}"
|
||||||
|
f"</td>" + "".join(f"<td>{x}</td>" for x in statline(s))
|
||||||
|
+ "</tr>")
|
||||||
|
tbl = ('<table><tr><th>fund</th><th>5y</th><th>CAGR</th>'
|
||||||
|
'<th>maxDD</th><th>R² 5y</th><th>alpha 5y</th><th>tax</th></tr>'
|
||||||
|
+ "".join(rows) + "</table>")
|
||||||
|
# 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 += "<p><b>Advantages vs peers:</b> " + "; ".join(adv) + ".</p>"
|
||||||
|
if dis:
|
||||||
|
txt += "<p><b>Disadvantages vs peers:</b> " + "; ".join(dis) + ".</p>"
|
||||||
|
if not txt:
|
||||||
|
txt = ("<p>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.</p>")
|
||||||
|
return (f"<p>Cluster: <b>{html.escape(clabel)}</b> "
|
||||||
|
f"(n={cn}, k=30 grouping by return-driver signature).</p>"
|
||||||
|
+ 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"<details><summary>Strategy (excerpt from the filing)</summary>"
|
||||||
|
f"<p class='small'>{html.escape(f['strategy'])}</p></details>"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 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"""
|
||||||
|
<h3 id="{sym}">{html.escape(title)}
|
||||||
|
<span class="sub">{html.escape(subtitle)}</span></h3>
|
||||||
|
<div class="fund">{strategy_block(sym)}
|
||||||
|
{equity_figure(sym, name, refs_curve)}
|
||||||
|
<h4>Performance</h4>
|
||||||
|
{perf_table(price(sym), refs_curve) if price(sym) is not None else '<p>no price data</p>'}
|
||||||
|
<h4>What drove the returns</h4>
|
||||||
|
{drivers_section(fr, dr, srow)}
|
||||||
|
<h4>The reference mix - and what it exposes you to</h4>
|
||||||
|
{reference_section(refs, refs_curve, sym)}
|
||||||
|
<h4>Tax character & placement</h4>
|
||||||
|
{tax_section(sym)}
|
||||||
|
<h4>Peer comparison (same return-driver cluster)</h4>
|
||||||
|
{cluster_section(sym, fr, load)}
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
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'<li><a href="#{s}">{s.upper()}</a> — '
|
||||||
|
f'{html.escape((v.get("name") or "")[:48])}</li>')
|
||||||
|
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'<li><a href="#{s}">S{i:02d} · {s.upper()}</a> — '
|
||||||
|
f'{html.escape(nm[:48])}</li>')
|
||||||
|
body.append(fund_section(s, f"S{i:02d} · {s.upper()}", nm))
|
||||||
|
|
||||||
|
doc = f"""<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8">
|
||||||
|
<title>Fund report - candidates & shortlist</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
|
margin: 0; color: #1a1a2e; background: #fafafa; }}
|
||||||
|
.wrap {{ display: flex; }}
|
||||||
|
nav {{ width: 270px; min-width: 270px; position: sticky; top: 0;
|
||||||
|
height: 100vh; overflow-y: auto; background: #fff;
|
||||||
|
border-right: 1px solid #ddd; padding: 16px; box-sizing: border-box; }}
|
||||||
|
main {{ flex: 1; padding: 24px 36px; max-width: 1150px; }}
|
||||||
|
h1 {{ font-size: 26px; }} h3 {{ border-top: 3px solid #1a1a2e;
|
||||||
|
padding-top: 18px; margin-top: 40px; }}
|
||||||
|
h3 .sub {{ font-weight: normal; font-size: 14px; color: #555;
|
||||||
|
display: block; margin-top: 4px; }}
|
||||||
|
h4 {{ margin: 18px 0 6px; font-size: 15px; }}
|
||||||
|
table {{ border-collapse: collapse; margin: 8px 0 14px; font-size: 13px; }}
|
||||||
|
th, td {{ border: 1px solid #ccc; padding: 4px 9px; text-align: left; }}
|
||||||
|
th {{ background: #eee; }}
|
||||||
|
tr.me td {{ background: #fff8e1; }}
|
||||||
|
.small {{ color: #555; font-size: 12.5px; }}
|
||||||
|
nav a {{ text-decoration: none; color: #1a1a2e; font-size: 13px;
|
||||||
|
display: block; padding: 3px 0; }}
|
||||||
|
nav a:hover {{ color: #b00020; }}
|
||||||
|
nav h2 {{ font-size: 14px; margin: 14px 0 6px; }}
|
||||||
|
details {{ margin: 6px 0; }} summary {{ cursor: pointer;
|
||||||
|
font-size: 13px; color: #333; }}
|
||||||
|
</style>
|
||||||
|
{"<script>" + PLOTLY_JS + "</script>" if PLOTLY_JS else
|
||||||
|
'<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>'}
|
||||||
|
</head><body><div class="wrap">
|
||||||
|
<nav>
|
||||||
|
<b>Return-driver report</b><br><span class="small">generated
|
||||||
|
{time.strftime('%Y-%m-%d %H:%M')}</span>
|
||||||
|
<h2>Candidates (alpha screen, excess of T-bill)</h2>
|
||||||
|
<ol>{''.join(toc[:len(cand)])}</ol>
|
||||||
|
<h2>Shortlist (your funds)</h2>
|
||||||
|
<ol start="{len(cand) + 1}">{''.join(toc[len(cand):])}</ol>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<h1>Fund report - {len(cand)} alpha candidates & {len(short)}
|
||||||
|
shortlist funds</h1>
|
||||||
|
<p class="small"><b>Method.</b> Every alpha in this report is computed
|
||||||
|
<b>in excess of the 3-month T-bill rate</b> (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 <b>own fitted sleeve mix</b> (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.</p>
|
||||||
|
{''.join(body)}
|
||||||
|
<p class="small" style="margin-top:40px">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).</p>
|
||||||
|
</main></div></body></html>"""
|
||||||
|
OUT.write_text(doc)
|
||||||
|
print(f"wrote {OUT} in {time.time() - t0:.0f}s "
|
||||||
|
f"({len(doc) / 1e6:.1f} MB)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4298
reports/fund_report.html
Normal file
4298
reports/fund_report.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user