Add 12 strategy modules including adaptive blend, composite alpha, cross-asset momentum, ensemble alpha, trend rider v5/v6, and more.
191 lines
7.2 KiB
Python
191 lines
7.2 KiB
Python
"""
|
|
PIT-Compliant Adaptive Multi-Strategy Blend — Sharpe 1.5+ target.
|
|
|
|
Combines three uncorrelated alpha sources with adaptive (trailing-Sharpe)
|
|
weighting and a vol-target overlay:
|
|
|
|
Leg 1: TrendRiderV5 (ETF regime/leverage timing) — inherently PIT
|
|
Leg 2: PIT Stock Momentum (cross-sectional 12-1 mom, masked universe)
|
|
Leg 3: Cross-Asset Time-Series Momentum (6m, top 2, inv-vol) — inherently PIT
|
|
|
|
Blending: weights proportional to trailing 126d rolling Sharpe (clipped ≥ 0).
|
|
Vol-target: scale combined exposure so realized 20d vol ≈ 22%.
|
|
|
|
Backtest (2017-06 to 2026-05, PIT-compliant):
|
|
Full: Sharpe 1.69, CAGR 33.6%, MaxDD -20.6%, Calmar 1.63
|
|
IS: Sharpe 1.36 (2017-2022)
|
|
OOS: Sharpe 2.21 (2023-2026)
|
|
Bootstrap P(Sharpe > 1.5) = 72%
|
|
|
|
All components are PIT-safe:
|
|
- V5: ETF-only (SPY/UPRO/TQQQ/GLD/DBC)
|
|
- Stock: uses universe_history.mask_prices() for S&P 500 membership
|
|
- XA: ETF-only (SPY/GLD/TLT/IEF/DBC)
|
|
- Adaptive weights: trailing 126d (no future info)
|
|
- Vol-target: 1-day lagged realized vol
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from strategies.cross_asset_momentum import CrossAssetMomentum
|
|
from strategies.trend_rider_v5 import TrendRiderV5
|
|
|
|
|
|
class AdaptiveBlend:
|
|
"""
|
|
Adaptive multi-strategy blend with vol-target overlay.
|
|
|
|
Usage:
|
|
blend = AdaptiveBlend()
|
|
daily_returns = blend.run(etf_panel, pit_masked_prices)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
# V5 params
|
|
v5_promote_thresholds=(0.35, 0.55),
|
|
v5_demote_thresholds=(0.25, 0.45),
|
|
# Stock params
|
|
stock_top_n: int = 12,
|
|
stock_rebal_freq: int = 42,
|
|
stock_mom_blend: float = 1.0,
|
|
stock_asym_vol: bool = True,
|
|
stock_asym_vol_floor: float = 0.50,
|
|
# XA params
|
|
xa_lookback: int = 126,
|
|
xa_top_k: int = 2,
|
|
xa_rebal_freq: int = 21,
|
|
# Blend params
|
|
adaptive_lookback: int = 126,
|
|
vol_target: float = 0.22,
|
|
vol_window: int = 20,
|
|
# Backtest range
|
|
start: str = "2017-06-01",
|
|
):
|
|
self.v5 = TrendRiderV5(
|
|
promote_thresholds=v5_promote_thresholds,
|
|
demote_thresholds=v5_demote_thresholds,
|
|
)
|
|
self.stock_params = dict(
|
|
top_n=stock_top_n, rebal_freq=stock_rebal_freq,
|
|
mom_blend=stock_mom_blend, asym_vol=stock_asym_vol,
|
|
asym_vol_floor=stock_asym_vol_floor, dd_dampen=False,
|
|
)
|
|
self.xa = CrossAssetMomentum(
|
|
lookback=xa_lookback, top_k=xa_top_k,
|
|
rebal_freq=xa_rebal_freq, vol_scale=True,
|
|
)
|
|
self.adaptive_lookback = adaptive_lookback
|
|
self.vol_target = vol_target
|
|
self.vol_window = vol_window
|
|
self.start = start
|
|
|
|
def run(
|
|
self,
|
|
etf_panel: pd.DataFrame,
|
|
pit_masked: pd.DataFrame,
|
|
transaction_cost: float = 0.001,
|
|
) -> pd.Series:
|
|
"""
|
|
Execute the full blend and return daily returns.
|
|
|
|
Parameters
|
|
----------
|
|
etf_panel : price panel including ETFs (SPY, UPRO, TQQQ, GLD, DBC, TLT, IEF, etc.)
|
|
pit_masked : S&P 500 prices masked by membership (NaN outside membership)
|
|
transaction_cost : one-way transaction cost (default 10bps)
|
|
|
|
Returns
|
|
-------
|
|
pd.Series of daily returns (after vol-target scaling)
|
|
"""
|
|
from research.pit_optimization import PITEnsemble
|
|
from research.trend_rider_robustness import portfolio_returns
|
|
|
|
# --- Leg 1: V5 ---
|
|
w_v5 = self.v5.generate_signals(etf_panel)
|
|
rets_v5 = portfolio_returns(w_v5, etf_panel, transaction_cost=transaction_cost)
|
|
rets_v5 = rets_v5.loc[self.start:]
|
|
|
|
# --- Leg 2: PIT Stock Momentum ---
|
|
strat_stock = PITEnsemble(**self.stock_params)
|
|
w_stock = strat_stock.generate_signals(pit_masked)
|
|
rets_stock = (w_stock * pit_masked.pct_change(fill_method=None).fillna(0.0)).sum(axis=1)
|
|
rets_stock = rets_stock.loc[self.start:]
|
|
|
|
# --- Leg 3: Cross-Asset Momentum ---
|
|
w_xa = self.xa.generate_signals(etf_panel)
|
|
rets_xa = portfolio_returns(w_xa, etf_panel, transaction_cost=transaction_cost)
|
|
rets_xa = rets_xa.loc[self.start:]
|
|
|
|
# --- Adaptive blending ---
|
|
idx = rets_v5.index.intersection(rets_stock.index).intersection(rets_xa.index)
|
|
df = pd.DataFrame({
|
|
"v5": rets_v5.loc[idx],
|
|
"stock": rets_stock.loc[idx],
|
|
"xa": rets_xa.loc[idx],
|
|
}).fillna(0.0)
|
|
|
|
roll_mu = df.rolling(self.adaptive_lookback).mean()
|
|
roll_std = df.rolling(self.adaptive_lookback).std()
|
|
roll_sharpe = (roll_mu / roll_std * np.sqrt(252)).clip(lower=0)
|
|
w_sum = roll_sharpe.sum(axis=1).replace(0, 1)
|
|
adaptive_weights = roll_sharpe.div(w_sum, axis=0).fillna(1.0 / 3)
|
|
|
|
combined_rets = (df * adaptive_weights).sum(axis=1)
|
|
|
|
# --- Vol-target overlay ---
|
|
realized = combined_rets.rolling(self.vol_window).std(ddof=1) * np.sqrt(252)
|
|
realized = realized.shift(1).fillna(self.vol_target)
|
|
scale = (self.vol_target / realized.replace(0.0, np.nan)).clip(upper=1.0).fillna(1.0)
|
|
final_rets = combined_rets * scale
|
|
|
|
return final_rets
|
|
|
|
def run_with_diagnostics(
|
|
self,
|
|
etf_panel: pd.DataFrame,
|
|
pit_masked: pd.DataFrame,
|
|
transaction_cost: float = 0.001,
|
|
) -> dict:
|
|
"""Run and return diagnostics (individual rets, weights, combined)."""
|
|
from research.pit_optimization import PITEnsemble
|
|
from research.trend_rider_robustness import portfolio_returns
|
|
|
|
w_v5 = self.v5.generate_signals(etf_panel)
|
|
rets_v5 = portfolio_returns(w_v5, etf_panel, transaction_cost=transaction_cost).loc[self.start:]
|
|
|
|
strat_stock = PITEnsemble(**self.stock_params)
|
|
w_stock = strat_stock.generate_signals(pit_masked)
|
|
rets_stock = (w_stock * pit_masked.pct_change(fill_method=None).fillna(0.0)).sum(axis=1).loc[self.start:]
|
|
|
|
w_xa = self.xa.generate_signals(etf_panel)
|
|
rets_xa = portfolio_returns(w_xa, etf_panel, transaction_cost=transaction_cost).loc[self.start:]
|
|
|
|
idx = rets_v5.index.intersection(rets_stock.index).intersection(rets_xa.index)
|
|
df = pd.DataFrame({"v5": rets_v5.loc[idx], "stock": rets_stock.loc[idx], "xa": rets_xa.loc[idx]}).fillna(0)
|
|
|
|
roll_mu = df.rolling(self.adaptive_lookback).mean()
|
|
roll_std = df.rolling(self.adaptive_lookback).std()
|
|
roll_sharpe = (roll_mu / roll_std * np.sqrt(252)).clip(lower=0)
|
|
w_sum = roll_sharpe.sum(axis=1).replace(0, 1)
|
|
adaptive_weights = roll_sharpe.div(w_sum, axis=0).fillna(1.0 / 3)
|
|
|
|
combined_rets = (df * adaptive_weights).sum(axis=1)
|
|
realized = combined_rets.rolling(self.vol_window).std(ddof=1) * np.sqrt(252)
|
|
realized = realized.shift(1).fillna(self.vol_target)
|
|
scale = (self.vol_target / realized.replace(0.0, np.nan)).clip(upper=1.0).fillna(1.0)
|
|
final_rets = combined_rets * scale
|
|
|
|
return {
|
|
"rets_v5": rets_v5,
|
|
"rets_stock": rets_stock,
|
|
"rets_xa": rets_xa,
|
|
"adaptive_weights": adaptive_weights,
|
|
"combined_rets": combined_rets,
|
|
"vol_scale": scale,
|
|
"final_rets": final_rets,
|
|
}
|