Files
quant/strategies/trend_rider_v5.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

373 lines
15 KiB
Python
Raw 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.

"""TrendRiderV5 — V3 with conviction-gated leverage tier modulation.
Design rationale
----------------
V3 picks one of {TQQQ, UPRO, GLD, DBC} and rides it 100%. Its 75 regime
switches over 11 years are the *correct* edge — we don't disturb them.
V5 layers a small post-processor: at each rebalance event V3 produces, V5
inspects the prevailing conviction and decides what fraction of the equity
sleeve is held in the 3× ETF vs its 1× counterpart. The state is a discrete
*leverage tier* in {0%, 50%, 100%} of leveraged exposure, with hysteresis
and minimum holding to keep turnover low. Specifically
pair: SPY ↔ UPRO, QQQ ↔ TQQQ
tier 0 (core_only) : 100% core (1× equity)
tier 1 (half) : 50% core + 50% leveraged (≈ 2× equity)
tier 2 (full) : 100% leveraged (3× equity)
Conviction is built from directional/regime-quality signals (trend strength,
drawdown depth, peak distance, downside-vol percentile). It is NOT a function
of two-sided realized vol — that throttled V5 in good periods. Tier
transitions require:
promote (k → k+1) : conviction ≥ promote_threshold[k+1] for confirm_days
demote (k → k-1) : conviction ≤ demote_threshold[k] for demote_confirm
with `tier_min_hold` bars between any tier change.
Risk-off behavior is unchanged from V3 (single-pick momentum leader of the
risk_off basket), preserving V3's defensive characteristics.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from strategies.permanent import TrendRiderV3
class TrendRiderV5(TrendRiderV3):
"""V3 + leverage-tier modulator on the equity sleeve.
Default tier thresholds aim for: full 3× only when (a) below-MA200 risk
is small, (b) we're near the 20-day high, and (c) drawdowns from the
recent peak are inside ~1 vol-unit. Otherwise step down to 1× or 1.5×.
"""
DEFAULT_LEVERAGED_PAIR = {"SPY": "UPRO", "QQQ": "TQQQ"}
DEFAULT_CORE_PAIR = {"UPRO": "SPY", "TQQQ": "QQQ"}
def __init__(
self,
*args,
# Conviction inputs
peak_window: int = 20,
dd_window: int = 40,
trend_lookback: int = 63,
downvol_window: int = 60,
downvol_lookback: int = 252,
# Tier thresholds [tier 1, tier 2] for promote / demote (hysteresis)
promote_thresholds: tuple[float, float] = (0.40, 0.65),
demote_thresholds: tuple[float, float] = (0.30, 0.50),
promote_confirm: int = 5,
demote_confirm: int = 3,
tier_min_hold: int = 10,
starting_tier: int = 2, # if regime is risk_on at first placement, start at 2 (full lev)
# Panic demote — bypasses min-hold when fast vol regime detected.
# Defaults below were chosen by walk-forward Calmar maximization on
# IS (2015-2020, which does NOT contain the 2024-08 crash) — not
# curve-fit to that specific event.
panic_vol_short: int = 7,
panic_vol_long: int = 60,
panic_vol_ratio: float = 1.6,
panic_peak_drop_pct: float = 0.06,
panic_peak_window: int = 5,
# Conviction component weights
w_trend: float = 0.30,
w_dd: float = 0.30,
w_peak: float = 0.25,
w_downvol: float = 0.15,
# Pair mapping
leveraged_pair: dict[str, str] | None = None,
core_pair: dict[str, str] | None = None,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.peak_window = peak_window
self.dd_window = dd_window
self.trend_lookback = trend_lookback
self.downvol_window = downvol_window
self.downvol_lookback = downvol_lookback
self.promote_thresholds = promote_thresholds
self.demote_thresholds = demote_thresholds
self.promote_confirm = promote_confirm
self.demote_confirm = demote_confirm
self.tier_min_hold = tier_min_hold
self.starting_tier = starting_tier
self.panic_vol_short = panic_vol_short
self.panic_vol_long = panic_vol_long
self.panic_vol_ratio = panic_vol_ratio
self.panic_peak_drop_pct = panic_peak_drop_pct
self.panic_peak_window = panic_peak_window
self.w_trend = w_trend
self.w_dd = w_dd
self.w_peak = w_peak
self.w_downvol = w_downvol
self.leveraged_pair = leveraged_pair or dict(self.DEFAULT_LEVERAGED_PAIR)
self.core_pair = core_pair or dict(self.DEFAULT_CORE_PAIR)
# ---- Conviction features ----
@staticmethod
def _clip01(x: float) -> float:
if not np.isfinite(x):
return 0.0
return float(min(1.0, max(0.0, x)))
def _panic_demote(self, sig_closes: np.ndarray) -> bool:
"""Detect fast vol regime / sharp peak velocity → panic demote tier 2→0."""
if sig_closes.size < self.panic_vol_long + 1:
return False
# Short vs long realized vol
rets = np.diff(sig_closes[-(self.panic_vol_long + 1):]) / np.maximum(
sig_closes[-(self.panic_vol_long + 1):-1], 1e-12
)
if rets.size < self.panic_vol_long:
return False
long_vol = float(rets.std(ddof=1))
short_rets = rets[-self.panic_vol_short:]
short_vol = float(short_rets.std(ddof=1)) if short_rets.size > 1 else 0.0
if long_vol > 0 and short_vol / long_vol >= self.panic_vol_ratio:
return True
# Peak-velocity: drop > X% in last N days from rolling peak
window = sig_closes[-self.panic_peak_window:]
if window.size >= 2:
peak = float(window.max())
drop = (peak - float(sig_closes[-1])) / max(peak, 1e-12)
if drop >= self.panic_peak_drop_pct:
return True
return False
def _conviction(self, sig_closes: np.ndarray) -> float:
"""Directional conviction in [0, 1] — higher means cleaner trend."""
n = sig_closes.size
if n < max(self.ma_long, self.trend_lookback,
self.downvol_lookback + self.downvol_window) + 1:
return 0.0
last = float(sig_closes[-1])
# 1) Trend score: distance above MA200 in vol-units
ma_long = float(sig_closes[-self.ma_long:].mean())
rets = np.diff(sig_closes[-self.downvol_window - 1:]) / np.maximum(
sig_closes[-self.downvol_window - 1:-1], 1e-12
)
ann_vol = float(rets.std(ddof=1) * np.sqrt(252)) if rets.size > 1 else 0.20
ann_vol = max(ann_vol, 1e-3)
trend_units = (last / ma_long - 1.0) / ann_vol # vol-units (annualized)
trend_score = self._clip01(trend_units / 0.50) # ~0.50 vol-unit = strong
# 2) Drawdown score: shallower = better
dd_window_arr = sig_closes[-self.dd_window:]
dd = float(last / dd_window_arr.max() - 1.0) # ≤ 0
period_vol = ann_vol / np.sqrt(252) * np.sqrt(self.dd_window)
dd_units = -dd / max(period_vol, 1e-4)
dd_score = self._clip01(1.0 - dd_units / 2.5) # 2.5 vol-units → 0
# 3) Peak-distance score
peak_arr = sig_closes[-self.peak_window:]
peak_ratio = float(last / peak_arr.max())
peak_period_vol = ann_vol / np.sqrt(252) * np.sqrt(self.peak_window)
peak_drop_units = (1.0 - peak_ratio) / max(peak_period_vol, 1e-4)
peak_score = self._clip01(1.0 - peak_drop_units / 2.0)
# 4) Downside-vol percentile (lower = better)
full_rets = np.diff(sig_closes[-(self.downvol_lookback + self.downvol_window):]) / np.maximum(
sig_closes[-(self.downvol_lookback + self.downvol_window):-1], 1e-12
)
# Rolling downside semideviation
s = pd.Series(full_rets)
downside = s.where(s < 0, 0.0)
dv_series = downside.rolling(self.downvol_window).std(ddof=1) * np.sqrt(252)
dv_now = float(dv_series.iloc[-1]) if not dv_series.empty else np.nan
dv_history = dv_series.dropna().to_numpy()
if dv_history.size == 0 or not np.isfinite(dv_now):
downvol_score = 0.5
else:
pct = float((dv_history < dv_now).mean())
downvol_score = 1.0 - pct # low downvol → high score
score = (
self.w_trend * trend_score
+ self.w_dd * dd_score
+ self.w_peak * peak_score
+ self.w_downvol * downvol_score
)
total_w = self.w_trend + self.w_dd + self.w_peak + self.w_downvol
return float(score / max(total_w, 1e-9))
# ---- Tier state ----
def _tier_for(self, conviction: float, current: int,
pending_promote: int, pending_demote: int) -> tuple[int, int, int]:
"""Update tier given conviction. Returns (new_tier, new_pp, new_pd)."""
new_tier = current
# Demote first (safety > greed)
if current >= 1 and conviction <= self.demote_thresholds[current - 1]:
pending_demote += 1
pending_promote = 0
if pending_demote >= self.demote_confirm:
new_tier = max(0, current - 1)
pending_demote = 0
return new_tier, pending_promote, pending_demote
else:
pending_demote = 0
# Promote
target = current
if current < 2 and conviction >= self.promote_thresholds[current]:
pending_promote += 1
if pending_promote >= self.promote_confirm:
target = min(2, current + 1)
pending_promote = 0
else:
pending_promote = 0
return target, pending_promote, pending_demote
def _equity_blend(self, sym: str, tier: int, cols: list[str]) -> dict[str, float]:
"""Blend a chosen symbol with its leveraged/core counterpart by tier."""
# If V3 picked a leveraged sym (TQQQ/UPRO), map to core counterpart
if sym in self.core_pair:
lev_sym = sym
core_sym = self.core_pair[sym]
elif sym in self.leveraged_pair:
core_sym = sym
lev_sym = self.leveraged_pair[sym]
else:
# No leveraged variant available → 100% as-is
return {sym: 1.0}
if core_sym not in cols and lev_sym not in cols:
return {sym: 1.0}
if core_sym not in cols:
return {lev_sym: 1.0}
if lev_sym not in cols:
return {core_sym: 1.0}
if tier == 0:
return {core_sym: 1.0}
if tier == 1:
return {core_sym: 0.5, lev_sym: 0.5}
return {lev_sym: 1.0}
# ---- Override: post-process V3 weights ----
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
# 1) Get V3's PIT-safe weights (already shifted)
v3_w = super().generate_signals(data)
# We need to "un-shift" V3 weights to align with the day they were decided,
# apply tier blending in that frame, then re-shift. Easier: work directly
# in the signal frame (which is v3_w's index, with row t = position for t).
# Since super() already shifted by 1, v3_w.iloc[t] is the *position* held
# on day t (decided on close of t-1). We modulate row-by-row.
sig = data[self.signal] if self.signal in data.columns else None
if sig is None:
return v3_w
sig_arr = sig.to_numpy()
cols = list(v3_w.columns)
# Make sure leveraged/core counterparts exist as columns; expand if not
extra_cols = []
for sym in (*self.core_pair.keys(), *self.leveraged_pair.keys()):
if sym in data.columns and sym not in cols:
extra_cols.append(sym)
if extra_cols:
for c in extra_cols:
v3_w[c] = 0.0
cols = list(v3_w.columns)
out = pd.DataFrame(0.0, index=v3_w.index, columns=cols)
# Tier state
tier = 0 # start at 0 — promotions happen via confirm
pending_promote = 0
pending_demote = 0
tier_age = 0
prev_active_sym: str | None = None
first_risk_on_seen = False
for t in range(len(v3_w)):
row = v3_w.iloc[t]
active = row[row > 0]
if active.empty:
# No position → no modulation
tier = 0
pending_promote = pending_demote = 0
tier_age = 0
prev_active_sym = None
continue
sym = active.idxmax() # V3 outputs 100% to one symbol
# Compute conviction from signal closes through t-1 (already PIT)
# v3_w.iloc[t] reflects position decided on close(t-1), so we can
# use sig_arr[:t] as available info.
sig_closes = sig_arr[: t]
if sig_closes.size == 0:
continue
conviction = self._conviction(sig_closes)
# Detect new active position
is_equity = sym in self.core_pair or sym in self.leveraged_pair
if not is_equity:
# Risk-off: pass through, reset tier state
tier = 0
pending_promote = pending_demote = 0
tier_age = 0
prev_active_sym = sym
out.iloc[t] = row
continue
if prev_active_sym != sym:
# Fresh entry into equity sleeve
if not first_risk_on_seen:
tier = self.starting_tier
first_risk_on_seen = True
else:
# Initialize tier from current conviction
if conviction >= self.promote_thresholds[1]:
tier = 2
elif conviction >= self.promote_thresholds[0]:
tier = 1
else:
tier = 0
pending_promote = pending_demote = 0
tier_age = 0
# Panic demote — bypasses min-hold and conviction logic
panic = self._panic_demote(sig_closes)
if panic and tier > 0:
tier = 0
tier_age = 0
pending_promote = pending_demote = 0
else:
# Tier transition logic with min-hold
new_tier = tier
if tier_age >= self.tier_min_hold:
new_tier, pending_promote, pending_demote = self._tier_for(
conviction, tier, pending_promote, pending_demote
)
if new_tier != tier:
tier_age = 0
tier = new_tier
else:
tier_age += 1
else:
tier_age += 1
# Even within min-hold, allow emergency demote if conviction crashes
if tier > 0 and conviction <= self.demote_thresholds[tier - 1] * 0.6:
tier = max(0, tier - 1)
tier_age = 0
pending_promote = pending_demote = 0
# Blend
blend = self._equity_blend(sym, tier, cols)
for s, ww in blend.items():
out.at[v3_w.index[t], s] = ww
prev_active_sym = sym
return out
__all__ = ["TrendRiderV5"]