research: add strategy evaluation and exploration scripts
Add 28 research scripts covering DCA simulation, momentum evaluation, Sharpe optimization, trend rider analysis, and US fundamentals exploration.
This commit is contained in:
321
research/sharpe_blend.py
Normal file
321
research/sharpe_blend.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
PIT-compliant Sharpe 1.5+ blend: V5 ETF timing + PIT stock-picking + cross-asset momentum.
|
||||
|
||||
Combines three uncorrelated alpha sources with a vol-target overlay.
|
||||
All components are PIT-safe (ETF-only or membership-masked).
|
||||
|
||||
Run:
|
||||
uv run python -m research.sharpe_blend
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from research.permanent_yearly import load_etfs
|
||||
from research.pit_backtest import load_pit_prices, pit_universe
|
||||
from research.pit_optimization import PITEnsemble, compute_metrics
|
||||
from research.trend_rider_robustness import portfolio_returns, evaluate_weights
|
||||
from research.trend_rider_v6_eval import load_combined_panel
|
||||
from strategies.cross_asset_momentum import CrossAssetMomentum
|
||||
from strategies.trend_rider_v5 import TrendRiderV5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_all_data() -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""Return (etf_panel, pit_stock_prices) aligned to common dates."""
|
||||
# ETF panel for V5 and cross-asset
|
||||
etf_panel = load_combined_panel()
|
||||
|
||||
# Ensure cross-asset ETFs are present (TLT, IEF)
|
||||
extra_etfs = ["TLT", "IEF"]
|
||||
missing = [t for t in extra_etfs if t not in etf_panel.columns]
|
||||
if missing:
|
||||
extra = load_etfs(missing, start="2013-06-01")
|
||||
extra = extra.reindex(etf_panel.index).ffill()
|
||||
etf_panel = etf_panel.join(extra, how="left")
|
||||
|
||||
# PIT-masked stock prices
|
||||
pit_prices = load_pit_prices()
|
||||
pit_masked = pit_universe(pit_prices)
|
||||
|
||||
return etf_panel, pit_masked
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategy runners — produce daily returns series
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_v5(panel: pd.DataFrame, start: str = "2017-06-01") -> pd.Series:
|
||||
"""TrendRiderV5 daily returns."""
|
||||
v5 = TrendRiderV5()
|
||||
weights = v5.generate_signals(panel)
|
||||
rets = portfolio_returns(weights, panel, transaction_cost=0.001)
|
||||
return rets.loc[start:]
|
||||
|
||||
|
||||
def run_pit_stock(pit_prices: pd.DataFrame, start: str = "2017-06-01") -> pd.Series:
|
||||
"""PIT stock-picking (cross-sectional momentum) daily returns."""
|
||||
strat = PITEnsemble(
|
||||
top_n=12, rebal_freq=42, mom_blend=1.0,
|
||||
asym_vol=True, asym_vol_floor=0.50,
|
||||
dd_dampen=False,
|
||||
)
|
||||
weights = strat.generate_signals(pit_prices)
|
||||
daily_rets = (weights * pit_prices.pct_change().fillna(0.0)).sum(axis=1)
|
||||
return daily_rets.loc[start:]
|
||||
|
||||
|
||||
def run_cross_asset(panel: pd.DataFrame, start: str = "2017-06-01") -> pd.Series:
|
||||
"""Cross-asset time-series momentum daily returns."""
|
||||
strat = CrossAssetMomentum(lookback=252, top_k=3, rebal_freq=21, vol_scale=True)
|
||||
weights = strat.generate_signals(panel)
|
||||
rets = portfolio_returns(weights, panel, transaction_cost=0.001)
|
||||
return rets.loc[start:]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vol-target overlay (standalone, operates on combined returns)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def vol_target_returns(
|
||||
combined_rets: pd.Series,
|
||||
target_vol: float = 0.18,
|
||||
vol_window: int = 20,
|
||||
) -> pd.Series:
|
||||
"""Scale combined returns by min(1, target_vol / realized_vol)."""
|
||||
realized = combined_rets.rolling(vol_window).std(ddof=1) * np.sqrt(252)
|
||||
realized = realized.shift(1).fillna(target_vol)
|
||||
scale = (target_vol / realized.replace(0.0, np.nan)).clip(upper=1.0).fillna(1.0)
|
||||
return combined_rets * scale
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blend engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def blend_returns(
|
||||
rets_v5: pd.Series,
|
||||
rets_stock: pd.Series,
|
||||
rets_xasset: pd.Series,
|
||||
w_v5: float = 0.50,
|
||||
w_stock: float = 0.30,
|
||||
w_xasset: float = 0.20,
|
||||
) -> pd.Series:
|
||||
"""Weighted blend of three strategy return streams."""
|
||||
# Align to common dates
|
||||
idx = rets_v5.index.intersection(rets_stock.index).intersection(rets_xasset.index)
|
||||
return (w_v5 * rets_v5.loc[idx]
|
||||
+ w_stock * rets_stock.loc[idx]
|
||||
+ w_xasset * rets_xasset.loc[idx])
|
||||
|
||||
|
||||
def inverse_vol_weights(
|
||||
rets_v5: pd.Series,
|
||||
rets_stock: pd.Series,
|
||||
rets_xasset: pd.Series,
|
||||
window: int = 63,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Compute inverse-vol weights from trailing realized vol."""
|
||||
vols = pd.DataFrame({
|
||||
"v5": rets_v5.rolling(window).std() * np.sqrt(252),
|
||||
"stock": rets_stock.rolling(window).std() * np.sqrt(252),
|
||||
"xasset": rets_xasset.rolling(window).std() * np.sqrt(252),
|
||||
}).iloc[-1]
|
||||
inv = 1.0 / vols.replace(0, np.nan)
|
||||
w = inv / inv.sum()
|
||||
return w["v5"], w["stock"], w["xasset"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sweep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BLEND_CONFIGS = [
|
||||
("V5=50/Stock=30/XA=20", 0.50, 0.30, 0.20),
|
||||
("V5=40/Stock=40/XA=20", 0.40, 0.40, 0.20),
|
||||
("V5=60/Stock=20/XA=20", 0.60, 0.20, 0.20),
|
||||
("V5=50/Stock=25/XA=25", 0.50, 0.25, 0.25),
|
||||
("V5=45/Stock=35/XA=20", 0.45, 0.35, 0.20),
|
||||
("V5=55/Stock=25/XA=20", 0.55, 0.25, 0.20),
|
||||
]
|
||||
|
||||
VOL_TARGETS = [None, 0.15, 0.18, 0.20, 0.22, 0.25]
|
||||
|
||||
|
||||
def run_sweep(rets_v5, rets_stock, rets_xasset) -> pd.DataFrame:
|
||||
"""Sweep blend configs × vol targets, return summary DataFrame."""
|
||||
rows = []
|
||||
|
||||
# Add inverse-vol config
|
||||
iv_w = inverse_vol_weights(rets_v5, rets_stock, rets_xasset)
|
||||
configs = list(BLEND_CONFIGS) + [
|
||||
(f"InvVol({iv_w[0]:.0%}/{iv_w[1]:.0%}/{iv_w[2]:.0%})", *iv_w)
|
||||
]
|
||||
|
||||
for name, wv, ws, wx in configs:
|
||||
combined = blend_returns(rets_v5, rets_stock, rets_xasset, wv, ws, wx)
|
||||
for tgt in VOL_TARGETS:
|
||||
if tgt is not None:
|
||||
final = vol_target_returns(combined, target_vol=tgt)
|
||||
label = f"{name} | VT={tgt}"
|
||||
else:
|
||||
final = combined
|
||||
label = f"{name} | no-VT"
|
||||
m = compute_metrics(final)
|
||||
m["label"] = label
|
||||
m["w_v5"] = wv
|
||||
m["w_stock"] = ws
|
||||
m["w_xasset"] = wx
|
||||
m["vol_target"] = tgt
|
||||
rows.append(m)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df = df.sort_values("sharpe", ascending=False).reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_oos_split(rets: pd.Series, split_date="2023-01-01"):
|
||||
"""Split returns into IS and OOS."""
|
||||
is_rets = rets[rets.index < split_date]
|
||||
oos_rets = rets[rets.index >= split_date]
|
||||
return is_rets, oos_rets
|
||||
|
||||
|
||||
def block_bootstrap(rets: pd.Series, n_boot: int = 5000, block_size: int = 63) -> np.ndarray:
|
||||
"""Block bootstrap of annualized Sharpe ratio."""
|
||||
n = len(rets)
|
||||
arr = rets.values
|
||||
sharpes = np.empty(n_boot)
|
||||
rng = np.random.default_rng(42)
|
||||
n_blocks = int(np.ceil(n / block_size))
|
||||
|
||||
for i in range(n_boot):
|
||||
starts = rng.integers(0, n - block_size, size=n_blocks)
|
||||
sample = np.concatenate([arr[s:s + block_size] for s in starts])[:n]
|
||||
mu = sample.mean()
|
||||
sigma = sample.std(ddof=1)
|
||||
sharpes[i] = mu / sigma * np.sqrt(252) if sigma > 0 else 0.0
|
||||
return sharpes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("PIT-Compliant Multi-Strategy Blend — Sharpe 1.5+ Target")
|
||||
print("=" * 80)
|
||||
|
||||
# Load data
|
||||
print("\n[1] Loading data...")
|
||||
etf_panel, pit_masked = load_all_data()
|
||||
|
||||
# Run individual strategies
|
||||
print("\n[2] Running individual strategies...")
|
||||
rets_v5 = run_v5(etf_panel)
|
||||
rets_stock = run_pit_stock(pit_masked)
|
||||
rets_xasset = run_cross_asset(etf_panel)
|
||||
|
||||
# Individual metrics
|
||||
print("\n--- Individual Strategy Metrics ---")
|
||||
for name, r in [("V5 ETF Timing", rets_v5),
|
||||
("PIT Stock Momentum", rets_stock),
|
||||
("Cross-Asset Momentum", rets_xasset)]:
|
||||
m = compute_metrics(r)
|
||||
print(f" {name:<25s} Sharpe={m['sharpe']:5.2f} CAGR={m['cagr']*100:5.1f}% "
|
||||
f"Vol={m['vol']*100:5.1f}% MaxDD={m['max_dd']*100:5.1f}%")
|
||||
|
||||
# Correlation diagnostic
|
||||
print("\n--- Correlation Matrix (daily returns) ---")
|
||||
corr_df = pd.DataFrame({
|
||||
"V5": rets_v5, "Stock": rets_stock, "XAsset": rets_xasset
|
||||
}).dropna()
|
||||
corr = corr_df.corr()
|
||||
print(corr.to_string(float_format=lambda x: f"{x:.3f}"))
|
||||
|
||||
# Rolling correlation
|
||||
print("\n--- Rolling 63d Correlations (mean / max) ---")
|
||||
for pair in [("V5", "Stock"), ("V5", "XAsset"), ("Stock", "XAsset")]:
|
||||
roll = corr_df[pair[0]].rolling(63).corr(corr_df[pair[1]])
|
||||
print(f" {pair[0]:>8s} vs {pair[1]:<8s}: mean={roll.mean():.3f} max={roll.max():.3f}")
|
||||
|
||||
# Sweep
|
||||
print("\n[3] Running blend sweep...")
|
||||
results = run_sweep(rets_v5, rets_stock, rets_xasset)
|
||||
|
||||
print("\n--- Top 15 Configurations ---")
|
||||
print(f" {'Label':<50s} {'Sharpe':>7s} {'CAGR':>7s} {'Vol':>7s} {'MaxDD':>7s} {'Calmar':>7s}")
|
||||
for _, row in results.head(15).iterrows():
|
||||
print(f" {row['label']:<50s} {row['sharpe']:7.2f} "
|
||||
f"{row['cagr']*100:6.1f}% {row['vol']*100:6.1f}% "
|
||||
f"{row['max_dd']*100:6.1f}% {row['calmar']:6.2f}")
|
||||
|
||||
# Best config validation
|
||||
best = results.iloc[0]
|
||||
print(f"\n--- Best Config: {best['label']} ---")
|
||||
best_rets = blend_returns(rets_v5, rets_stock, rets_xasset,
|
||||
best["w_v5"], best["w_stock"], best["w_xasset"])
|
||||
if best["vol_target"] is not None:
|
||||
best_rets = vol_target_returns(best_rets, target_vol=best["vol_target"])
|
||||
|
||||
# IS/OOS
|
||||
print("\n[4] IS/OOS Validation (split: 2023-01-01)...")
|
||||
is_rets, oos_rets = is_oos_split(best_rets)
|
||||
is_m = compute_metrics(is_rets)
|
||||
oos_m = compute_metrics(oos_rets)
|
||||
print(f" IS (2017-2022): Sharpe={is_m['sharpe']:5.2f} CAGR={is_m['cagr']*100:5.1f}% MaxDD={is_m['max_dd']*100:5.1f}%")
|
||||
print(f" OOS (2023-2026): Sharpe={oos_m['sharpe']:5.2f} CAGR={oos_m['cagr']*100:5.1f}% MaxDD={oos_m['max_dd']*100:5.1f}%")
|
||||
print(f" OOS/IS ratio: {oos_m['sharpe']/is_m['sharpe']:.2f}" if is_m['sharpe'] > 0 else "")
|
||||
|
||||
# Bootstrap
|
||||
print("\n[5] Block Bootstrap (5000 resamples, block=63d)...")
|
||||
boot = block_bootstrap(best_rets, n_boot=5000)
|
||||
print(f" Median Sharpe: {np.median(boot):.2f}")
|
||||
print(f" 5th pctile: {np.percentile(boot, 5):.2f}")
|
||||
print(f" 95th pctile: {np.percentile(boot, 95):.2f}")
|
||||
print(f" P(Sharpe>1.0): {(boot > 1.0).mean()*100:.1f}%")
|
||||
print(f" P(Sharpe>1.3): {(boot > 1.3).mean()*100:.1f}%")
|
||||
print(f" P(Sharpe>1.5): {(boot > 1.5).mean()*100:.1f}%")
|
||||
|
||||
# Parameter sensitivity
|
||||
print("\n[6] Parameter Sensitivity (±perturbation on blend weights)...")
|
||||
base_w = (best["w_v5"], best["w_stock"], best["w_xasset"])
|
||||
perturbations = [
|
||||
("base", 0, 0, 0),
|
||||
("+10% V5", 0.10, -0.05, -0.05),
|
||||
("-10% V5", -0.10, 0.05, 0.05),
|
||||
("+10% Stock", -0.05, 0.10, -0.05),
|
||||
("-10% Stock", 0.05, -0.10, 0.05),
|
||||
]
|
||||
for pname, dv, ds, dx in perturbations:
|
||||
wv = max(0.05, base_w[0] + dv)
|
||||
ws = max(0.05, base_w[1] + ds)
|
||||
wx = max(0.05, base_w[2] + dx)
|
||||
total = wv + ws + wx
|
||||
wv, ws, wx = wv/total, ws/total, wx/total
|
||||
r = blend_returns(rets_v5, rets_stock, rets_xasset, wv, ws, wx)
|
||||
if best["vol_target"] is not None:
|
||||
r = vol_target_returns(r, target_vol=best["vol_target"])
|
||||
m = compute_metrics(r)
|
||||
print(f" {pname:<15s}: Sharpe={m['sharpe']:5.2f} CAGR={m['cagr']*100:5.1f}%")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user