Files
quant/strategies/adaptive_blend.py
Gahow Wang d086930ab3 feat: add new trading strategies
Add 12 strategy modules including adaptive blend, composite alpha,
cross-asset momentum, ensemble alpha, trend rider v5/v6, and more.
2026-05-14 12:54:05 +08:00

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,
}