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>
328 lines
12 KiB
Python
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"]
|