Files
quant-trading/strategies/trend_rider_v9.py
Gahow Wang 36cb125719 live: commit the TrendRider V9/V10 registrations the monitor already runs
trader.py registers trend_rider_v9 (+ vt24/vt32/vt36) and trend_rider_v10
(+ vt32/vt36) from strategies/trend_rider_v9.py, and bridge.py labels them.
These were left uncommitted by the 2026-10-07 research session while the
monitor ran them from the working tree (state files data/trader_us_trend_rider_v9*/v10*).
research/tr9_*.py is the research trail CLAUDE.md cites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:20:28 -07:00

328 lines
12 KiB
Python

"""TrendRider V9 — V7 + instrument trailing stop on the leveraged risk-on leg.
Architecture (PIT-safe, same convention as V3/V7):
Layer 1 — TrendRiderV3 regime engine (SPY MA150 gates, momentum-leader
pick, confirm/min-hold/stop-loss/cooloff), extended with one rule:
Trailing stop: while in risk_on, if the held leg's close falls
`trail_pct` below its `trail_window`-day high (both through t-1),
switch immediately to the risk-off basket (same path as the SPY
emergency exit). Entries back into risk-on still need the normal
regime confirmation, so the stop cannot flip-flop daily.
Layer 2 — Vol-target overlay (same as V7): scale = clip(target/vol60d,
min_lev, 1.0), shift(1).
Layer 3 — Profit-take with hysteresis (same as V7): gain >= 30% from
entry clears to SHY; restore below 20%.
Terminal shift(1) once, then layers 2-3 on the lagged weights, exactly
as V7 does. The engine's session_targets applies the final -1 shift.
Why the trailing stop
---------------------
The SPY-based exits (MA150 / -5% from 40d max) protect against market
crashes, but a 3x leg can lose 25-40% from its own high while SPY is only
a few percent off (2024-07 carry unwind, 2026-07). Cutting the leg at a
20% instrument drawdown buys back a large part of such episodes; with the
regime confirmation needed to re-enter, the cost in trending markets is
small. 2010-2026 live-book simulation ($2/order): every vol-target tier
improves on both CAGR and Sharpe vs the same tier without the stop; the
2021-2026 block Sharpe rises from 1.25 to ~1.38. Trail 20-23% is a flat
plateau; >=24% only fires after the SPY emergency already did (no effect).
This is an empirical risk overlay. It was designed on the 2018-2026
window and validated on 2010-2016 (Sharpe essentially unchanged there:
0.22-0.32 per block) and 2010-2026.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.permanent import TrendRiderV3
class _V3Trail(TrendRiderV3):
"""TrendRiderV3 loop + trailing stop on the risk-on leg."""
def __init__(self, trail_pct: float | None = 0.20, trail_window: int = 30, **kw) -> None:
super().__init__(**kw)
self.trail_pct = trail_pct
self.trail_window = trail_window
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
cols = list({self.signal, *self.risk_on, *self.risk_off})
cols = [c for c in cols if c in data.columns]
sym_to_ix = {s: ix for ix, s in enumerate(cols)}
w = pd.DataFrame(0.0, index=data.index, columns=cols)
if self.signal not in sym_to_ix:
return w.shift(1).fillna(0.0)
sig_arr = data[self.signal].to_numpy()
sym_arrays = {sym_to_ix[s]: data[s].to_numpy() for s in cols}
ron_idx = [sym_to_ix[s] for s in self.risk_on if s in sym_to_ix]
roff_idx = [sym_to_ix[s] for s in self.risk_off if s in sym_to_ix]
need = max(self.ma_long, self.vol_window + 1, self.dd_window,
self.peak_window, self.mom_lookback + 1) + 1
current_regime: str | None = None
bars_in_regime = 0
pending_regime: str | None = None
pending_count = 0
current_sym: int | None = None
bars_in_sym = 0
sym_entry_close: float | None = None
cooloff_remaining = 0
for t in range(len(data)):
if t < need:
continue
sig_closes = sig_arr[: t]
if np.isnan(sig_closes[-1]):
continue
desired = self._desired_regime(sig_closes, current_regime)
emergency = (sig_closes[-1] / sig_closes[-self.dd_window:].max() - 1.0) <= -self.dd_stop
cps = {ix: arr[:t] for ix, arr in sym_arrays.items()}
def assign_one(sym_ix: int) -> None:
nonlocal current_sym, bars_in_sym, sym_entry_close
current_sym = sym_ix
bars_in_sym = 0
p = float(sym_arrays[sym_ix][t]) if t < sym_arrays[sym_ix].size else float("nan")
sym_entry_close = p if not np.isnan(p) else float(sym_arrays[sym_ix][t - 1])
if current_regime is None:
basket = ron_idx if desired == "risk_on" else roff_idx
pick = self._pick_top(None, basket, cps)
if pick is None:
continue
current_regime = desired
bars_in_regime = 0
assign_one(pick)
w.iat[t, pick] = 1.0
continue
bars_in_regime += 1
bars_in_sym += 1
if cooloff_remaining > 0:
cooloff_remaining -= 1
in_on = current_regime == "risk_on"
sym_yclose = (float(sym_arrays[current_sym][t - 1])
if current_sym is not None and not np.isnan(sym_arrays[current_sym][t - 1])
else None)
if (in_on and sym_yclose is not None and sym_entry_close
and sym_yclose / sym_entry_close - 1.0 <= -self.stop_loss_pct):
pick = self._pick_top(None, roff_idx, cps)
if pick is not None:
current_regime = "risk_off"
bars_in_regime = 0
assign_one(pick)
pending_regime = None
pending_count = 0
cooloff_remaining = self.cooloff_days
w.iat[t, pick] = 1.0
continue
if (in_on and self.trail_pct and sym_yclose is not None
and current_sym is not None):
w0 = max(0, t - self.trail_window)
hc = float(np.nanmax(sym_arrays[current_sym][w0:t]))
if hc > 0 and sym_yclose / hc - 1.0 <= -self.trail_pct:
pick = self._pick_top(None, roff_idx, cps)
if pick is not None:
current_regime = "risk_off"
bars_in_regime = 0
assign_one(pick)
pending_regime = None
pending_count = 0
w.iat[t, pick] = 1.0
continue
if emergency and current_regime != "risk_off":
pick = self._pick_top(None, roff_idx, cps)
if pick is not None:
current_regime = "risk_off"
bars_in_regime = 0
assign_one(pick)
pending_regime = None
pending_count = 0
w.iat[t, pick] = 1.0
continue
if desired != current_regime:
if current_regime == "risk_off" and cooloff_remaining > 0:
pending_regime = None
pending_count = 0
elif bars_in_regime < self.regime_min_hold:
pending_regime = None
pending_count = 0
else:
if desired != pending_regime:
pending_regime = desired
pending_count = 1
else:
pending_count += 1
if pending_count >= self.confirm_days:
basket = ron_idx if desired == "risk_on" else roff_idx
pick = self._pick_top(None, basket, cps)
if pick is None:
pick = current_sym
current_regime = desired
bars_in_regime = 0
assign_one(pick)
pending_regime = None
pending_count = 0
if pick is not None:
w.iat[t, pick] = 1.0
continue
if current_sym is not None:
w.iat[t, current_sym] = 1.0
continue
pending_regime = None
pending_count = 0
basket = ron_idx if in_on else roff_idx
top = self._pick_top(None, basket, cps)
if top is None or top == current_sym:
if current_sym is not None:
w.iat[t, current_sym] = 1.0
continue
if bars_in_sym < self.instrument_min_hold:
if current_sym is not None:
w.iat[t, current_sym] = 1.0
continue
assign_one(top)
w.iat[t, top] = 1.0
return w.shift(1).fillna(0.0)
class TrendRiderV9(Strategy):
"""V7 pipeline (vol target + profit take) over the trailing-stop regime engine."""
def __init__(
self,
*,
ma_long: int = 150,
signal: str = "SPY",
risk_on: tuple[str, ...] = ("TQQQ", "UPRO"),
risk_off: tuple[str, ...] = ("GLD", "DBC"),
target_vol: float = 0.34,
vol_window: int = 60,
min_lev: float = 0.72,
max_lev: float = 1.0,
trail_pct: float = 0.20,
trail_window: int = 30,
pt_threshold: float = 0.30,
pt_band: float = 0.10,
pt_min_hold: int = 0,
pt_park: str = "SHY",
**v3_kwargs,
) -> None:
self.target_vol = target_vol
self.vol_window = vol_window
self.min_lev = min_lev
self.max_lev = max_lev
self.pt_threshold = pt_threshold
self.pt_band = pt_band
self.pt_min_hold = pt_min_hold
self.pt_park = pt_park
self.v3 = _V3Trail(
signal=signal,
risk_on=risk_on,
risk_off=risk_off,
ma_long=ma_long,
trail_pct=trail_pct,
trail_window=trail_window,
**v3_kwargs,
)
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
w = self.v3.generate_signals(data)
if self.pt_park and self.pt_park in data.columns and self.pt_park not in w.columns:
w[self.pt_park] = 0.0
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
port_rets = (w * daily_ret).sum(axis=1)
realized_vol = (
port_rets.rolling(self.vol_window, min_periods=21).std()
* np.sqrt(252)
)
scale = (self.target_vol / realized_vol).clip(
lower=self.min_lev, upper=self.max_lev,
)
scale = scale.shift(1).fillna(1.0)
w = w.mul(scale, axis=0)
if self.pt_threshold <= 0:
return w
held = w.idxmax(axis=1)
max_w = w.max(axis=1)
held[max_w < 1e-8] = ""
park_col = self.pt_park if self.pt_park in w.columns else ""
entry_price: float | None = None
current_sym: str | None = None
is_stopped = False
restore_level = self.pt_threshold - self.pt_band
last_restore_i = -10 ** 9
for i in range(len(w)):
sym = held.iloc[i]
if not sym or max_w.iloc[i] < 1e-8:
current_sym = None
entry_price = None
is_stopped = False
continue
if sym != current_sym:
current_sym = sym
entry_price = (
float(data[sym].iloc[i - 1])
if i > 0 and sym in data.columns
else None
)
is_stopped = False
last_restore_i = -10 ** 9
continue
if (
entry_price is None
or entry_price <= 0
or sym not in data.columns
):
continue
yesterday = float(data[sym].iloc[i - 1]) if i > 0 else float(data[sym].iloc[i])
gain = yesterday / entry_price - 1.0
if is_stopped:
if gain < restore_level:
is_stopped = False
last_restore_i = i
else:
w.iloc[i] = 0.0
if park_col:
w.at[w.index[i], park_col] = scale.iloc[i]
else:
if gain >= self.pt_threshold and i - last_restore_i >= self.pt_min_hold:
is_stopped = True
w.iloc[i] = 0.0
if park_col:
w.at[w.index[i], park_col] = scale.iloc[i]
return w
__all__ = ["TrendRiderV9"]