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),
|
||||
format_func=lambda s: _FL_LABELS[s],
|
||||
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, {})
|
||||
_man = _MAN.get(_fl_pick, {})
|
||||
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}")
|
||||
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"):
|
||||
st.markdown("**Strategy (excerpt from the prospectus)**")
|
||||
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"
|
||||
},
|
||||
"atesx": {
|
||||
"url": null,
|
||||
"filed": null,
|
||||
"note": "No current SOI found via EDGAR FTS (latest Anchor filings cover the Income fund only)"
|
||||
"url": "https://anchor-capital.com/wp-content/uploads/2024/10/anchor-soi-5.31.26.pdf",
|
||||
"filed": "2026-05-31",
|
||||
"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)
|
||||
shv US short-term treasuries
|
||||
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"]))
|
||||
|
||||
|
||||
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:
|
||||
print("curated", flush=True)
|
||||
import fundlab.fundinfo as fi
|
||||
|
|
@ -259,6 +293,7 @@ def main() -> int:
|
|||
test_classify()
|
||||
test_strategy()
|
||||
test_nport()
|
||||
test_decompose()
|
||||
test_curated()
|
||||
test_edgar_live()
|
||||
print(f"\n{PASS} passed, {FAIL} failed")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user