Files
quant-trading/research/lab/us_final.py
Gahow Wang 722cade974 fix: unify backtest execution and add forward validation candidates
Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band.

Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
2026-10-01 02:24:01 -07:00

153 lines
7.1 KiB
Python

"""
Final check of the recommended US portfolio: three sleeves with different
return sources, equal capital, rebalanced monthly between sleeves.
momentum : overnight-return momentum + distance from 126d low, top 10, 10-day
rebalance, hold while rank <= 30 (the live overnight_recovery_top10)
defensive : sector-neutral quality (GP/A, CFO/A, ROE, accruals, leverage) +
low risk (idiosyncratic vol, beta), top 20, monthly, buffer 2
(price-only alternative: low risk alone)
trend : TrendRider V7 VT24 on leveraged ETFs (live trend_rider_v7_vt24)
PYTHONPATH=. uv run python -m research.lab.trend_rider_long # once: data/research/tr_long.pkl
PYTHONPATH=. uv run --with numba python -m research.lab.us_final
The live version is ``us_three_sleeve`` (strategies/sleeve_portfolio.py).
"""
from __future__ import annotations
import json
import os
import pickle
import warnings
import numpy as np
import pandas as pd
from research.lab import fundamentals as fu
from research.lab.engine import Costs
from research.lab.factor_scan import RESULTS, load
from research.lab.report import deflated_sharpe, stats
from research.lab.rotation import factor_ranks
from research.lab.us_sweep import _blend, run, sector_rank
warnings.filterwarnings("ignore")
SPAN = ("2011-01-03", "2026-09-24")
LONG = ("2006-09-27", "2026-09-24")
BLOCKS = [("2006-09-27", "2010-12-31"), ("2011-01-01", "2014-12-31"), ("2015-01-01", "2018-12-31"),
("2019-01-01", "2022-12-31"), ("2023-01-01", "2026-09-24")]
def sleeve_scores(p):
pr = factor_ranks(p, "us")
F = fu.load(p)
Q = _blend(*(sector_rank(F[k], p) for k in ("gp_a", "cfo_a", "roe", "accruals", "leverage")))
low = _blend(pr["ivol"], pr["low_beta"])
return {
"momentum": (_blend(pr["overnight_mom"], pr["rec_126"]), dict(top_n=10, buffer=2.0, every=10)),
"defensive": (_blend(Q, low), dict(top_n=20, buffer=2.0, every=21)),
"lowrisk": (low, dict(top_n=20, buffer=2.0, every=21)),
}
def monthly_blend(R: pd.DataFrame, weights: dict[str, float]) -> pd.Series:
"""Sleeves drift inside each month and are reset to ``weights`` at month
ends (the sleeves are separate accounts; only capital moves)."""
cols = list(weights)
w0 = np.array([weights[c] for c in cols])
out = []
for _, g in R[cols].groupby(R.index.to_period("M")):
grow = (1 + g).cumprod()
val = grow.mul(w0, axis=1).sum(axis=1)
prev = np.r_[1.0, val.values[:-1]]
out.append(pd.Series(val.values / prev - 1, index=g.index))
return pd.concat(out)
def describe(r: pd.Series, blocks=BLOCKS) -> dict:
s = stats((1 + r).cumprod())
bl = {f"{a[:4]}-{b[:4]}": stats((1 + r.loc[a:b]).cumprod())["sharpe"]
for a, b in blocks if len(r.loc[a:b]) > 200}
yrs = ((1 + r).groupby(r.index.year).prod() - 1)
return {**{k: float(v) for k, v in s.items()}, "blocks": bl, "yearly": {int(k): float(v) for k, v in yrs.items()}}
def line(name, d) -> str:
return (f"{name:44s} cagr {d['cagr']*100:5.1f}% sh {d['sharpe']:.2f} vol {d['vol']*100:4.1f}% "
f"mdd {d['mdd']*100:5.1f}% calmar {d['calmar']:.2f} worst yr {min(d['yearly'].values())*100:5.1f}% "
f"blocks [{' '.join(f'{v:.2f}' for v in d['blocks'].values())}]")
def main() -> None:
p, _ = load("us")
pf = p.slice()
pf.costs = Costs(p.costs.buy, p.costs.sell, None, 2.0, 10_000.0) # + $2 per order on a $10k sleeve
S = sleeve_scores(p)
R, Rfee = {}, {}
for name, (score, kw) in S.items():
span = LONG if name in ("momentum", "lowrisk") else SPAN
R[name] = run(p, score.where(p.member), [span], **kw)["_rets"]
Rfee[name] = run(pf, score.where(p.member), [span], **kw)["_rets"]
ew = p.close.notna().astype(float).where(p.member)
R["ew_sp500"] = run(p, ew, [LONG], top_n=10000, every=10)["_rets"]
with open(os.path.join("data", "research", "tr_long.pkl"), "rb") as fh:
tr = pickle.load(fh)["equity"]["trend_rider_v7_vt24"]
R["trend"] = Rfee["trend"] = tr.pct_change().reindex(p.dates).loc["2010-03-01":] # $2/order already
R["spy"] = p.benchmark.ffill().pct_change().reindex(p.dates)
R = pd.DataFrame(R).loc[LONG[0]:LONG[1]]
Rfee = pd.DataFrame(Rfee).loc[LONG[0]:LONG[1]]
out = {}
span = R.loc[SPAN[0]:SPAN[1], ["momentum", "defensive", "lowrisk", "trend", "spy"]].fillna(0.0)
monthly = (1 + span).resample("ME").prod() - 1
print("monthly return correlation 2011-2026:\n", monthly.corr().round(2).to_string(), "\n")
print("=== sleeves (10 bp costs; trend includes $2/order)")
for c in R.columns:
r = R[c].dropna()
out[c] = describe(r)
print(line(f"{c} {r.index[0].year}-", out[c]))
three = {"momentum": 1 / 3, "defensive": 1 / 3, "trend": 1 / 3}
three_px = {"momentum": 1 / 3, "lowrisk": 1 / 3, "trend": 1 / 3}
print("\n=== portfolios 2011-2026, monthly rebalanced between sleeves")
for label, w, src in [("3 sleeves (defensive = quality+lowrisk)", three, R),
("3 sleeves (defensive = lowrisk, price only)", three_px, R),
("3 sleeves, + $2/order on $10k sleeves", three, Rfee),
("momentum + defensive (no ETF sleeve)", {"momentum": .5, "defensive": .5}, R),
("momentum + trend", {"momentum": .5, "trend": .5}, R)]:
r = monthly_blend(src.loc[SPAN[0]:SPAN[1]].fillna(0.0), w)
out[label] = describe(r)
print(line(label, out[label]))
print("\n=== price-only walk-back 2006-2026 (includes 2008; trend sleeve from 2010-03, before that 50/50)")
rr = R.loc[LONG[0]:LONG[1]]
pre = monthly_blend(rr.loc[:"2010-02-28", ["momentum", "lowrisk"]].fillna(0.0), {"momentum": .5, "lowrisk": .5})
post = monthly_blend(rr.loc["2010-03-01":].fillna(0.0), three_px)
r = pd.concat([pre, post])
out["walkback price-only"] = describe(r)
print(line("momentum + lowrisk (+ trend from 2010)", out["walkback price-only"]))
for c in ("momentum", "lowrisk", "ew_sp500", "spy"):
print(line(f" {c} 2006-", describe(rr[c].dropna())))
# deflated Sharpe: every configuration this and the previous round tried
from research.lab.trials import count_trials
n = count_trials(RESULTS, "us")
if n < 1:
raise ValueError("A trial ledger is required for DSR")
r = monthly_blend(R.loc[SPAN[0]:SPAN[1]].fillna(0.0), three)
dsr = deflated_sharpe(r, n)
out["dsr"] = {"observed_trials_lower_bound": n, "role": "development", **{k: float(v) for k, v in dsr.items()}}
print(f"\nDSR of the 3-sleeve book: {dsr['dsr']:.3f} (Sharpe {dsr['sr_annual']:.2f} vs "
f"expected max of {n} unskilled trials {dsr['sr0_annual']:.2f})")
print("\nyearly returns (3 sleeves vs SPY):")
spy_y = out["spy"]["yearly"]
for y, v in out["3 sleeves (defensive = quality+lowrisk)"]["yearly"].items():
print(f" {y}: {v*100:6.1f}% SPY {spy_y.get(y, float('nan'))*100:6.1f}%")
with open(os.path.join(RESULTS, "us_final.json"), "w") as fh:
json.dump(out, fh, indent=1)
R.to_pickle(os.path.join(RESULTS, "us_final_sleeves.pkl"))
if __name__ == "__main__":
main()