Files
quant/research/sharpe_blend.py
Gahow Wang 541f7bcf5b 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.
2026-05-14 12:54:08 +08:00

322 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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