feat: US 3-sleeve portfolio and lowrisk_top20; US portfolio research harness
Register two live strategies from the 2026-09-29 US research: - lowrisk_top20: idiosyncratic vol + low beta, top 20, monthly, buffer 2 (new `low_risk` signal in strategies/robust_momentum.py) - us_three_sleeve: 1/3 each of overnight_recovery_top10, lowrisk_top20 and trend_rider_v7_vt24 in one account (strategies/sleeve_portfolio.py, a mixed stock+ETF strategy). Sleeve shares drift with their returns and reset at a month start once any is >25% off its share. Research (research/lab, S&P 500 PIT, next-open fills): - rotation.py / persistence.py: walk-forward factor weighting (factor momentum, rolling IC, market state) and last-year's-winner selection do not beat a fixed composite - us_sweep.py: holdings count, z/vol weights, dynamic N, tranching and market overlays (Faber SMA, de-risk vol target, statistical jump model, momentum state); engine.py gains zvol weighting and dyn_n - fetch_us_fundamentals.py / fundamentals.py / fund_eval.py: SEC EDGAR point-in-time fundamentals (first-filed values) and factor tests - trend_rider_long.py / us_final.py: 2010-2026 TrendRider curves and the 3-sleeve check (2011-2026 Sharpe 1.21, CAGR 21.9%, MaxDD -23%) Web-UI engine, 2017-09..2026-09, $2/order: us_three_sleeve CAGR 28.9%, Sharpe 1.49, MaxDD -24.8%. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
98
research/lab/persistence.py
Normal file
98
research/lab/persistence.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Does a factor combo that did well recently keep doing well?
|
||||
|
||||
For every pair of price factors (equal-weight rank composite) compute a cheap
|
||||
daily proxy: the equal-weight return of its top-N members on the previous
|
||||
close's scores, minus the member average. Then, each calendar year, rank the
|
||||
pairs by their trailing 1/3/5-year proxy Sharpe and look at how the chosen
|
||||
pairs do the NEXT year, against the average pair and a fixed pick.
|
||||
|
||||
PYTHONPATH=. uv run python -m research.lab.persistence
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import os
|
||||
import pickle
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy import stats as sps
|
||||
|
||||
from research.lab.factor_scan import RESULTS, load
|
||||
from research.lab.rotation import factor_ranks
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def top_proxy(p, score: pd.DataFrame, top_n: int = 20) -> pd.Series:
|
||||
"""Daily excess return of the top-N (previous close scores) over members."""
|
||||
S = score.shift(1).values
|
||||
r = p.close.pct_change(fill_method=None).values
|
||||
mem = p.member.shift(1, fill_value=False).values
|
||||
S = np.where(mem & ~np.isnan(r), S, np.nan)
|
||||
out = np.full(len(S), np.nan)
|
||||
base = np.nanmean(np.where(mem, r, np.nan), axis=1)
|
||||
for t in range(len(S)):
|
||||
row = S[t]
|
||||
ok = np.where(~np.isnan(row))[0]
|
||||
if len(ok) < top_n * 3:
|
||||
continue
|
||||
top = ok[np.argpartition(-row[ok], top_n)[:top_n]]
|
||||
out[t] = np.nanmean(r[t, top]) - base[t]
|
||||
return pd.Series(out, index=p.dates)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p, _ = load("us")
|
||||
ranks = factor_ranks(p, "us")
|
||||
names = sorted(ranks)
|
||||
path = os.path.join(RESULTS, "pair_proxies_us.pkl")
|
||||
if os.path.exists(path):
|
||||
with open(path, "rb") as fh:
|
||||
P = pickle.load(fh)
|
||||
else:
|
||||
t = time.time()
|
||||
P = {}
|
||||
for a, b in itertools.combinations(names, 2):
|
||||
s = (ranks[a] + ranks[b]) / 2
|
||||
P[f"{a}+{b}"] = top_proxy(p, s)
|
||||
for a in names:
|
||||
P[a] = top_proxy(p, ranks[a])
|
||||
P = pd.DataFrame(P)
|
||||
print(f"{P.shape[1]} proxies [{time.time() - t:.0f}s]", flush=True)
|
||||
with open(path, "wb") as fh:
|
||||
pickle.dump(P, fh)
|
||||
P = P.loc["2006":]
|
||||
yearly = P.groupby(P.index.year).apply(lambda x: x.mean() / x.std() * np.sqrt(252)) # excess Sharpe (IR)
|
||||
yret = P.groupby(P.index.year).sum() # ~excess return
|
||||
years = [y for y in yearly.index if y >= 2008 and y <= 2025]
|
||||
print("\nRank correlation of combo IR between consecutive years (all pairs):")
|
||||
rho = [sps.spearmanr(yearly.loc[y - 1], yearly.loc[y], nan_policy="omit").statistic for y in years]
|
||||
print(" " + " ".join(f"{y}:{r:+.2f}" for y, r in zip(years, rho)), f"| mean {np.nanmean(rho):+.3f}")
|
||||
for look in (1, 3, 5):
|
||||
rows = []
|
||||
for y in range(2006 + look, 2027):
|
||||
hist = P.loc[str(y - look):str(y - 1)]
|
||||
ir = hist.mean() / hist.std()
|
||||
nxt = yret.loc[y] if y in yret.index else None
|
||||
if nxt is None:
|
||||
continue
|
||||
best = ir.nlargest(10).index
|
||||
worst = ir.nsmallest(10).index
|
||||
rows.append({"year": y, "top10": nxt[best].mean(), "bottom10": nxt[worst].mean(),
|
||||
"all": nxt.mean(), "best1": nxt[ir.idxmax()], "best1_name": ir.idxmax()})
|
||||
df = pd.DataFrame(rows).set_index("year")
|
||||
print(f"\nlookback {look}y: next-year excess return of the pairs ranked by trailing IR")
|
||||
print(f" mean top10 {df['top10'].mean()*100:+.2f}% bottom10 {df['bottom10'].mean()*100:+.2f}% "
|
||||
f"all {df['all'].mean()*100:+.2f}% best1 {df['best1'].mean()*100:+.2f}% "
|
||||
f"top10 beats all in {(df['top10'] > df['all']).mean()*100:.0f}% of years")
|
||||
if look == 1:
|
||||
print(df.assign(**{c: (df[c] * 100).round(1) for c in ["top10", "bottom10", "all", "best1"]}).to_string())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user