vitals / scripts /backtest_fit_policy.py
s-ttp's picture
construction layer (risk decomposition, joint selection, constrained scenarios, post-trade proof, instruments, guards, exclusions) + Alpha Vantage premium as primary data source + policy backtest
fb36c27 verified
Raw History Blame Contribute Delete
5.37 kB
"""Policy validation: does the construction rule actually improve risk-adjusted outcomes?
For each seed book (concentrated tech / income / mid-cap), at each quarter-end over the
last ~5 years: estimate the shrinkage covariance from the trailing 2 years, pick 3 adds
from a fixed universe by (a) the greedy variance-reduction rule, (b) random draws (mean of
30), (c) top trailing-12-month momentum, size each add at 8% pro-rata, hold 12 months, and
record realized volatility, max drawdown, return and Sharpe. Walk-forward: only information
available at the rebalance date is used. Output: data/policy_backtest.json + a markdown table.
Run: .venv/bin/python scripts/backtest_fit_policy.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from src import construct as CX # noqa: E402
from src.market_data import fetch_history, load_instruments # noqa: E402
SEEDS = {
"tech_concentrated": {"NVDA": 0.35, "AMD": 0.2, "PLTR": 0.15, "TSLA": 0.15, "MSFT": 0.15},
"income_rates": {"O": 0.25, "DUK": 0.2, "VICI": 0.2, "ENB": 0.2, "SPG": 0.15},
"quality_midcap": {"CROX": 0.25, "DECK": 0.25, "ETSY": 0.2, "ROKU": 0.15, "CELH": 0.15},
}
UNIVERSE = ["ED", "NOC", "BSX", "CRH", "MO", "VLO", "EXPD", "JLL", "ES", "AMP", "CMCSA", "CL",
"UBER", "TSM", "AMZN", "VEEV", "LIN", "JNJ", "PG", "KO", "XOM", "CAT", "UNH", "WMT",
"JPM", "BRK-B", "LLY", "ABBV", "PEP", "HD", "GOOGL", "META", "COST", "AVGO", "V"]
UNIVERSE += [i["ticker"] for i in load_instruments()]
W_ADD, HOLD_DAYS, N_RANDOM = 0.08, 252, 30
def _stats(ret: pd.Series) -> dict:
if len(ret) < 100:
return {}
curve = (1 + ret).cumprod()
dd = float((curve / curve.cummax() - 1).min())
vol = float(ret.std() * np.sqrt(252))
tot = float(curve.iloc[-1] - 1)
return {"return": tot, "vol": vol, "max_dd": dd, "sharpe": float(ret.mean() / ret.std() * np.sqrt(252)) if ret.std() > 0 else 0.0}
def _hold(px: pd.DataFrame, w: dict, start, end) -> pd.Series | None:
sub = px.loc[(px.index > start) & (px.index <= end), list(w)]
if len(sub) < 100:
return None
rets = sub.pct_change(fill_method=None).fillna(0.0)
wv = np.array([w[t] for t in sub.columns]); wv = wv / wv.sum()
return pd.Series(rets.to_numpy() @ wv, index=sub.index)
def main() -> None:
tickers = sorted(set(sum((list(s) for s in SEEDS.values()), [])) | set(UNIVERSE))
px = fetch_history(tickers, period="8y")
px.index = pd.to_datetime(px.index)
px = px.dropna(axis=1, thresh=int(len(px) * 0.6))
dates = pd.date_range("2021-03-31", "2025-06-30", freq="QE")
rng = np.random.default_rng(0)
results: dict = {}
for seed_name, seed in SEEDS.items():
seed = {t: w for t, w in seed.items() if t in px.columns}
rows = {"greedy": [], "random": [], "momentum": [], "seed_only": []}
for d in dates:
hist = px.loc[px.index <= d]
end = d + pd.Timedelta(days=int(HOLD_DAYS * 365 / 252))
if end > px.index[-1]:
break
univ = [t for t in UNIVERSE if t in hist.columns and t not in seed
and int(hist[t].iloc[-500:].notna().sum()) >= 400]
if len(univ) < 6:
continue
rets = CX.returns_matrix(hist, None, list(seed) + univ)
cov, tick, _ = CX.shrunk_cov(rets)
cands = [{"ticker": t, "fit": {"score": 0.0}} for t in univ if t in tick]
picks = [p["ticker"] for p in CX.greedy_select(seed, cands, cov, tick, max_adds=3, w_add=W_ADD)]
mom = hist[univ].iloc[-1] / hist[univ].iloc[-252] - 1
mom_picks = list(mom.dropna().sort_values(ascending=False).index[:3])
def book(adds):
w = {t: v * (1 - W_ADD * len(adds)) for t, v in seed.items()}
for a in adds:
w[a] = W_ADD
return w
for name, adds in (("greedy", picks), ("momentum", mom_picks), ("seed_only", [])):
r = _hold(px, book(adds), d, end)
if r is not None:
rows[name].append(_stats(r))
rand = []
for _ in range(N_RANDOM):
adds = list(rng.choice(univ, size=3, replace=False))
r = _hold(px, book(adds), d, end)
if r is not None:
rand.append(_stats(r))
if rand:
rows["random"].append({k: float(np.mean([x[k] for x in rand])) for k in rand[0]})
results[seed_name] = {name: {k: round(float(np.mean([x[k] for x in v])), 4) for k in v[0]} | {"n_periods": len(v)}
for name, v in rows.items() if v}
out = ROOT / "data" / "policy_backtest.json"
out.write_text(json.dumps(results, indent=1))
print("| seed | policy | periods | avg 12m return | avg vol | avg max DD | avg Sharpe |")
print("|---|---|---|---|---|---|---|")
for s, pols in results.items():
for p, m in pols.items():
print(f"| {s} | {p} | {m['n_periods']} | {m['return'] * 100:+.1f}% | {m['vol'] * 100:.1f}% | {m['max_dd'] * 100:.1f}% | {m['sharpe']:.2f} |")
print("saved", out)
if __name__ == "__main__":
main()