Fund Lab: returns-based strategy decomposition for the 16 shortlist funds
- fundlab/decompose.py: per-fund OLS forward selection (BIC-gated, |t|>2,
per-model complete cases so differently-vintaged candidates stay
comparable) against curated DISTINCT-AXIS candidate sets; full-history
+ last-5y models; rolling 1y beta drift for static-vs-time-varying
verdicts; per-fund holdings cross-check notes
- results (13 unique funds; pmfkx/lcrix/egrsx are share classes):
* jlpsx ~1.04x S&P 500, R2 0.96 5y (cleanest)
* lamhx S&P + value/mid tilt, R2 0.95, stable
* cosix 5y: HY +0.30 / MBS +0.29 / IG +0.18, R2 0.86
* cvsix market neutral, 5y R2 0.74, +5.5%/yr alpha (t 6.7)
* pmaix multi-asset: HY .62 / EFA .23 / comm .05 / bonds -.15
* mbxix hedge: ivv .39 / ief -.67 / fxe -.28, R2 0.53
* atesx NOT a static mix - rolling beta to its own QQQ/SPY holdings
is 0.13-0.89 (median 0.30): the 'risk managed' overlay is real
* qspnx/svarx/eagmx/atrfx/pmorx: market-neutral or idiosyncratic -
alpha, not sleeves (qspnx +12.8%/yr alpha t 4.0)
* lcorx/lcrix: new classes (Jul 2026), no history yet - holdings only
- atesx holdings: pulled from the adviser's SOI PDF (anchor-soi-5.31.26):
QQQ 65.2% + SPY 29.3% + MMF 0.6%, options overlay 4.9%
- pool: added qqq (Nasdaq 100) - needed to fit tech-concentrated funds
- app Fund Lab tab: per-fund decomposition (verdict, R2 full/5y, alpha,
tracking error, beta drift, component table + bar chart, holdings
cross-check note) and an all-funds summary expander
- tests: ols/forward-select engine tests (50/50 fundlab)
This commit is contained in:
parent
54d26939dc
commit
db5fc4626d
95
app.py
95
app.py
|
|
@ -588,6 +588,33 @@ with tab_fundlab:
|
||||||
_fl_pick = st.selectbox("Fund (shortlist)", list(_FL_LABELS),
|
_fl_pick = st.selectbox("Fund (shortlist)", list(_FL_LABELS),
|
||||||
format_func=lambda s: _FL_LABELS[s],
|
format_func=lambda s: _FL_LABELS[s],
|
||||||
key="fundlab_pick")
|
key="fundlab_pick")
|
||||||
|
|
||||||
|
# --- all-funds summary table -------------------------------------
|
||||||
|
from fundlab import decompose as _dc
|
||||||
|
try:
|
||||||
|
_DR = json.loads(_dc.RESULTS.read_text())
|
||||||
|
except Exception:
|
||||||
|
_DR = {}
|
||||||
|
with st.expander(f"Summary — all {len(_FL_LABELS)} funds"):
|
||||||
|
_sum_rows = []
|
||||||
|
for _s in _ORDER:
|
||||||
|
_d = _DR.get(_s, {})
|
||||||
|
_rec = _d.get("recent", {})
|
||||||
|
_comp = ", ".join(f"{c['sym']} {c['beta']:+.2f}"
|
||||||
|
for c in _rec.get("components", [])[:4]) or \
|
||||||
|
", ".join(f"{c['sym']} {c['beta']:+.2f}"
|
||||||
|
for c in _d.get("components", [])[:4])
|
||||||
|
_sum_rows.append({
|
||||||
|
"fund": _FL_LABELS.get(_s, _s),
|
||||||
|
"verdict": _d.get("verdict", "n/a"),
|
||||||
|
"R² 5y": round(_rec["r2"], 3) if "r2" in _rec else None,
|
||||||
|
"alpha 5y": (f"{_rec['alpha_ann']*100:+.1f}% "
|
||||||
|
f"(t={_rec['alpha_t']:+.1f})")
|
||||||
|
if "alpha_ann" in _rec else None,
|
||||||
|
"components": _comp or "—",
|
||||||
|
})
|
||||||
|
st.dataframe(pd.DataFrame(_sum_rows), width="stretch")
|
||||||
|
|
||||||
_f = _FUNDS.get(_fl_pick, {})
|
_f = _FUNDS.get(_fl_pick, {})
|
||||||
_man = _MAN.get(_fl_pick, {})
|
_man = _MAN.get(_fl_pick, {})
|
||||||
st.subheader(f"{_f.get('name', _fl_pick)} · {_fl_pick.upper()}")
|
st.subheader(f"{_f.get('name', _fl_pick)} · {_fl_pick.upper()}")
|
||||||
|
|
@ -646,6 +673,74 @@ with tab_fundlab:
|
||||||
top["value"] = top["value"].map(lambda v: f"${v:,.0f}")
|
top["value"] = top["value"].map(lambda v: f"${v:,.0f}")
|
||||||
st.dataframe(top, width="stretch")
|
st.dataframe(top, width="stretch")
|
||||||
|
|
||||||
|
# ---- returns-based strategy decomposition -------------------------
|
||||||
|
st.markdown("**Strategy decomposition** — which benchmark sleeves explain "
|
||||||
|
"the fund's returns (OLS forward selection, BIC-gated, "
|
||||||
|
"|t|>2; full history + last 5 years)")
|
||||||
|
_dr = _DR.get(_fl_pick, {})
|
||||||
|
if not _dr or "verdict" not in _dr:
|
||||||
|
st.info("No decomposition available for this fund.")
|
||||||
|
elif "r2" not in _dr:
|
||||||
|
st.info(_dr["verdict"])
|
||||||
|
if _dr.get("note"):
|
||||||
|
st.markdown("**Holdings cross-check:** " + _dr["note"])
|
||||||
|
else:
|
||||||
|
_rec = _dr.get("recent", {})
|
||||||
|
_v = _dr["verdict"]
|
||||||
|
if _v.startswith("static") or _v.startswith("mostly stable"):
|
||||||
|
st.success(_v)
|
||||||
|
elif "no component" in _v or "not a static" in _v:
|
||||||
|
st.warning(_v)
|
||||||
|
else:
|
||||||
|
st.info(_v)
|
||||||
|
_c = st.columns(6)
|
||||||
|
_c[0].metric("R² full", f"{_dr.get('r2', float('nan')):.3f}")
|
||||||
|
_c[1].metric("R² 5y",
|
||||||
|
f"{_rec['r2']:.3f}" if "r2" in _rec else "n/a")
|
||||||
|
_c[2].metric("alpha 5y",
|
||||||
|
f"{_rec['alpha_ann']*100:+.1f}% (t={_rec['alpha_t']:+.1f})"
|
||||||
|
if "alpha_ann" in _rec else "n/a")
|
||||||
|
_c[3].metric("trk err 5y",
|
||||||
|
f"{_rec['tracking_err_ann']*100:.1f}%"
|
||||||
|
if "tracking_err_ann" in _rec else "n/a")
|
||||||
|
_c[4].metric("max β-drift",
|
||||||
|
f"{_dr.get('rolling', {}).get('max_drift', 0.0):.2f}")
|
||||||
|
_c[5].metric("sample", f"{_dr['n_obs']}d", help=f"{_dr['start']} .. {_dr['end']}")
|
||||||
|
_rows = []
|
||||||
|
_full_b = {c["sym"]: c for c in _dr.get("components", [])}
|
||||||
|
_rec_b = {c["sym"]: c for c in _rec.get("components", [])}
|
||||||
|
for _sym in list(dict.fromkeys(list(_full_b) + list(_rec_b))):
|
||||||
|
_frow = _full_b.get(_sym)
|
||||||
|
_rrow = _rec_b.get(_sym)
|
||||||
|
_rows.append({
|
||||||
|
"component": f"{_sym} — {_frow['label'] if _frow else _rrow['label']}",
|
||||||
|
"β full": f"{_frow['beta']:+.2f} (t={_frow['t']:+.1f})" if _frow else "—",
|
||||||
|
"β 5y": f"{_rrow['beta']:+.2f} (t={_rrow['t']:+.1f})" if _rrow else "—",
|
||||||
|
})
|
||||||
|
if _rows:
|
||||||
|
st.dataframe(pd.DataFrame(_rows), width="stretch")
|
||||||
|
if _full_b:
|
||||||
|
_bar = pd.DataFrame([
|
||||||
|
{"component": f"{s} — {c['label']}", "full": c["beta"]}
|
||||||
|
for s, c in _full_b.items()])
|
||||||
|
fig = go.Figure(go.Bar(
|
||||||
|
x=_bar["full"], y=_bar["component"], orientation="h",
|
||||||
|
marker_color=["#2ca02c" if v >= 0 else "#d62728"
|
||||||
|
for v in _bar["full"]]))
|
||||||
|
fig.update_layout(height=30 + 24 * len(_bar),
|
||||||
|
margin=dict(l=0, r=0, t=8, b=8),
|
||||||
|
xaxis_title="β (full sample)")
|
||||||
|
st.plotly_chart(fig, width="stretch")
|
||||||
|
if _dr.get("note"):
|
||||||
|
st.markdown("**Holdings cross-check:** " + _dr["note"])
|
||||||
|
st.caption(
|
||||||
|
"Method: daily total returns; greedy forward selection on BIC "
|
||||||
|
"(add only if ΔBIC ≥ 2 and |t| > 2); candidate sleeves curated per "
|
||||||
|
"fund from the strategy text + N-PORT. β = exposure, not a literal "
|
||||||
|
"holding weight; 'drift' = mean |rolling 1y β − full β| relative to "
|
||||||
|
"the full β. High-R² + low drift ≈ static mix; low R² with positive "
|
||||||
|
"alpha ≈ market-neutral/alpha strategy.")
|
||||||
|
|
||||||
if _f.get("strategy"):
|
if _f.get("strategy"):
|
||||||
st.markdown("**Strategy (excerpt from the prospectus)**")
|
st.markdown("**Strategy (excerpt from the prospectus)**")
|
||||||
st.write(_f["strategy"])
|
st.write(_f["strategy"])
|
||||||
|
|
|
||||||
394
fundlab/decompose.py
Normal file
394
fundlab/decompose.py
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
"""Strategy decomposition: which pool components best explain a fund's returns.
|
||||||
|
|
||||||
|
For each actively-managed fund we:
|
||||||
|
|
||||||
|
1. take its total-return (Adj Close) daily returns,
|
||||||
|
2. run a greedy BIC forward-selection regression against a curated
|
||||||
|
candidate set of pool components (broad sleeves, not single
|
||||||
|
positions — the question is "what does the fund look like", not
|
||||||
|
"what exact holdings"),
|
||||||
|
3. report the chosen weights (betas), fit (R^2, alpha/t), and a
|
||||||
|
rolling-window check for time-varying weights,
|
||||||
|
|
||||||
|
and hand a verdict to the report: *static sleeve mix* (weights stable),
|
||||||
|
*mostly stable with some timing*, or *time-varying / not a sleeve mix*.
|
||||||
|
|
||||||
|
The candidate sets are per-fund and come from the fund's stated strategy
|
||||||
|
plus its N-PORT holdings; selection within the set is data-driven.
|
||||||
|
|
||||||
|
NumPy only (no statsmodels in the venv). All regressions include an
|
||||||
|
intercept; alphas are annualized (x252); t-stats use the OLS covariance.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from fundlab import pool as _pool
|
||||||
|
|
||||||
|
DATA = Path.home() / "prog/fin/stocks"
|
||||||
|
RESULTS = Path(__file__).parent / "decompose_results.json"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ data
|
||||||
|
def adj_close(sym: str, root: Path = DATA) -> pd.Series | None:
|
||||||
|
f = root / f"{sym}-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
|
||||||
|
if len(s) < 252:
|
||||||
|
return None
|
||||||
|
s.name = sym
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def returns_panel(symbols: list[str], start: str | None = None) -> pd.DataFrame:
|
||||||
|
"""Daily simple returns, outer-joined on dates (NaN where a series
|
||||||
|
has no data). Callers must handle missing values per regressor —
|
||||||
|
candidate histories have different vintages (and some end early, e.g.
|
||||||
|
finux stops in 2017), so a global inner join can be empty."""
|
||||||
|
cols = []
|
||||||
|
for s in symbols:
|
||||||
|
a = adj_close(s)
|
||||||
|
if a is not None:
|
||||||
|
cols.append(a)
|
||||||
|
p = pd.concat(cols, axis=1, sort=False)
|
||||||
|
if start is not None:
|
||||||
|
p = p[p.index >= pd.Timestamp(start)]
|
||||||
|
return p.pct_change()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ stats
|
||||||
|
def ols(y: np.ndarray, X: np.ndarray) -> dict:
|
||||||
|
"""OLS of y on X (X must already include the intercept column)."""
|
||||||
|
n, k = X.shape
|
||||||
|
beta, *_ = np.linalg.lstsq(X, y, rcond=None)
|
||||||
|
resid = y - X @ beta
|
||||||
|
ssr = float(resid @ resid)
|
||||||
|
dof = max(n - k, 1)
|
||||||
|
s2 = ssr / dof
|
||||||
|
XtX_inv = np.linalg.pinv(X.T @ X)
|
||||||
|
se = np.sqrt(np.maximum(np.diag(XtX_inv) * s2, 0.0))
|
||||||
|
t = np.where(se > 0, beta / np.where(se > 0, se, 1.0), 0.0)
|
||||||
|
yss = float(((y - y.mean()) ** 2).sum())
|
||||||
|
r2 = 1.0 - ssr / yss if yss > 0 else 0.0
|
||||||
|
adj_r2 = 1.0 - (1.0 - r2) * (n - 1) / dof
|
||||||
|
bic = n * np.log(ssr / n) + k * np.log(n)
|
||||||
|
dw = float(((resid[1:] - resid[:-1]) ** 2).sum() / ssr) if ssr > 0 else 0.0
|
||||||
|
return {"beta": beta, "se": se, "t": t, "r2": r2, "adj_r2": adj_r2,
|
||||||
|
"bic": bic, "dw": dw, "ssr": ssr, "n": n, "resid": resid}
|
||||||
|
|
||||||
|
|
||||||
|
def forward_select(y: np.ndarray, cand: dict[str, np.ndarray],
|
||||||
|
max_k: int = 6, min_d_bic: float = 2.0,
|
||||||
|
y_ok: np.ndarray | None = None) -> tuple[list[str], dict, np.ndarray]:
|
||||||
|
"""Greedy forward selection on BIC, with per-model complete cases.
|
||||||
|
|
||||||
|
``cand`` maps symbol -> daily-return vector (aligned with y); NaNs
|
||||||
|
mark dates where that series has no data. Each trial regression uses
|
||||||
|
the rows where the fund, all chosen components and the candidate are
|
||||||
|
all present, so differently-vintaged candidates stay comparable.
|
||||||
|
A candidate is only added if it lowers BIC by at least ``min_d_bic``
|
||||||
|
(~3:1 odds in its favour), so we stop before overfitting noise.
|
||||||
|
|
||||||
|
Returns (chosen symbols, model on the final complete cases, the
|
||||||
|
boolean mask of those rows).
|
||||||
|
"""
|
||||||
|
if y_ok is None:
|
||||||
|
y_ok = ~np.isnan(y)
|
||||||
|
ones0 = np.ones(len(y))
|
||||||
|
chosen: list[str] = []
|
||||||
|
|
||||||
|
def fit(ch: list[str]) -> tuple[dict, np.ndarray]:
|
||||||
|
ok = y_ok.copy()
|
||||||
|
for c in ch:
|
||||||
|
ok &= ~np.isnan(cand[c])
|
||||||
|
X = np.column_stack([ones0[ok], *[cand[c][ok] for c in ch]])
|
||||||
|
return ols(y[ok], X), ok
|
||||||
|
|
||||||
|
cur, cur_ok = fit(chosen)
|
||||||
|
for _ in range(max_k):
|
||||||
|
best_j, best_b, best_m = None, cur["bic"], None
|
||||||
|
for j in cand:
|
||||||
|
if j in chosen:
|
||||||
|
continue
|
||||||
|
ok = cur_ok & ~np.isnan(cand[j])
|
||||||
|
X = np.column_stack([ones0[ok], *[cand[c][ok] for c in chosen],
|
||||||
|
cand[j][ok]])
|
||||||
|
if X.shape[0] < 252 or np.linalg.matrix_rank(X) < X.shape[1]:
|
||||||
|
continue
|
||||||
|
m = ols(y[ok], X)
|
||||||
|
# the new regressor is the last column; require it to be
|
||||||
|
# individually significant (|t|>2) so we don't chase noise
|
||||||
|
if abs(m["t"][-1]) < 2.0:
|
||||||
|
continue
|
||||||
|
if m["bic"] < best_b - 1e-9:
|
||||||
|
best_j, best_b, best_m = j, m["bic"], m
|
||||||
|
if best_j is None or best_b > cur["bic"] - min_d_bic:
|
||||||
|
break
|
||||||
|
chosen.append(best_j)
|
||||||
|
cur, cur_ok = fit(chosen)
|
||||||
|
return chosen, cur, cur_ok
|
||||||
|
|
||||||
|
|
||||||
|
def rolling_beta(y: np.ndarray, X: np.ndarray, w: int = 252,
|
||||||
|
step: int = 21, dates: pd.Index | None = None) -> pd.DataFrame:
|
||||||
|
"""Refit betas on rolling windows; returns a DataFrame indexed by the
|
||||||
|
window end date, one column per regressor (no intercept column)."""
|
||||||
|
n = len(y)
|
||||||
|
rows = []
|
||||||
|
idx = []
|
||||||
|
i = w
|
||||||
|
while i <= n:
|
||||||
|
Xm = X[i - w:i]
|
||||||
|
ym = y[i - w:i]
|
||||||
|
if np.linalg.matrix_rank(Xm) < Xm.shape[1]:
|
||||||
|
i += step
|
||||||
|
continue
|
||||||
|
b, *_ = np.linalg.lstsq(Xm, ym, rcond=None)
|
||||||
|
rows.append(b[1:])
|
||||||
|
idx.append(dates[i - 1] if dates is not None else i - 1)
|
||||||
|
i += step
|
||||||
|
cols = ["intercept"] + [f"x{k}" for k in range(X.shape[1] - 1)]
|
||||||
|
return pd.DataFrame(rows, index=idx, columns=cols[1:])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ funds
|
||||||
|
# per-fund candidate sets: broad pool sleeves consistent with the fund's
|
||||||
|
# stated strategy and N-PORT holdings. Pool labels for the report come
|
||||||
|
# from fundlab.pool.
|
||||||
|
# Candidate sets are curated to DISTINCT AXES (one representative per
|
||||||
|
# sleeve): near-duplicates (ivv/vt/vti, efa/vea, agg/vbtlx) would make the
|
||||||
|
# OLS knife-edge and produce offsetting betas. Discovery happens within
|
||||||
|
# this set; the set itself comes from the fund's stated strategy + N-PORT.
|
||||||
|
CANDIDATES: dict[str, list[str]] = {
|
||||||
|
# QQQ 65% + SPY 29% + cash, options overlay
|
||||||
|
"atesx": ["qqq", "ivv", "iwm", "shv", "bil"],
|
||||||
|
# systematic short-duration IG credit + cash
|
||||||
|
"atrfx": ["vstbx", "vicbx", "vweax", "ief", "ivv", "shv", "bil"],
|
||||||
|
# market neutral: long equity, short credit
|
||||||
|
"cvsix": ["ivv", "ive", "vweax", "vicbx", "tlt", "shv", "bil"],
|
||||||
|
# US large-cap core plus
|
||||||
|
"jlpsx": ["ivv", "ijt", "iwm", "efa", "vwo", "shv", "bil"],
|
||||||
|
# global multi-asset
|
||||||
|
"pmaix": ["ivv", "efa", "vwo", "vnq", "agg", "vweax", "djp", "gld",
|
||||||
|
"fxe", "tlt", "shv", "bil"],
|
||||||
|
# long/short mortgage & ABS
|
||||||
|
"pmorx": ["vmbix", "vweax", "vicbx", "finux", "ief", "tlt", "shv",
|
||||||
|
"bil"],
|
||||||
|
# market-neutral style premia
|
||||||
|
"qspnx": ["ive", "ivw", "iwm", "ijt", "ijs", "efa", "shv", "bil"],
|
||||||
|
# low-volatility equity fund of funds
|
||||||
|
"svarx": ["ivv", "ive", "ijk", "efa", "vwo", "shv", "bil", "agg"],
|
||||||
|
# full credit-spectrum strategic income
|
||||||
|
"cosix": ["agg", "vweax", "vmbix", "finux", "fghnx", "ief", "tlt",
|
||||||
|
"shv", "bil"],
|
||||||
|
# hedge fund (multi-strategy)
|
||||||
|
"mbxix": ["ivv", "efa", "tlt", "ief", "vweax", "djp", "gld", "fxe",
|
||||||
|
"shv", "bil"],
|
||||||
|
# global macro, sovereign-centric
|
||||||
|
"eagmx": ["tlt", "ief", "vweax", "finux", "fxe", "fxb", "fxy", "djp",
|
||||||
|
"gld", "shv", "bil"],
|
||||||
|
# Leuthold Core: fund of ETFs (no return history - holdings only)
|
||||||
|
"lcorx": ["ivv", "efa", "vwo", "agg", "djp", "shv", "bil"],
|
||||||
|
# US dividend-growth equity
|
||||||
|
"lamhx": ["ivv", "ive", "ijk", "ijt", "iwm", "efa", "shv", "bil"],
|
||||||
|
}
|
||||||
|
# share classes: same underlying fund
|
||||||
|
ALIAS = {"pmfkx": "pmaix", "lcrix": "lcorx", "egrsx": "eagmx"}
|
||||||
|
FULL_WINDOW = "1990-01-01" # effectively all history
|
||||||
|
RECENT_WINDOW = "2021-01-01" # last ~5 years
|
||||||
|
|
||||||
|
|
||||||
|
def _drift(rb: pd.DataFrame, full_beta: np.ndarray) -> dict[str, float]:
|
||||||
|
"""Mean |rolling beta - full-sample beta| per component, as a fraction
|
||||||
|
of max(|full beta|, 0.25) — 0 means perfectly stable weights."""
|
||||||
|
out = {}
|
||||||
|
for k, col in enumerate(rb.columns):
|
||||||
|
b = full_beta[k + 1]
|
||||||
|
d = float((rb[col] - b).abs().mean())
|
||||||
|
out[col] = d / max(abs(b), 0.25)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def decompose(fund: str, start: str = FULL_WINDOW,
|
||||||
|
candidates: dict[str, list[str]] | None = None) -> dict:
|
||||||
|
cand_syms = (candidates or CANDIDATES).get(fund, [])
|
||||||
|
r = returns_panel([fund] + cand_syms, start=start)
|
||||||
|
if fund not in r.columns:
|
||||||
|
return {"fund": fund, "error": "no return history in the data set",
|
||||||
|
"verdict": "no return history", "start": start}
|
||||||
|
y = r[fund].to_numpy()
|
||||||
|
dates = r.index
|
||||||
|
cand = {s: r[s].to_numpy() for s in r.columns if s != fund}
|
||||||
|
y_ok = ~np.isnan(y)
|
||||||
|
if y_ok.sum() < 252:
|
||||||
|
return {"fund": fund, "error": "insufficient return history",
|
||||||
|
"verdict": "insufficient return history", "start": start}
|
||||||
|
chosen, m, ok = forward_select(y, cand, y_ok=y_ok)
|
||||||
|
d0 = dates[ok]
|
||||||
|
ann_alpha = float(m["beta"][0]) * 252
|
||||||
|
te = float(np.std(m["resid"]) * np.sqrt(252))
|
||||||
|
port_ann = float(np.mean(y[ok]) * 252)
|
||||||
|
out = {
|
||||||
|
"fund": fund,
|
||||||
|
"start": str(d0[0].date()),
|
||||||
|
"end": str(d0[-1].date()),
|
||||||
|
"n_obs": int(m["n"]),
|
||||||
|
"components": [
|
||||||
|
{"sym": s, "label": _label(s), "beta": float(m["beta"][k + 1]),
|
||||||
|
"t": float(m["t"][k + 1])}
|
||||||
|
for k, s in enumerate(chosen)],
|
||||||
|
"alpha_ann": ann_alpha,
|
||||||
|
"alpha_t": float(m["t"][0]),
|
||||||
|
"r2": float(m["r2"]),
|
||||||
|
"adj_r2": float(m["adj_r2"]),
|
||||||
|
"tracking_err_ann": te,
|
||||||
|
"fund_ann_return": port_ann,
|
||||||
|
"dw": float(m["dw"]),
|
||||||
|
"weights_sum": float(m["beta"][1:].sum()),
|
||||||
|
}
|
||||||
|
# rolling stability of the chosen model (on the same complete cases)
|
||||||
|
if chosen:
|
||||||
|
X = np.column_stack([np.ones(int(ok.sum())),
|
||||||
|
*[r[s].to_numpy()[ok] for s in chosen]])
|
||||||
|
rb = rolling_beta(y[ok], X, dates=d0)
|
||||||
|
drift = _drift(rb, m["beta"])
|
||||||
|
out["rolling"] = {
|
||||||
|
"window": 252,
|
||||||
|
"drift": {s: float(v) for s, v in drift.items()},
|
||||||
|
"max_drift": float(max(drift.values())) if drift else 0.0,
|
||||||
|
"n_windows": int(len(rb)),
|
||||||
|
}
|
||||||
|
out["verdict"] = _verdict(out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# per-fund cross-check notes: what the N-PORT holdings + strategy say, and
|
||||||
|
# how the returns decomposition should be read against that
|
||||||
|
NOTES: dict[str, str] = {
|
||||||
|
"atesx": ("Holdings (May 2026): QQQ 65% + SPY 29% + MMF 0.6%, with 4.9% "
|
||||||
|
"'other assets in excess of liabilities' — an options overlay. "
|
||||||
|
"But the rolling beta to those SAME holdings stays 0.13–0.89 "
|
||||||
|
"(median 0.30, never above 1): the 'risk managed' in the name is "
|
||||||
|
"real — a systematic equity de-risking overlay. Decomposition: one "
|
||||||
|
"TACTICAL US-equity sleeve, not a static mix."),
|
||||||
|
"atrfx": ("Systematic alpha over short-duration IG credit + cash. Returns are "
|
||||||
|
"dominated by idiosyncratic credit/derivatives P&L (R² ≤ 0.22 vs bond "
|
||||||
|
"sleeves) and the best-fit weights are knife-edge. Read as: "
|
||||||
|
"cash-like carry + systematic alpha, no meaningful static sleeve."),
|
||||||
|
"cvsix": ("Market neutral (long US equity, short credit). Full sample (since "
|
||||||
|
"1990) is unexplainable — the strategy has changed over 36 years; the "
|
||||||
|
"last 5 years show a small net equity/credit tilt (ivv +0.14, "
|
||||||
|
"vweax +0.06) explaining 74%. The rest is spread/option alpha "
|
||||||
|
"(full-sample annualized alpha +5.5%, t=6.7)."),
|
||||||
|
"jlpsx": ("US large-cap core plus: essentially 1.04x the S&P 500 "
|
||||||
|
"(R² 0.96 over 5y, stable). The 'plus' is small optionality "
|
||||||
|
"(tiny ijt/vwo tilts in the 5y fit). The cleanest fund on the list."),
|
||||||
|
"pmaix": ("Global multi-asset fund of funds (N-PORT: 99.5% in unaffiliated "
|
||||||
|
"underlying funds/loans). Returns decompose into high-yield credit "
|
||||||
|
"(vweax +0.62), intl equity (efa +0.23), commodities (+0.05), "
|
||||||
|
"bonds (−0.15): R² 0.68, stable weights, alpha +3.5%/yr (t=3.3). "
|
||||||
|
"The sleeves show through the underlying funds."),
|
||||||
|
"pmorx": ("Long/short mortgage & ABS — returns mostly idiosyncratic (R² 0.10). "
|
||||||
|
"5y direction is long MBS (vmbix +0.31) / short intermediate rates "
|
||||||
|
"(ief −0.39), consistent with a carry/relative-value mortgage "
|
||||||
|
"strategy. Not a static sleeve."),
|
||||||
|
"qspnx": ("Market-neutral style premia: no static sleeve explains returns "
|
||||||
|
"(R² 0.18 5y). Alpha vs a cash-like benchmark: +12.8%/yr full sample "
|
||||||
|
"(t=4.0). Decomposition = pure factor harvesting (value/size/style "
|
||||||
|
"tilts in both books); the 'exposures' in the table are residuals, "
|
||||||
|
"not sleeves."),
|
||||||
|
"svarx": ("Low-volatility equity fund of funds. Small but positive net market "
|
||||||
|
"(efa +0.07, agg +0.10; R² 0.24, low drift). The edge is in "
|
||||||
|
"volatility selection, not the mix: +5.2%/yr alpha over that small "
|
||||||
|
"sleeve (t=5.1)."),
|
||||||
|
"cosix": ("Strategic income across the credit spectrum. Full sample (since "
|
||||||
|
"1990) unexplainable — vintage; the last 5 years are the honest "
|
||||||
|
"current mix: high-yield +0.30, MBS +0.29, IG core +0.18 (R² 0.86)."),
|
||||||
|
"mbxix": ("Multi-strategy hedge fund: 53% explained over a decade "
|
||||||
|
"(ivv +0.39, ief −0.67, fxe −0.28, tlt +0.17, djp +0.08) — equity "
|
||||||
|
"long, duration short, FX/commodity tilts, large active residual. "
|
||||||
|
"Caveat: newest N-PORT on file is Sep 2024 — the fund may have "
|
||||||
|
"changed strategy or stopped filing."),
|
||||||
|
"eagmx": ("Global macro (sovereign-centric): nothing explains returns in the "
|
||||||
|
"full or 5y window (R² ≤ 0.05) — textbook macro, positions are "
|
||||||
|
"tactical and asset-agnostic. The whole story is +5.1%/yr (t=8.0) "
|
||||||
|
"over a flat benchmark."),
|
||||||
|
"lcorx": ("NEW share classes (trading since Jul 2026) — no return history to "
|
||||||
|
"regress. Holdings (Dec 2025 N-PORT): 91.7% ETFs + 8.4% money "
|
||||||
|
"market; the strategy is Leuthold core multi-asset via ETFs. "
|
||||||
|
"Re-run the decomposition after a year of NAV accumulates."),
|
||||||
|
"lamhx": ("Dividend growth: R² 0.95; S&P 500 + value/mid tilt "
|
||||||
|
"(ivv +0.62, ive +0.26, ijk +0.20, iwm −0.14 over 5y), stable "
|
||||||
|
"weights. Closest to a passive fund with an overlay on this list."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _label(sym: str) -> str:
|
||||||
|
try:
|
||||||
|
for e in _pool.load_pool():
|
||||||
|
if e.symbol == sym:
|
||||||
|
return e.label
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return sym
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict(d: dict) -> str:
|
||||||
|
if d.get("error"):
|
||||||
|
return d["error"]
|
||||||
|
md = d.get("rolling", {}).get("max_drift", 0.0)
|
||||||
|
r2 = d["r2"]
|
||||||
|
if r2 >= 0.95 and md < 0.5:
|
||||||
|
v = "static sleeve mix (weights stable, ~fully explained)"
|
||||||
|
elif r2 >= 0.85:
|
||||||
|
v = "mostly stable sleeve mix" + ("" if md < 1.0 else " with drifting weights")
|
||||||
|
elif r2 >= 0.6:
|
||||||
|
v = "partially explainable — material active/timing residual"
|
||||||
|
else:
|
||||||
|
v = "not a static sleeve mix — returns driven by active decisions"
|
||||||
|
if d.get("components") is None or not d.get("components"):
|
||||||
|
v = "no component explains returns (market neutral or cash-like)"
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def run_all() -> dict[str, dict]:
|
||||||
|
out = {}
|
||||||
|
done = set()
|
||||||
|
for fund in CANDIDATES:
|
||||||
|
if fund in ALIAS or fund in done: # share class: parent covers it
|
||||||
|
continue
|
||||||
|
full = decompose(fund)
|
||||||
|
full["recent"] = decompose(fund, start=RECENT_WINDOW)
|
||||||
|
full["note"] = NOTES.get(fund, "")
|
||||||
|
# a much better 5y fit than the full sample means the strategy
|
||||||
|
# itself changed — say so in the verdict
|
||||||
|
rec = full["recent"]
|
||||||
|
if ("r2" in rec and "r2" in full and rec["r2"] > full["r2"] + 0.2):
|
||||||
|
full["verdict"] += (f" [strategy evolved — 5y R² = "
|
||||||
|
f"{rec['r2']:.2f}]")
|
||||||
|
out[fund] = full
|
||||||
|
done.add(fund)
|
||||||
|
print(f"{fund}: {full['verdict'][:90]}", flush=True)
|
||||||
|
# share classes reference their parent fund
|
||||||
|
for cls, parent in ALIAS.items():
|
||||||
|
if parent in out:
|
||||||
|
d = dict(out[parent])
|
||||||
|
d["fund"] = cls
|
||||||
|
d["note"] = (f"share class of {parent} — identical report. "
|
||||||
|
+ d.get("note", ""))
|
||||||
|
out[cls] = d
|
||||||
|
RESULTS.write_text(json.dumps(out, indent=1))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_all()
|
||||||
1340
fundlab/decompose_results.json
Normal file
1340
fundlab/decompose_results.json
Normal file
File diff suppressed because it is too large
Load Diff
53
fundlab/nport_cache/atesx.json
Normal file
53
fundlab/nport_cache/atesx.json
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"sym": "atesx",
|
||||||
|
"as_of": "May 31, 2026",
|
||||||
|
"net_assets": 147227199.0,
|
||||||
|
"n_positions": 3,
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"name": "EXCHANGE-TRADED FUNDS",
|
||||||
|
"pct": 94.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EQUITY",
|
||||||
|
"pct": 94.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SHORT-TERM INVESTMENTS",
|
||||||
|
"pct": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MONEY MARKET FUND",
|
||||||
|
"pct": 0.6
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buckets": [
|
||||||
|
{
|
||||||
|
"name": "Equity (US)",
|
||||||
|
"value": 139099660.0,
|
||||||
|
"pct": 99.37930790437584
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cash",
|
||||||
|
"value": 868773.0,
|
||||||
|
"pct": 0.6206920956241612
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"top": [
|
||||||
|
{
|
||||||
|
"text": "Invesco QQQ Trust Series 1 (QQQ), 130,000 sh",
|
||||||
|
"value": 95980300,
|
||||||
|
"cat": "EXCHANGE-TRADED FUNDS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "State Street SPDR S&P 500 ETF Trust (SPY), 57,000 sh",
|
||||||
|
"value": 43119360,
|
||||||
|
"cat": "EXCHANGE-TRADED FUNDS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "First American Government Obligations Fund Class X, 3.54%, 868,773 sh",
|
||||||
|
"value": 868773,
|
||||||
|
"cat": "MONEY MARKET FUND"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -60,8 +60,8 @@
|
||||||
"filed": "2025-09-26"
|
"filed": "2025-09-26"
|
||||||
},
|
},
|
||||||
"atesx": {
|
"atesx": {
|
||||||
"url": null,
|
"url": "https://anchor-capital.com/wp-content/uploads/2024/10/anchor-soi-5.31.26.pdf",
|
||||||
"filed": null,
|
"filed": "2026-05-31",
|
||||||
"note": "No current SOI found via EDGAR FTS (latest Anchor filings cover the Income fund only)"
|
"note": "Schedule of Investments (unaudited) from the adviser's website; the fund's own recent EDGAR N-PORTs cover only the Income fund. Composition: QQQ 65.2% + SPY 29.3% + MMF 0.6%; 'other assets in excess of liabilities' 4.9% indicates an options overlay."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,3 +156,4 @@ tlt US long-term treasuries (20+ yr)
|
||||||
ief US intermediate treasuries (7-10 yr)
|
ief US intermediate treasuries (7-10 yr)
|
||||||
shv US short-term treasuries
|
shv US short-term treasuries
|
||||||
bil US t-bills / cash
|
bil US t-bills / cash
|
||||||
|
qqq Nasdaq 100
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,40 @@ def test_nport() -> None:
|
||||||
check("parse net assets", out["net_assets"] == 50000, repr(out["net_assets"]))
|
check("parse net assets", out["net_assets"] == 50000, repr(out["net_assets"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_decompose() -> None:
|
||||||
|
print("decompose engine", flush=True)
|
||||||
|
import numpy as np
|
||||||
|
import fundlab.decompose as dc
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
n = 1000
|
||||||
|
x1 = rng.normal(0, 0.01, n)
|
||||||
|
x2 = rng.normal(0, 0.008, n)
|
||||||
|
x3 = rng.normal(0, 0.01, n) # no signal
|
||||||
|
y = 0.6 * x1 + 0.3 * x2 + rng.normal(0, 0.001, n)
|
||||||
|
# ols recovers betas
|
||||||
|
X = np.column_stack([np.ones(n), x1, x2])
|
||||||
|
m = dc.ols(y, X)
|
||||||
|
check("ols beta1", abs(m["beta"][1] - 0.6) < 0.05, f"{m['beta'][1]:.3f}")
|
||||||
|
check("ols beta2", abs(m["beta"][2] - 0.3) < 0.05, f"{m['beta'][2]:.3f}")
|
||||||
|
check("ols r2 high", m["r2"] > 0.95, f"{m['r2']:.3f}")
|
||||||
|
# forward selection: picks the two signal sleeves, not the noise one
|
||||||
|
chosen, _, ok = dc.forward_select(y, {"s1": x1, "s2": x2, "s3": x3})
|
||||||
|
check("fwd picks signal", set(chosen) == {"s1", "s2"}, str(chosen))
|
||||||
|
# market neutral (pure noise): nothing selected
|
||||||
|
yn = rng.normal(0, 0.004, n)
|
||||||
|
chosen_n, _, _ = dc.forward_select(yn, {"s1": x1, "s2": x2})
|
||||||
|
check("fwd rejects noise", chosen_n == [], str(chosen_n))
|
||||||
|
# NaN handling: a candidate whose history only partly overlaps the
|
||||||
|
# fund's doesn't crash the selection, and the full-history sleeve is
|
||||||
|
# still found. (The short-history sleeve may lose on BIC because its
|
||||||
|
# complete-case sample is smaller - that's the expected, conservative
|
||||||
|
# behaviour, so we only assert robustness here.)
|
||||||
|
x2p = x2.copy()
|
||||||
|
x2p[:500] = np.nan
|
||||||
|
chosen_p, _, _ok_p = dc.forward_select(y, {"s1": x1, "s2": x2p})
|
||||||
|
check("fwd handles nan overlap", "s1" in chosen_p, f"chosen={chosen_p}")
|
||||||
|
|
||||||
|
|
||||||
def test_curated() -> None:
|
def test_curated() -> None:
|
||||||
print("curated", flush=True)
|
print("curated", flush=True)
|
||||||
import fundlab.fundinfo as fi
|
import fundlab.fundinfo as fi
|
||||||
|
|
@ -259,6 +293,7 @@ def main() -> int:
|
||||||
test_classify()
|
test_classify()
|
||||||
test_strategy()
|
test_strategy()
|
||||||
test_nport()
|
test_nport()
|
||||||
|
test_decompose()
|
||||||
test_curated()
|
test_curated()
|
||||||
test_edgar_live()
|
test_edgar_live()
|
||||||
print(f"\n{PASS} passed, {FAIL} failed")
|
print(f"\n{PASS} passed, {FAIL} failed")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user