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:
Greg Pomerantz 2026-08-26 11:59:59 -04:00
parent 54d26939dc
commit db5fc4626d
7 changed files with 1921 additions and 3 deletions

95
app.py
View File

@ -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
View 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()

File diff suppressed because it is too large Load Diff

View 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"
}
]
}

View File

@ -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."
}
}

View File

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

View File

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