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>
This commit is contained in:
44
bridge.py
44
bridge.py
@@ -386,7 +386,49 @@ STRATEGY_META = {
|
|||||||
"markets": ["us"],
|
"markets": ["us"],
|
||||||
},
|
},
|
||||||
"trend_rider_v7_vt36": {
|
"trend_rider_v7_vt36": {
|
||||||
"label": "Trend Rider V7 (VT36% + PT30) ★ SOTA",
|
"label": "Trend Rider V7 (VT36% + PT30)",
|
||||||
|
"category": "tactical_allocation",
|
||||||
|
"params": {},
|
||||||
|
"markets": ["us"],
|
||||||
|
},
|
||||||
|
"trend_rider_v9": {
|
||||||
|
"label": "Trend Rider V9 (VT34% + PT30 + trail20)",
|
||||||
|
"category": "tactical_allocation",
|
||||||
|
"params": {},
|
||||||
|
"markets": ["us"],
|
||||||
|
},
|
||||||
|
"trend_rider_v9_vt24": {
|
||||||
|
"label": "Trend Rider V9 (VT24% + PT30 + trail20)",
|
||||||
|
"category": "tactical_allocation",
|
||||||
|
"params": {},
|
||||||
|
"markets": ["us"],
|
||||||
|
},
|
||||||
|
"trend_rider_v9_vt32": {
|
||||||
|
"label": "Trend Rider V9 (VT32% + PT30 + trail20)",
|
||||||
|
"category": "tactical_allocation",
|
||||||
|
"params": {},
|
||||||
|
"markets": ["us"],
|
||||||
|
},
|
||||||
|
"trend_rider_v9_vt36": {
|
||||||
|
"label": "Trend Rider V9 (VT36% + PT30 + trail20)",
|
||||||
|
"category": "tactical_allocation",
|
||||||
|
"params": {},
|
||||||
|
"markets": ["us"],
|
||||||
|
},
|
||||||
|
"trend_rider_v10": {
|
||||||
|
"label": "Trend Rider V10 (VT34% + PT28/8pp/lock10 + trail20) ★ SOTA",
|
||||||
|
"category": "tactical_allocation",
|
||||||
|
"params": {},
|
||||||
|
"markets": ["us"],
|
||||||
|
},
|
||||||
|
"trend_rider_v10_vt32": {
|
||||||
|
"label": "Trend Rider V10 (VT32% + PT28/8pp/lock10 + trail20)",
|
||||||
|
"category": "tactical_allocation",
|
||||||
|
"params": {},
|
||||||
|
"markets": ["us"],
|
||||||
|
},
|
||||||
|
"trend_rider_v10_vt36": {
|
||||||
|
"label": "Trend Rider V10 (VT36% + PT28/8pp/lock10 + trail20)",
|
||||||
"category": "tactical_allocation",
|
"category": "tactical_allocation",
|
||||||
"params": {},
|
"params": {},
|
||||||
"markets": ["us"],
|
"markets": ["us"],
|
||||||
|
|||||||
69
research/tr9_audit.py
Normal file
69
research/tr9_audit.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Full-window audit: every registered US strategy vs TrendRider V9.
|
||||||
|
|
||||||
|
Runs the official engine path (run_market, same params as precompute) over
|
||||||
|
the current 10y common window and writes the comparison table plus the
|
||||||
|
head-to-head verdict (does V9 beat every other strategy on BOTH CAGR and
|
||||||
|
Sharpe?) to data/research/results/tr9_full_comparison.json.
|
||||||
|
|
||||||
|
PYTHONPATH=. uv run python -m research.tr9_audit
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from trader import (AGENT_REVIEW_STRATEGIES, STRATEGY_REGISTRY,
|
||||||
|
strategy_supports_market)
|
||||||
|
|
||||||
|
import research.tr9_lab as lab
|
||||||
|
|
||||||
|
OUT = os.path.join("data", "research", "results", "tr9_full_comparison.json")
|
||||||
|
V9_NAMES = ["trend_rider_v9", "trend_rider_v9_vt24", "trend_rider_v9_vt32", "trend_rider_v9_vt36",
|
||||||
|
"trend_rider_v10", "trend_rider_v10_vt32", "trend_rider_v10_vt36"]
|
||||||
|
NEW_NAMES = [n for n in V9_NAMES if "v10" in n]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
us_names = [n for n in STRATEGY_REGISTRY
|
||||||
|
if strategy_supports_market(n, "us") and n not in AGENT_REVIEW_STRATEGIES
|
||||||
|
and not n.startswith("sim_")]
|
||||||
|
end = "2026-10-07"
|
||||||
|
print(f"auditing {len(us_names)} US strategies (window ...{end})")
|
||||||
|
curves = lab.run(us_names, start="2017-12-26", end=end)
|
||||||
|
rows = []
|
||||||
|
for name, eq in curves.items():
|
||||||
|
m = lab.metrics(eq)
|
||||||
|
rows.append({"name": name, **m})
|
||||||
|
df = pd.DataFrame(rows).sort_values("cagr", ascending=False)
|
||||||
|
pd.set_option("display.width", 200)
|
||||||
|
print(f"{'name':44s} {'CAGR':>8s} {'vol':>7s} {'Sharpe':>7s} {'MDD':>7s}")
|
||||||
|
for _, r in df.iterrows():
|
||||||
|
print(f"{r['name']:44s} {r['cagr']*100:7.1f}% {r['vol']*100:6.1f}% "
|
||||||
|
f"{r['sharpe']:7.2f} {r['mdd']*100:6.1f}%")
|
||||||
|
|
||||||
|
others = df[~df["name"].isin(NEW_NAMES)]
|
||||||
|
best_cagr = others.loc[others["cagr"].idxmax()]
|
||||||
|
best_sharpe = others.loc[others["sharpe"].idxmax()]
|
||||||
|
print(f"\nexisting best CAGR : {best_cagr['name']} {best_cagr['cagr']*100:.1f}%")
|
||||||
|
print(f"existing best Sharpe : {best_sharpe['name']} {best_sharpe['sharpe']:.2f}")
|
||||||
|
verdict = {}
|
||||||
|
for v9 in NEW_NAMES:
|
||||||
|
row = df[df["name"] == v9].iloc[0]
|
||||||
|
beat_cagr = bool((others["cagr"] < row["cagr"]).all())
|
||||||
|
beat_sharpe = bool((others["sharpe"] < row["sharpe"]).all())
|
||||||
|
verdict[v9] = {"cagr": float(row["cagr"]), "sharpe": float(row["sharpe"]),
|
||||||
|
"mdd": float(row["mdd"]),
|
||||||
|
"beats_all_cagr": beat_cagr, "beats_all_sharpe": beat_sharpe,
|
||||||
|
"beats_all_both": beat_cagr and beat_sharpe}
|
||||||
|
print(f"{v9}: CAGR {row['cagr']*100:.1f}% (beats all: {beat_cagr}), "
|
||||||
|
f"Sharpe {row['sharpe']:.2f} (beats all: {beat_sharpe})")
|
||||||
|
with open(OUT, "w") as fh:
|
||||||
|
json.dump({"window": {"start": "2017-12-26", "end": end},
|
||||||
|
"rows": rows, "verdict": verdict}, fh, indent=1)
|
||||||
|
print("wrote", OUT)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
654
research/tr9_lab.py
Normal file
654
research/tr9_lab.py
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
"""TrendRider V9 research lab.
|
||||||
|
|
||||||
|
Scratch harness for the goal "beat every registered US strategy on the past
|
||||||
|
10y CAGR *and* Sharpe under strict PIT". Runs candidates through the same
|
||||||
|
official path as precompute_backtest.py (run_market -> cash ledger, $10k,
|
||||||
|
0.1% cost + $2/order, next-session close fills via session_targets).
|
||||||
|
|
||||||
|
Only ETF strategies are used here (fast, no stock panel needed).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
import trader
|
||||||
|
from strategies.base import Strategy
|
||||||
|
from trader import ETF_STRATEGY_UNIVERSES, STRATEGY_REGISTRY
|
||||||
|
|
||||||
|
V9_UNIVERSE = [
|
||||||
|
"SPY", "QQQ", "TQQQ", "UPRO", "SOXL", "TECL", "TNA", "FAS", "SPXL",
|
||||||
|
"SSO", "QLD", "GLD", "DBC", "TMF", "TLT", "SHY", "IEF", "SQQQ", "LABU",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _above_ma(closes: np.ndarray, w: int) -> bool:
|
||||||
|
return closes.size >= w and float(closes[-1]) > float(closes[-w:].mean())
|
||||||
|
|
||||||
|
|
||||||
|
def _vol(closes: np.ndarray, w: int) -> float:
|
||||||
|
if closes.size < w + 1:
|
||||||
|
return float("nan")
|
||||||
|
rets = np.diff(closes[-w - 1:]) / np.maximum(closes[-w - 1:-1], 1e-12)
|
||||||
|
return float(rets.std(ddof=1) * np.sqrt(252))
|
||||||
|
|
||||||
|
|
||||||
|
def _total_return(closes: np.ndarray, w: int) -> float:
|
||||||
|
if closes.size < w + 1 or closes[-w - 1] <= 0:
|
||||||
|
return float("nan")
|
||||||
|
return float(closes[-1] / closes[-w - 1] - 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TrendRiderV9(Strategy):
|
||||||
|
"""V7 architecture (V3 regime + vol target + profit take) with:
|
||||||
|
|
||||||
|
- extended risk-on basket (TECL/SOXL/... momentum leader pick)
|
||||||
|
- gated risk-off additions: TMF (TLT>MA200 gate), SQQQ (QQQ 20d drop gate)
|
||||||
|
- optional asset-level vol targeting or EWMA vol
|
||||||
|
- optional momentum/vol pick ranking
|
||||||
|
- laddered profit-taking; park bucket configurable (SHY/QQQ mix)
|
||||||
|
|
||||||
|
PIT convention identical to TrendRiderV3/V7: decisions at raw row t use
|
||||||
|
bars through t-1; the V3-style loop then shifts the output by one extra
|
||||||
|
day before the V7 layers, and the engine applies the final -1 shift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
# V3 regime core
|
||||||
|
signal: str = "SPY",
|
||||||
|
risk_on: tuple[str, ...] = ("TQQQ", "UPRO"),
|
||||||
|
risk_off: tuple[str, ...] = ("GLD", "DBC"),
|
||||||
|
ma_long: int = 150,
|
||||||
|
ma_short: int = 50,
|
||||||
|
vol_window: int = 20,
|
||||||
|
vol_enter: float = 0.14,
|
||||||
|
vol_exit: float = 0.20,
|
||||||
|
dd_window: int = 40,
|
||||||
|
dd_stop: float = 0.05,
|
||||||
|
peak_window: int = 20,
|
||||||
|
peak_enter: float = 0.02,
|
||||||
|
peak_exit: float = 0.05,
|
||||||
|
regime_min_hold: int = 15,
|
||||||
|
instrument_min_hold: int = 30,
|
||||||
|
confirm_days: int = 3,
|
||||||
|
stop_loss_pct: float = 0.15,
|
||||||
|
cooloff_days: int = 20,
|
||||||
|
mom_lookback: int = 63,
|
||||||
|
# extensions
|
||||||
|
tmf: str | None = None,
|
||||||
|
tlt_ma_window: int = 200,
|
||||||
|
sqqq: str | None = None,
|
||||||
|
sqqq_trigger: float = 0.08,
|
||||||
|
ro_stop_loss: float | None = None,
|
||||||
|
mom_pick_divisor: bool = False,
|
||||||
|
trail_pct: float | None = None,
|
||||||
|
trail_window: int = 30,
|
||||||
|
trail_reentry_days: int = 0,
|
||||||
|
instma_exit: int | None = None,
|
||||||
|
fast_reentry_days: int | None = None,
|
||||||
|
breadth_ma: int | None = None,
|
||||||
|
# vol target
|
||||||
|
target_vol: float = 0.28,
|
||||||
|
vt_window: int = 60,
|
||||||
|
min_lev: float = 0.6,
|
||||||
|
max_lev: float = 1.0,
|
||||||
|
vt_mode: str = "strategy",
|
||||||
|
vt_ewma: float = 0.0,
|
||||||
|
# profit take
|
||||||
|
pt_mode: str = "binary",
|
||||||
|
pt_threshold: float = 0.30,
|
||||||
|
pt_band: float = 0.10,
|
||||||
|
pt_park: str = "SHY",
|
||||||
|
pt_park_mix: dict | None = None,
|
||||||
|
pt_ladder: tuple = ((0.30, 0.5), (0.60, 1.0)),
|
||||||
|
pt_min_hold: int = 0,
|
||||||
|
pt_only: tuple[str, ...] | None = None,
|
||||||
|
# macro throttle (free-data overlays: VIX term structure, credit)
|
||||||
|
throttle_factor: float = 0.6,
|
||||||
|
throttle_vix_inv: bool = False,
|
||||||
|
vix_inv_thresh: float = 0.0,
|
||||||
|
throttle_vix_spike: bool = False,
|
||||||
|
vix_spike_5d: float = 0.5,
|
||||||
|
throttle_credit: bool = False,
|
||||||
|
credit_ma: int = 50,
|
||||||
|
throttle_recover_days: int = 5,
|
||||||
|
) -> None:
|
||||||
|
self.signal = signal
|
||||||
|
self.risk_on = tuple(risk_on)
|
||||||
|
self.risk_off = tuple(risk_off)
|
||||||
|
self.ma_long = ma_long
|
||||||
|
self.ma_short = ma_short
|
||||||
|
self.vol_window = vol_window
|
||||||
|
self.vol_enter = vol_enter
|
||||||
|
self.vol_exit = vol_exit
|
||||||
|
self.dd_window = dd_window
|
||||||
|
self.dd_stop = dd_stop
|
||||||
|
self.peak_window = peak_window
|
||||||
|
self.peak_enter = peak_enter
|
||||||
|
self.peak_exit = peak_exit
|
||||||
|
self.regime_min_hold = regime_min_hold
|
||||||
|
self.instrument_min_hold = instrument_min_hold
|
||||||
|
self.confirm_days = confirm_days
|
||||||
|
self.stop_loss_pct = stop_loss_pct
|
||||||
|
self.cooloff_days = cooloff_days
|
||||||
|
self.mom_lookback = mom_lookback
|
||||||
|
self.tmf = tmf
|
||||||
|
self.tlt_ma_window = tlt_ma_window
|
||||||
|
self.sqqq = sqqq
|
||||||
|
self.sqqq_trigger = sqqq_trigger
|
||||||
|
self.ro_stop_loss = ro_stop_loss
|
||||||
|
self.mom_pick_divisor = mom_pick_divisor
|
||||||
|
self.trail_pct = trail_pct
|
||||||
|
self.trail_window = trail_window
|
||||||
|
self.trail_reentry_days = trail_reentry_days
|
||||||
|
self.instma_exit = instma_exit
|
||||||
|
self.fast_reentry_days = fast_reentry_days
|
||||||
|
self.breadth_ma = breadth_ma
|
||||||
|
self.target_vol = target_vol
|
||||||
|
self.vt_window = vt_window
|
||||||
|
self.min_lev = min_lev
|
||||||
|
self.max_lev = max_lev
|
||||||
|
self.vt_mode = vt_mode
|
||||||
|
self.vt_ewma = vt_ewma
|
||||||
|
self.pt_mode = pt_mode
|
||||||
|
self.pt_threshold = pt_threshold
|
||||||
|
self.pt_band = pt_band
|
||||||
|
self.pt_park = pt_park
|
||||||
|
self.pt_park_mix = pt_park_mix
|
||||||
|
self.pt_ladder = tuple(pt_ladder)
|
||||||
|
self.pt_min_hold = pt_min_hold
|
||||||
|
self.pt_only = set(pt_only) if pt_only else None
|
||||||
|
self.throttle_factor = throttle_factor
|
||||||
|
self.throttle_vix_inv = throttle_vix_inv
|
||||||
|
self.vix_inv_thresh = vix_inv_thresh
|
||||||
|
self.throttle_vix_spike = throttle_vix_spike
|
||||||
|
self.vix_spike_5d = vix_spike_5d
|
||||||
|
self.throttle_credit = throttle_credit
|
||||||
|
self.credit_ma = credit_ma
|
||||||
|
self.throttle_recover_days = throttle_recover_days
|
||||||
|
|
||||||
|
# ---- regime helpers (same rules as TrendRiderV3) ----
|
||||||
|
def _desired_regime(self, closes: np.ndarray, current: str | None) -> str:
|
||||||
|
window_dd = closes[-self.dd_window:]
|
||||||
|
if closes[-1] / window_dd.max() - 1.0 <= -self.dd_stop:
|
||||||
|
return "risk_off"
|
||||||
|
if not _above_ma(closes, self.ma_long):
|
||||||
|
return "risk_off"
|
||||||
|
v = _vol(closes, self.vol_window)
|
||||||
|
if v != v:
|
||||||
|
v = 1.0
|
||||||
|
peak_ratio = closes[-1] / closes[-self.peak_window:].max()
|
||||||
|
if current == "risk_on":
|
||||||
|
if (_above_ma(closes, self.ma_short) and v < self.vol_exit
|
||||||
|
and peak_ratio >= 1.0 - self.peak_exit):
|
||||||
|
return "risk_on"
|
||||||
|
return "risk_off"
|
||||||
|
if (_above_ma(closes, self.ma_short) and v < self.vol_enter
|
||||||
|
and peak_ratio >= 1.0 - self.peak_enter):
|
||||||
|
return "risk_on"
|
||||||
|
return "risk_off"
|
||||||
|
|
||||||
|
def _macro_scale(self, data: pd.DataFrame) -> pd.Series | None:
|
||||||
|
"""PIT macro throttle: VIX term inversion / VIX spike / credit stress
|
||||||
|
each multiply exposure by `throttle_factor` (enter fast, exit after
|
||||||
|
`throttle_recover_days` consecutive clean sessions)."""
|
||||||
|
conds: list[tuple[pd.Series, float]] = []
|
||||||
|
if self.throttle_vix_inv and "^VIX9D" in data.columns and "^VIX3M" in data.columns:
|
||||||
|
ratio = data["^VIX9D"] / data["^VIX3M"].replace(0.0, np.nan)
|
||||||
|
conds.append(((ratio - 1.0) >= self.vix_inv_thresh, self.throttle_factor))
|
||||||
|
if self.throttle_vix_spike and "^VIX" in data.columns:
|
||||||
|
vix = data["^VIX"]
|
||||||
|
conds.append((vix / vix.shift(5).replace(0.0, np.nan) - 1.0 >= self.vix_spike_5d,
|
||||||
|
self.throttle_factor))
|
||||||
|
if self.throttle_credit and "HYG" in data.columns:
|
||||||
|
hyg = data["HYG"]
|
||||||
|
conds.append((hyg < hyg.rolling(self.credit_ma).mean(), self.throttle_factor))
|
||||||
|
if not conds:
|
||||||
|
return None
|
||||||
|
out = pd.Series(1.0, index=data.index)
|
||||||
|
for cond, factor in conds:
|
||||||
|
cond = cond.fillna(False).to_numpy()
|
||||||
|
state, clean, vals = False, 0, np.ones(len(cond))
|
||||||
|
for i, c in enumerate(cond):
|
||||||
|
if c:
|
||||||
|
state, clean = True, 0
|
||||||
|
elif state:
|
||||||
|
clean += 1
|
||||||
|
if clean >= self.throttle_recover_days:
|
||||||
|
state = False
|
||||||
|
if state:
|
||||||
|
vals[i] = factor
|
||||||
|
out = out * pd.Series(vals, index=data.index)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _pick_top(self, basket: list[int], cps: dict[int, np.ndarray]) -> int | None:
|
||||||
|
best_i, best_r = None, -np.inf
|
||||||
|
for ix in basket:
|
||||||
|
closes = cps[ix]
|
||||||
|
r = _total_return(closes, self.mom_lookback)
|
||||||
|
if r != r:
|
||||||
|
continue
|
||||||
|
if self.mom_pick_divisor:
|
||||||
|
sigma = _vol(closes, self.mom_lookback)
|
||||||
|
r = r / sigma if sigma == sigma and sigma > 1e-9 else -np.inf
|
||||||
|
if r > best_r:
|
||||||
|
best_r, best_i = r, ix
|
||||||
|
return best_i
|
||||||
|
|
||||||
|
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
gate_refs = []
|
||||||
|
if self.tmf:
|
||||||
|
gate_refs.append("TLT")
|
||||||
|
if self.sqqq:
|
||||||
|
gate_refs.append("QQQ")
|
||||||
|
if self.breadth_ma:
|
||||||
|
gate_refs.append("RSP")
|
||||||
|
cols = [c for c in dict.fromkeys(
|
||||||
|
[self.signal, *self.risk_on, *self.risk_off,
|
||||||
|
*([self.tmf] if self.tmf else []),
|
||||||
|
*([self.sqqq] if self.sqqq else []),
|
||||||
|
*([self.pt_park] if self.pt_park else []),
|
||||||
|
*gate_refs]) 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]
|
||||||
|
tmf_ix = sym_to_ix.get(self.tmf) if self.tmf else None
|
||||||
|
tlt_ix = sym_to_ix.get("TLT")
|
||||||
|
sqqq_ix = sym_to_ix.get(self.sqqq) if self.sqqq else None
|
||||||
|
qqq_ix = sym_to_ix.get("QQQ")
|
||||||
|
if tmf_ix is not None and tmf_ix not in roff_idx:
|
||||||
|
roff_idx.append(tmf_ix)
|
||||||
|
if sqqq_ix is not None and sqqq_ix not in roff_idx:
|
||||||
|
roff_idx.append(sqqq_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
|
||||||
|
fast_until = -1
|
||||||
|
rsp_ix = sym_to_ix.get("RSP") if self.breadth_ma else None
|
||||||
|
|
||||||
|
def gate_on(ix: int, cps: dict[int, np.ndarray]) -> bool:
|
||||||
|
if ix == tmf_ix:
|
||||||
|
closes = cps.get(tlt_ix)
|
||||||
|
return closes is not None and _above_ma(closes, self.tlt_ma_window)
|
||||||
|
if ix == sqqq_ix:
|
||||||
|
closes = cps.get(qqq_ix)
|
||||||
|
if closes is None or closes.size < 22:
|
||||||
|
return False
|
||||||
|
if closes[-1] <= 0 or closes[-22] <= 0:
|
||||||
|
return False
|
||||||
|
return closes[-1] / closes[-22] - 1.0 <= -self.sqqq_trigger
|
||||||
|
return True
|
||||||
|
|
||||||
|
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
|
||||||
|
if rsp_ix is not None and t > self.breadth_ma + 1:
|
||||||
|
ratio = sym_arrays[rsp_ix][:t] / sig_arr[:t]
|
||||||
|
if ratio[-1] < float(ratio[-self.breadth_ma:].mean()):
|
||||||
|
desired = "risk_off"
|
||||||
|
|
||||||
|
cps = {ix: arr[:t] for ix, arr in sym_arrays.items()}
|
||||||
|
ron_today = [ix for ix in ron_idx if gate_on(ix, cps)]
|
||||||
|
roff_today = [ix for ix in roff_idx if gate_on(ix, cps)]
|
||||||
|
|
||||||
|
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_today if desired == "risk_on" else roff_today
|
||||||
|
pick = self._pick_top(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(roff_today, 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 (not in_on and self.ro_stop_loss and sym_yclose is not None and sym_entry_close
|
||||||
|
and current_sym in (tmf_ix, sqqq_ix)
|
||||||
|
and sym_yclose / sym_entry_close - 1.0 <= -self.ro_stop_loss):
|
||||||
|
# bail to cash-park until the regime/pick changes again
|
||||||
|
park_ix = sym_to_ix.get(self.pt_park)
|
||||||
|
if park_ix is not None:
|
||||||
|
current_sym = None
|
||||||
|
sym_entry_close = None
|
||||||
|
w.iat[t, park_ix] = 1.0
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (in_on and self.instma_exit and sym_yclose is not None
|
||||||
|
and current_sym is not None):
|
||||||
|
closes = cps[current_sym]
|
||||||
|
if closes.size >= self.instma_exit and sym_yclose < float(
|
||||||
|
closes[-self.instma_exit:].mean()):
|
||||||
|
pick = self._pick_top(roff_today, cps)
|
||||||
|
if pick is not None:
|
||||||
|
current_regime = "risk_off"
|
||||||
|
bars_in_regime = 0
|
||||||
|
assign_one(pick)
|
||||||
|
pending_regime = None
|
||||||
|
pending_count = 0
|
||||||
|
if self.fast_reentry_days:
|
||||||
|
fast_until = t + self.fast_reentry_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(roff_today, cps)
|
||||||
|
if pick is not None:
|
||||||
|
current_regime = "risk_off"
|
||||||
|
bars_in_regime = 0
|
||||||
|
assign_one(pick)
|
||||||
|
pending_regime = None
|
||||||
|
pending_count = 0
|
||||||
|
if self.trail_reentry_days:
|
||||||
|
cooloff_remaining = self.trail_reentry_days
|
||||||
|
if self.fast_reentry_days:
|
||||||
|
fast_until = t + self.fast_reentry_days
|
||||||
|
w.iat[t, pick] = 1.0
|
||||||
|
continue
|
||||||
|
|
||||||
|
if emergency and current_regime != "risk_off":
|
||||||
|
pick = self._pick_top(roff_today, 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:
|
||||||
|
fast = bool(self.fast_reentry_days and desired == "risk_on" and t < fast_until)
|
||||||
|
min_hold_eff = 5 if fast else self.regime_min_hold
|
||||||
|
confirm_eff = 1 if fast else self.confirm_days
|
||||||
|
if current_regime == "risk_off" and cooloff_remaining > 0:
|
||||||
|
pending_regime = None
|
||||||
|
pending_count = 0
|
||||||
|
elif bars_in_regime < min_hold_eff:
|
||||||
|
pending_regime = None
|
||||||
|
pending_count = 0
|
||||||
|
else:
|
||||||
|
if desired != pending_regime:
|
||||||
|
pending_regime = desired
|
||||||
|
pending_count = 1
|
||||||
|
else:
|
||||||
|
pending_count += 1
|
||||||
|
if pending_count >= confirm_eff:
|
||||||
|
basket = ron_today if desired == "risk_on" else roff_today
|
||||||
|
pick = self._pick_top(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_today if in_on else roff_today
|
||||||
|
top = self._pick_top(basket, cps)
|
||||||
|
carry_ix = current_sym
|
||||||
|
if carry_ix is None:
|
||||||
|
park_ix = sym_to_ix.get(self.pt_park)
|
||||||
|
if park_ix is not None:
|
||||||
|
w.iat[t, park_ix] = 1.0
|
||||||
|
continue
|
||||||
|
if top is None or top == carry_ix:
|
||||||
|
w.iat[t, carry_ix] = 1.0
|
||||||
|
continue
|
||||||
|
if bars_in_sym < self.instrument_min_hold:
|
||||||
|
w.iat[t, carry_ix] = 1.0
|
||||||
|
continue
|
||||||
|
assign_one(top)
|
||||||
|
w.iat[t, top] = 1.0
|
||||||
|
|
||||||
|
w = w.shift(1).fillna(0.0)
|
||||||
|
|
||||||
|
# ---- Layer 2: vol target ----
|
||||||
|
if self.vt_mode == "asset":
|
||||||
|
assv = {}
|
||||||
|
for ix, arr in sym_arrays.items():
|
||||||
|
s = pd.Series(arr, index=data.index)
|
||||||
|
assv[ix] = s.pct_change(fill_method=None).rolling(
|
||||||
|
self.vt_window, min_periods=21).std() * np.sqrt(252)
|
||||||
|
held_ix = w.to_numpy().argmax(axis=1)
|
||||||
|
held_vol = np.full(len(w), np.nan)
|
||||||
|
for i in range(len(w)):
|
||||||
|
if w.iloc[i].max() > 1e-8:
|
||||||
|
held_vol[i] = assv[held_ix[i]].iloc[i]
|
||||||
|
realized = pd.Series(held_vol, index=data.index)
|
||||||
|
else:
|
||||||
|
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
|
||||||
|
port_rets = (w * daily_ret).sum(axis=1)
|
||||||
|
if self.vt_mode == "downside":
|
||||||
|
downside = port_rets.where(port_rets < 0.0, 0.0)
|
||||||
|
realized = downside.rolling(self.vt_window, min_periods=21).std() * np.sqrt(252)
|
||||||
|
realized = realized * np.sqrt(2.0) # semi-deviation -> full-vol scale
|
||||||
|
elif self.vt_ewma and self.vt_ewma > 0:
|
||||||
|
realized = port_rets.ewm(halflife=self.vt_ewma, min_periods=21).std() * np.sqrt(252)
|
||||||
|
else:
|
||||||
|
realized = port_rets.rolling(self.vt_window, min_periods=21).std() * np.sqrt(252)
|
||||||
|
scale = (self.target_vol / realized).clip(lower=self.min_lev, upper=self.max_lev)
|
||||||
|
macro = self._macro_scale(data)
|
||||||
|
if macro is not None:
|
||||||
|
scale = scale * macro.reindex(scale.index).shift(1).fillna(1.0)
|
||||||
|
scale = scale.shift(1).fillna(1.0)
|
||||||
|
w = w.mul(scale, axis=0)
|
||||||
|
|
||||||
|
# ---- Layer 3: profit take ----
|
||||||
|
if self.pt_mode == "none":
|
||||||
|
return w
|
||||||
|
|
||||||
|
park_cols = [self.pt_park] if self.pt_park else []
|
||||||
|
for c in [*park_cols, *(self.pt_park_mix or {})]:
|
||||||
|
if c and c in data.columns and c not in w.columns:
|
||||||
|
w[c] = 0.0
|
||||||
|
if self.pt_park_mix:
|
||||||
|
mix = {c: f for c, f in self.pt_park_mix.items() if c in w.columns}
|
||||||
|
if mix:
|
||||||
|
total = sum(mix.values())
|
||||||
|
mix = {c: f / total for c, f in mix.items()}
|
||||||
|
park_cols = list(mix)
|
||||||
|
else:
|
||||||
|
mix, park_cols = None, []
|
||||||
|
else:
|
||||||
|
mix = None
|
||||||
|
|
||||||
|
levels = sorted(self.pt_ladder, key=lambda x: x[0])
|
||||||
|
thresholds = [g for g, _ in levels]
|
||||||
|
cumfracs = [min(1.0, max(0.0, f)) for _, f in levels]
|
||||||
|
|
||||||
|
held = w.idxmax(axis=1)
|
||||||
|
held[w.max(axis=1) < 1e-8] = ""
|
||||||
|
|
||||||
|
entry_price: float | None = None
|
||||||
|
current_sym: str | None = None
|
||||||
|
is_cleared = False
|
||||||
|
cleared_idx = -1
|
||||||
|
last_restore_i = -10 ** 9
|
||||||
|
|
||||||
|
for i in range(len(w)):
|
||||||
|
sym = held.iloc[i]
|
||||||
|
if not sym:
|
||||||
|
current_sym = None
|
||||||
|
entry_price = None
|
||||||
|
is_cleared = False
|
||||||
|
cleared_idx = -1
|
||||||
|
continue
|
||||||
|
if self.pt_only is not None and sym not in self.pt_only:
|
||||||
|
current_sym = sym
|
||||||
|
entry_price = None
|
||||||
|
is_cleared = False
|
||||||
|
cleared_idx = -1
|
||||||
|
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_cleared = False
|
||||||
|
cleared_idx = -1
|
||||||
|
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 self.pt_mode == "binary":
|
||||||
|
if is_cleared:
|
||||||
|
if gain < self.pt_threshold - self.pt_band:
|
||||||
|
is_cleared = False
|
||||||
|
last_restore_i = i
|
||||||
|
elif gain >= self.pt_threshold and i - last_restore_i >= self.pt_min_hold:
|
||||||
|
is_cleared = True
|
||||||
|
frac = 1.0 if is_cleared else 0.0
|
||||||
|
else: # ladder
|
||||||
|
if cleared_idx >= 0 and gain < thresholds[cleared_idx] - self.pt_band:
|
||||||
|
cleared_idx = -1
|
||||||
|
for k, g in enumerate(thresholds):
|
||||||
|
if gain >= g and k > cleared_idx:
|
||||||
|
cleared_idx = k
|
||||||
|
frac = cumfracs[cleared_idx] if cleared_idx >= 0 else 0.0
|
||||||
|
|
||||||
|
if frac > 0:
|
||||||
|
cur_scale = float(w.iloc[i].sum())
|
||||||
|
w.iloc[i] = 0.0
|
||||||
|
for c in park_cols:
|
||||||
|
w.at[w.index[i], c] = cur_scale * frac * (mix[c] if mix else 1.0)
|
||||||
|
if frac < 1.0 and sym in w.columns:
|
||||||
|
w.at[w.index[i], sym] = cur_scale * (1.0 - frac)
|
||||||
|
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# harness
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_COUNT = {"n": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def register(name: str, factory, universe: list[str] | None = None) -> str:
|
||||||
|
STRATEGY_REGISTRY[name] = factory
|
||||||
|
ETF_STRATEGY_UNIVERSES[name] = sorted(set(universe or V9_UNIVERSE))
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def metrics(equity: pd.Series) -> dict:
|
||||||
|
vals = equity.to_numpy(dtype=float)
|
||||||
|
rets = vals[1:] / vals[:-1] - 1.0
|
||||||
|
n_years = len(rets) / 252.0
|
||||||
|
cagr = (vals[-1] / vals[0]) ** (1 / n_years) - 1
|
||||||
|
vol = rets.std() * np.sqrt(252)
|
||||||
|
sharpe = rets.mean() / rets.std() * np.sqrt(252) if rets.std() > 0 else 0.0
|
||||||
|
mdd = float((1 - vals / np.maximum.accumulate(vals)).max())
|
||||||
|
return {"cagr": cagr, "vol": vol, "sharpe": sharpe, "mdd": mdd,
|
||||||
|
"total": vals[-1] / vals[0] - 1.0, "years": n_years, "end": vals[-1]}
|
||||||
|
|
||||||
|
|
||||||
|
def run(names: list[str], *, start: str = "2017-12-26", end: str = "2026-09-30",
|
||||||
|
capital: float = 10000.0, tx_cost: float = 0.001, fixed_fee: float = 2.0,
|
||||||
|
resize_band: float | None = None) -> dict[str, pd.Series]:
|
||||||
|
from backtest_runner import run_market
|
||||||
|
out = run_market("us", names, None, capital, tx_cost, fixed_fee,
|
||||||
|
refresh=False, start=start, end=end)
|
||||||
|
res = {}
|
||||||
|
for run_ in out["strategy_runs"]:
|
||||||
|
res[run_["name"]] = run_["equity"]
|
||||||
|
for e in out["errors"]:
|
||||||
|
print("ERROR:", e)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def report(curves: dict[str, pd.Series], title: str = "") -> pd.DataFrame:
|
||||||
|
rows = []
|
||||||
|
for name, eq in curves.items():
|
||||||
|
m = metrics(eq)
|
||||||
|
rows.append({"name": name, **m})
|
||||||
|
df = pd.DataFrame(rows).sort_values("cagr", ascending=False)
|
||||||
|
pd.set_option("display.width", 200)
|
||||||
|
print(f"== {title} == ({len(df)} strategies)")
|
||||||
|
for _, r in df.iterrows():
|
||||||
|
print(f"{r['name']:44s} CAGR {r['cagr']*100:6.1f}% vol {r['vol']*100:5.1f}% "
|
||||||
|
f"Sharpe {r['sharpe']:5.2f} MDD {r['mdd']*100:6.1f}% end {r['end']:,.0f}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def yearly_table(curves: dict[str, pd.Series], names: list[str]) -> pd.DataFrame:
|
||||||
|
cols = {}
|
||||||
|
for n in names:
|
||||||
|
if n not in curves:
|
||||||
|
continue
|
||||||
|
eq = curves[n]
|
||||||
|
last = eq.groupby(eq.index.year).last()
|
||||||
|
prev = last.shift(1)
|
||||||
|
first_year_ret = last.iloc[0] / eq.iloc[0] - 1
|
||||||
|
yr = (last / prev - 1).fillna(0.0)
|
||||||
|
yr.iloc[0] = first_year_ret
|
||||||
|
cols[n] = yr.drop(index=eq.index.year[0] - 1, errors="ignore")
|
||||||
|
df = pd.DataFrame(cols)
|
||||||
|
print("== yearly returns ==")
|
||||||
|
print((df * 100).round(1))
|
||||||
|
return df
|
||||||
102
research/tr9_long.py
Normal file
102
research/tr9_long.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Long-history (2010+) validation for TrendRider V9 vs V7.
|
||||||
|
|
||||||
|
Reuses the Yahoo fetch of research/lab/trend_rider_long.py (ETF_UNIVERSE +
|
||||||
|
SPY/SHY, auto-adjusted, per-ticker with retries). Runs each variant through
|
||||||
|
main.strategy_positions + main.simulate_live_book with the same live-book
|
||||||
|
parameters as the earlier long-run study ($10k, 5bp, $2/order, 10% band).
|
||||||
|
|
||||||
|
PYTHONPATH=. uv run python -m research.tr9_long [--refresh]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
import trader # noqa: F401 (registry side effects)
|
||||||
|
from main import simulate_live_book, strategy_positions
|
||||||
|
from research.lab.trend_rider_long import fetch
|
||||||
|
from strategies.permanent import ETF_UNIVERSE
|
||||||
|
from trader import RESIZE_BAND, STRATEGY_REGISTRY
|
||||||
|
|
||||||
|
import research.tr9_lab as lab
|
||||||
|
|
||||||
|
OUT = os.path.join("data", "research", "tr9_long.pkl")
|
||||||
|
|
||||||
|
VARIANTS = {
|
||||||
|
# baseline registry names
|
||||||
|
"trend_rider_v7": lambda: STRATEGY_REGISTRY["trend_rider_v7"](),
|
||||||
|
"trend_rider_v7_vt24": lambda: STRATEGY_REGISTRY["trend_rider_v7_vt24"](),
|
||||||
|
"trend_rider_v7_vt32": lambda: STRATEGY_REGISTRY["trend_rider_v7_vt32"](),
|
||||||
|
"trend_rider_v7_vt36": lambda: STRATEGY_REGISTRY["trend_rider_v7_vt36"](),
|
||||||
|
"trend_rider_v9": lambda: STRATEGY_REGISTRY["trend_rider_v9"](),
|
||||||
|
"trend_rider_v9_vt32": lambda: STRATEGY_REGISTRY["trend_rider_v9_vt32"](),
|
||||||
|
"trend_rider_v10": lambda: STRATEGY_REGISTRY["trend_rider_v10"](),
|
||||||
|
"trend_rider_v10_vt32": lambda: STRATEGY_REGISTRY["trend_rider_v10_vt32"](),
|
||||||
|
"trend_rider_v10_vt36": lambda: STRATEGY_REGISTRY["trend_rider_v10_vt36"](),
|
||||||
|
# V9 candidates
|
||||||
|
"v9vt24_t20": lambda: lab.TrendRiderV9(target_vol=0.24, min_lev=0.5, trail_pct=0.20),
|
||||||
|
"v9vt28_t20": lambda: lab.TrendRiderV9(trail_pct=0.20),
|
||||||
|
"v9vt32_t20": lambda: lab.TrendRiderV9(target_vol=0.32, min_lev=0.7, trail_pct=0.20),
|
||||||
|
"v9vt34_t20": lambda: lab.TrendRiderV9(target_vol=0.34, min_lev=0.72, trail_pct=0.20),
|
||||||
|
"v9vt34_t20_h10": lambda: lab.TrendRiderV9(target_vol=0.34, min_lev=0.72, trail_pct=0.20,
|
||||||
|
pt_min_hold=10),
|
||||||
|
"v9vt34_t20_thr28_h10": lambda: lab.TrendRiderV9(target_vol=0.34, min_lev=0.72, trail_pct=0.20,
|
||||||
|
pt_min_hold=10, pt_threshold=0.28),
|
||||||
|
"v9vt34_t20_thr28_b08_h10": lambda: lab.TrendRiderV9(
|
||||||
|
target_vol=0.34, min_lev=0.72, trail_pct=0.20, pt_min_hold=10, pt_threshold=0.28,
|
||||||
|
pt_band=0.08),
|
||||||
|
"v9vt32_t20_thr28_h10": lambda: lab.TrendRiderV9(target_vol=0.32, min_lev=0.7, trail_pct=0.20,
|
||||||
|
pt_min_hold=10, pt_threshold=0.28),
|
||||||
|
"v9vt36_t20": lambda: lab.TrendRiderV9(target_vol=0.36, min_lev=0.75, trail_pct=0.20),
|
||||||
|
"v9vt36_t22": lambda: lab.TrendRiderV9(target_vol=0.36, min_lev=0.75, trail_pct=0.22),
|
||||||
|
"v9vt28_notrail": lambda: lab.TrendRiderV9(),
|
||||||
|
}
|
||||||
|
|
||||||
|
BLOCKS = [("2010-03-01", "2013-02-28"), ("2013-03-01", "2016-09-26"),
|
||||||
|
("2016-09-27", "2021-09-26"), ("2021-09-27", "2026-09-25"),
|
||||||
|
("2010-03-01", "2026-09-25")]
|
||||||
|
|
||||||
|
|
||||||
|
def block_stats(eq: pd.Series, a: str, b: str) -> dict:
|
||||||
|
r = eq.pct_change().dropna().loc[a:b]
|
||||||
|
e = (1 + r).cumprod()
|
||||||
|
return {"cagr": e.iloc[-1] ** (252 / len(r)) - 1,
|
||||||
|
"sharpe": r.mean() / r.std() * 252 ** 0.5,
|
||||||
|
"mdd": float(((e / e.cummax()) - 1).min())}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
refresh = "--refresh" in sys.argv
|
||||||
|
if refresh or not os.path.exists(OUT):
|
||||||
|
tickers = sorted(set(ETF_UNIVERSE) | {"SPY", "SHY"})
|
||||||
|
close, opens = fetch(tickers)
|
||||||
|
with open(OUT, "wb") as fh:
|
||||||
|
pickle.dump({"close": close, "opens": opens}, fh)
|
||||||
|
d = pickle.load(open(OUT, "rb"))
|
||||||
|
close, opens = d["close"], d["opens"]
|
||||||
|
print("panel", close.shape, close.index.min().date(), close.index.max().date())
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for name, factory in VARIANTS.items():
|
||||||
|
held = strategy_positions(factory(), close, open_data=opens)
|
||||||
|
eq, _ = simulate_live_book(held, close, 10_000, 0.0005, 2.0, 0, 0, 50.0, RESIZE_BAND)
|
||||||
|
full = block_stats(eq, *BLOCKS[-1])
|
||||||
|
per = {f"{a[:4]}-{b[:4]}": block_stats(eq, a, b) for a, b in BLOCKS[:-1]}
|
||||||
|
rows.append({"name": name, **full, **{k: v["sharpe"] for k, v in per.items()}})
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
pd.set_option("display.width", 220)
|
||||||
|
print("\nfull-period 2010-2026:")
|
||||||
|
print(df[["name", "cagr", "sharpe", "mdd"]].to_string(
|
||||||
|
index=False, formatters={"cagr": "{:.1%}".format, "sharpe": "{:.2f}".format,
|
||||||
|
"mdd": "{:.1%}".format}))
|
||||||
|
print("\nblock Sharpes:")
|
||||||
|
print(df[["name", "2010-2013", "2013-2016", "2016-2021", "2021-2026"]].to_string(
|
||||||
|
index=False, formatters={c: "{:.2f}".format for c in
|
||||||
|
["2010-2013", "2013-2016", "2016-2021", "2021-2026"]}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
268
research/tr9_run.py
Normal file
268
research/tr9_run.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""Run TrendRider V9 suites through the official engine path.
|
||||||
|
|
||||||
|
PYTHONPATH=. uv run python -m research.tr9_run baseline
|
||||||
|
PYTHONPATH=. uv run python -m research.tr9_run ablate
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import research.tr9_lab as lab
|
||||||
|
|
||||||
|
M = lab.TrendRiderV9
|
||||||
|
|
||||||
|
BASELINES = ["trend_rider_v7", "trend_rider_v7_vt24", "trend_rider_v7_vt32", "trend_rider_v7_vt36",
|
||||||
|
"trend_rider_v5_vt30", "trend_rider_v3_vt32"]
|
||||||
|
|
||||||
|
|
||||||
|
def register_all() -> list[str]:
|
||||||
|
reg = lab.register
|
||||||
|
names = []
|
||||||
|
# exact V7 replica in the V9 code path (sanity)
|
||||||
|
names.append(reg("v9_base", lambda **kw: M()))
|
||||||
|
# risk-on basket extensions
|
||||||
|
names.append(reg("v9_soxl", lambda **kw: M(risk_on=("TQQQ", "UPRO", "TECL", "SOXL"))))
|
||||||
|
names.append(reg("v9_soxl_tna", lambda **kw: M(risk_on=("TQQQ", "UPRO", "TECL", "SOXL", "TNA"))))
|
||||||
|
# risk-off extensions
|
||||||
|
names.append(reg("v9_tmf", lambda **kw: M(tmf="TMF")))
|
||||||
|
names.append(reg("v9_sqqq", lambda **kw: M(sqqq="SQQQ")))
|
||||||
|
names.append(reg("v9_tmf_sqqq", lambda **kw: M(tmf="TMF", sqqq="SQQQ")))
|
||||||
|
names.append(reg("v9_soxl_tmf", lambda **kw: M(risk_on=("TQQQ", "UPRO", "TECL", "SOXL"), tmf="TMF")))
|
||||||
|
names.append(reg("v9_soxl_tmf_sqqq", lambda **kw: M(
|
||||||
|
risk_on=("TQQQ", "UPRO", "TECL", "SOXL"), tmf="TMF", sqqq="SQQQ")))
|
||||||
|
# risk-off gating variants (fixed: TLT/QQQ gate refs now included)
|
||||||
|
names.append(reg("v9_sqqq05", lambda **kw: M(sqqq="SQQQ", sqqq_trigger=0.05)))
|
||||||
|
names.append(reg("v9_sqqq10", lambda **kw: M(sqqq="SQQQ", sqqq_trigger=0.10)))
|
||||||
|
names.append(reg("v9_tmf_sqqq05", lambda **kw: M(tmf="TMF", sqqq="SQQQ", sqqq_trigger=0.05)))
|
||||||
|
names.append(reg("v9_sqqq_rostop", lambda **kw: M(sqqq="SQQQ", ro_stop_loss=0.15)))
|
||||||
|
names.append(reg("v9_tmf_sqqq_rostop", lambda **kw: M(
|
||||||
|
tmf="TMF", sqqq="SQQQ", ro_stop_loss=0.15)))
|
||||||
|
# profit-take / park variants
|
||||||
|
names.append(reg("v9_park_qqq", lambda **kw: M(pt_park_mix={"SHY": 0.5, "QQQ": 0.5})))
|
||||||
|
names.append(reg("v9_ladder", lambda **kw: M(
|
||||||
|
pt_mode="ladder", pt_ladder=((0.30, 0.5), (0.60, 1.0)))))
|
||||||
|
names.append(reg("v9_no_pt", lambda **kw: M(pt_mode="none")))
|
||||||
|
# vol target variants
|
||||||
|
names.append(reg("v9_assetvt", lambda **kw: M(vt_mode="asset")))
|
||||||
|
names.append(reg("v9_ewma", lambda **kw: M(vt_ewma=20)))
|
||||||
|
# pick metric
|
||||||
|
names.append(reg("v9_momdiv", lambda **kw: M(mom_pick_divisor=True)))
|
||||||
|
# trailing-stop on the leveraged risk-on leg
|
||||||
|
names.append(reg("v9_trail18", lambda **kw: M(trail_pct=0.18)))
|
||||||
|
names.append(reg("v9_trail22", lambda **kw: M(trail_pct=0.22)))
|
||||||
|
names.append(reg("v9_trail26", lambda **kw: M(trail_pct=0.26)))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
suite = sys.argv[1] if len(sys.argv) > 1 else "baseline"
|
||||||
|
if suite == "baseline":
|
||||||
|
curves = lab.run(BASELINES)
|
||||||
|
lab.report(curves, "baseline replication")
|
||||||
|
elif suite == "ablate":
|
||||||
|
names = register_all()
|
||||||
|
curves = lab.run(BASELINES + names)
|
||||||
|
lab.report(curves, "ablation")
|
||||||
|
elif suite == "riskoff":
|
||||||
|
names = register_all()
|
||||||
|
pick = ["v9_base", "v9_tmf", "v9_sqqq", "v9_sqqq05", "v9_sqqq10",
|
||||||
|
"v9_tmf_sqqq", "v9_tmf_sqqq05", "v9_sqqq_rostop", "v9_tmf_sqqq_rostop"]
|
||||||
|
curves = lab.run(BASELINES[:4] + pick)
|
||||||
|
lab.report(curves, "risk-off extensions")
|
||||||
|
lab.yearly_table(curves, ["trend_rider_v7", "v9_tmf", "v9_sqqq", "v9_tmf_sqqq05"])
|
||||||
|
elif suite == "trail":
|
||||||
|
names = register_all()
|
||||||
|
|
||||||
|
def vt(tv, ml, **kw):
|
||||||
|
return M(target_vol=tv, min_lev=ml, **kw)
|
||||||
|
|
||||||
|
extra = [
|
||||||
|
lab.register("v9vt36", lambda **kw: vt(0.36, 0.75)),
|
||||||
|
lab.register("v9vt36_t18", lambda **kw: vt(0.36, 0.75, trail_pct=0.18)),
|
||||||
|
lab.register("v9vt36_t22", lambda **kw: vt(0.36, 0.75, trail_pct=0.22)),
|
||||||
|
lab.register("v9vt36_t26", lambda **kw: vt(0.36, 0.75, trail_pct=0.26)),
|
||||||
|
lab.register("v9vt32_t22", lambda **kw: vt(0.32, 0.7, trail_pct=0.22)),
|
||||||
|
lab.register("v9vt36_b15", lambda **kw: vt(0.36, 0.75, pt_band=0.15)),
|
||||||
|
lab.register("v9vt36_thr25", lambda **kw: vt(0.36, 0.75, pt_threshold=0.25)),
|
||||||
|
lab.register("v9vt36_thr35", lambda **kw: vt(0.36, 0.75, pt_threshold=0.35)),
|
||||||
|
]
|
||||||
|
curves = lab.run(BASELINES[:4] + ["v9_trail22"] + extra)
|
||||||
|
lab.report(curves, "trail / PT tuning")
|
||||||
|
lab.yearly_table(curves, ["trend_rider_v7_vt36", "v9vt36", "v9vt36_t22", "v9vt36_t26"])
|
||||||
|
elif suite == "grid":
|
||||||
|
def vt(tv, ml, **kw):
|
||||||
|
return M(target_vol=tv, min_lev=ml, **kw)
|
||||||
|
|
||||||
|
extra = []
|
||||||
|
for t in (0.20, 0.21, 0.22, 0.23, 0.24):
|
||||||
|
extra.append(lab.register(f"g36_t{int(t*100)}", lambda t=t, **kw: vt(0.36, 0.75, trail_pct=t)))
|
||||||
|
for t in (0.20, 0.22, 0.24):
|
||||||
|
extra.append(lab.register(f"g32_t{int(t*100)}", lambda t=t, **kw: vt(0.32, 0.7, trail_pct=t)))
|
||||||
|
extra.append(lab.register("g36_t22_w20", lambda **kw: vt(0.36, 0.75, trail_pct=0.22, trail_window=20)))
|
||||||
|
extra.append(lab.register("g36_t22_w40", lambda **kw: vt(0.36, 0.75, trail_pct=0.22, trail_window=40)))
|
||||||
|
extra.append(lab.register("g36_t22_r5", lambda **kw: vt(0.36, 0.75, trail_pct=0.22, trail_reentry_days=5)))
|
||||||
|
extra.append(lab.register("g36_t22_r10", lambda **kw: vt(0.36, 0.75, trail_pct=0.22, trail_reentry_days=10)))
|
||||||
|
extra.append(lab.register("g34_t22", lambda **kw: vt(0.34, 0.72, trail_pct=0.22)))
|
||||||
|
curves = lab.run(BASELINES[:4] + extra)
|
||||||
|
lab.report(curves, "trail grid")
|
||||||
|
lab.yearly_table(curves, ["trend_rider_v7_vt36", "g36_t20", "g36_t22", "g36_t24", "g32_t22"])
|
||||||
|
elif suite == "frontier":
|
||||||
|
def vt(tv, ml, **kw):
|
||||||
|
return M(target_vol=tv, min_lev=ml, **kw)
|
||||||
|
|
||||||
|
extra = []
|
||||||
|
for tv, ml in ((0.30, 0.65), (0.32, 0.70), (0.34, 0.72), (0.35, 0.73), (0.36, 0.75), (0.38, 0.78)):
|
||||||
|
extra.append(lab.register(f"f{int(tv*100)}_t20", lambda tv=tv, ml=ml, **kw: vt(tv, ml, trail_pct=0.20)))
|
||||||
|
extra.append(lab.register("f36_t18", lambda **kw: vt(0.36, 0.75, trail_pct=0.18)))
|
||||||
|
extra.append(lab.register("f36_t19", lambda **kw: vt(0.36, 0.75, trail_pct=0.19)))
|
||||||
|
extra.append(lab.register("f36_t22_w25", lambda **kw: vt(0.36, 0.75, trail_pct=0.22, trail_window=25)))
|
||||||
|
extra.append(lab.register("f36_t22_w35", lambda **kw: vt(0.36, 0.75, trail_pct=0.22, trail_window=35)))
|
||||||
|
extra.append(lab.register("f32_t18", lambda **kw: vt(0.32, 0.70, trail_pct=0.18)))
|
||||||
|
curves = lab.run(BASELINES[:4] + extra)
|
||||||
|
lab.report(curves, "frontier map")
|
||||||
|
elif suite == "macro":
|
||||||
|
MACRO_UNIVERSE = lab.V9_UNIVERSE + ["^VIX9D", "^VIX3M", "^VIX", "HYG"]
|
||||||
|
|
||||||
|
def mv(tv, ml, **kw):
|
||||||
|
return M(target_vol=tv, min_lev=ml, trail_pct=0.20, **kw)
|
||||||
|
|
||||||
|
def reg_macro(name, **kw):
|
||||||
|
return lab.register(name, lambda **kw2: mv(**kw, **kw2), universe=MACRO_UNIVERSE)
|
||||||
|
|
||||||
|
extra = [
|
||||||
|
reg_macro("mc32_m1", tv=0.32, ml=0.70, throttle_vix_inv=True),
|
||||||
|
reg_macro("mc32_m2", tv=0.32, ml=0.70, throttle_vix_spike=True),
|
||||||
|
reg_macro("mc32_m3", tv=0.32, ml=0.70, throttle_credit=True),
|
||||||
|
reg_macro("mc32_m13", tv=0.32, ml=0.70, throttle_vix_inv=True, throttle_credit=True),
|
||||||
|
reg_macro("mc32_m123", tv=0.32, ml=0.70, throttle_vix_inv=True, throttle_vix_spike=True,
|
||||||
|
throttle_credit=True),
|
||||||
|
reg_macro("mc36_m1", tv=0.36, ml=0.75, throttle_vix_inv=True),
|
||||||
|
reg_macro("mc36_m3", tv=0.36, ml=0.75, throttle_credit=True),
|
||||||
|
reg_macro("mc36_m123", tv=0.36, ml=0.75, throttle_vix_inv=True, throttle_vix_spike=True,
|
||||||
|
throttle_credit=True),
|
||||||
|
reg_macro("mc34_m3", tv=0.34, ml=0.72, throttle_credit=True),
|
||||||
|
reg_macro("mc34_m13", tv=0.34, ml=0.72, throttle_vix_inv=True, throttle_credit=True),
|
||||||
|
]
|
||||||
|
curves = lab.run(["trend_rider_v9", "trend_rider_v9_vt32", "trend_rider_v9_vt36"] + extra)
|
||||||
|
lab.report(curves, "macro throttle")
|
||||||
|
lab.yearly_table(curves, ["trend_rider_v9_vt32", "mc32_m3", "mc32_m13", "mc32_m123"])
|
||||||
|
elif suite == "knobs":
|
||||||
|
KNOB_UNIVERSE = lab.V9_UNIVERSE + ["RSP"]
|
||||||
|
|
||||||
|
def kv(tv, ml, **kw):
|
||||||
|
return M(target_vol=tv, min_lev=ml, trail_pct=0.20, **kw)
|
||||||
|
|
||||||
|
def reg_knob(name, **kw):
|
||||||
|
return lab.register(name, lambda **kw2: kv(**kw, **kw2), universe=KNOB_UNIVERSE)
|
||||||
|
|
||||||
|
extra = [
|
||||||
|
reg_knob("k32_base", tv=0.32, ml=0.70),
|
||||||
|
reg_knob("k32_instma50", tv=0.32, ml=0.70, instma_exit=50),
|
||||||
|
reg_knob("k32_instma30", tv=0.32, ml=0.70, instma_exit=30),
|
||||||
|
reg_knob("k32_fast", tv=0.32, ml=0.70, fast_reentry_days=60),
|
||||||
|
reg_knob("k32_instma50_fast", tv=0.32, ml=0.70, instma_exit=50, fast_reentry_days=60),
|
||||||
|
reg_knob("k32_breadth100", tv=0.32, ml=0.70, breadth_ma=100),
|
||||||
|
reg_knob("k32_dd4", tv=0.32, ml=0.70, dd_stop=0.04),
|
||||||
|
reg_knob("k32_ddw30", tv=0.32, ml=0.70, dd_window=30),
|
||||||
|
reg_knob("k32_conf2", tv=0.32, ml=0.70, confirm_days=2),
|
||||||
|
reg_knob("k32_mom84", tv=0.32, ml=0.70, mom_lookback=84),
|
||||||
|
]
|
||||||
|
curves = lab.run(["trend_rider_v9", "trend_rider_v9_vt32", "trend_rider_v9_vt36"] + extra)
|
||||||
|
lab.report(curves, "structural knobs")
|
||||||
|
import numpy as np
|
||||||
|
a = curves.get("trend_rider_v9_vt32")
|
||||||
|
b = curves.get("k32_base")
|
||||||
|
if a is not None and b is not None:
|
||||||
|
print("vt32 fidelity:", np.allclose(a.to_numpy(), b.to_numpy()), "maxdiff",
|
||||||
|
float(np.abs(a.to_numpy() - b.to_numpy()).max()))
|
||||||
|
elif suite == "pt2":
|
||||||
|
def pv(tv, ml, **kw):
|
||||||
|
return M(target_vol=tv, min_lev=ml, trail_pct=0.20, **kw)
|
||||||
|
|
||||||
|
extra = [
|
||||||
|
lab.register("p32_base", lambda **kw: pv(0.32, 0.70)),
|
||||||
|
lab.register("p32_band06", lambda **kw: pv(0.32, 0.70, pt_band=0.06)),
|
||||||
|
lab.register("p32_band08", lambda **kw: pv(0.32, 0.70, pt_band=0.08)),
|
||||||
|
lab.register("p32_hold10", lambda **kw: pv(0.32, 0.70, pt_min_hold=10)),
|
||||||
|
lab.register("p32_hold21", lambda **kw: pv(0.32, 0.70, pt_min_hold=21)),
|
||||||
|
lab.register("p32_ononly", lambda **kw: pv(0.32, 0.70, pt_only=("TQQQ", "UPRO"))),
|
||||||
|
lab.register("p36_band06", lambda **kw: pv(0.36, 0.75, pt_band=0.06)),
|
||||||
|
lab.register("p36_hold10", lambda **kw: pv(0.36, 0.75, pt_min_hold=10)),
|
||||||
|
lab.register("p36_ononly", lambda **kw: pv(0.36, 0.75, pt_only=("TQQQ", "UPRO"))),
|
||||||
|
lab.register("p34_hold10", lambda **kw: pv(0.34, 0.72, pt_min_hold=10)),
|
||||||
|
]
|
||||||
|
curves = lab.run(["trend_rider_v9", "trend_rider_v9_vt32", "trend_rider_v9_vt36"] + extra)
|
||||||
|
lab.report(curves, "PT variants 2")
|
||||||
|
lab.yearly_table(curves, ["trend_rider_v9_vt32", "p32_band06", "p32_hold10", "p32_ononly"])
|
||||||
|
elif suite == "pt3":
|
||||||
|
def qv(tv, ml, **kw):
|
||||||
|
return M(target_vol=tv, min_lev=ml, trail_pct=0.20, **kw)
|
||||||
|
|
||||||
|
extra = [
|
||||||
|
lab.register("q33_h10", lambda **kw: qv(0.33, 0.71, pt_min_hold=10)),
|
||||||
|
lab.register("q34_h10", lambda **kw: qv(0.34, 0.72, pt_min_hold=10)),
|
||||||
|
lab.register("q34_b08_h10", lambda **kw: qv(0.34, 0.72, pt_min_hold=10, pt_band=0.08)),
|
||||||
|
lab.register("q34_b06_h10", lambda **kw: qv(0.34, 0.72, pt_min_hold=10, pt_band=0.06)),
|
||||||
|
lab.register("q32_b06_h10", lambda **kw: qv(0.32, 0.70, pt_min_hold=10, pt_band=0.06)),
|
||||||
|
lab.register("q36_b06_h10", lambda **kw: qv(0.36, 0.75, pt_min_hold=10, pt_band=0.06)),
|
||||||
|
lab.register("q32_b08_h10", lambda **kw: qv(0.32, 0.70, pt_min_hold=10, pt_band=0.08)),
|
||||||
|
lab.register("q34_h10_thr28", lambda **kw: qv(0.34, 0.72, pt_min_hold=10, pt_threshold=0.28)),
|
||||||
|
]
|
||||||
|
curves = lab.run(["trend_rider_v9", "trend_rider_v9_vt32", "trend_rider_v9_vt36",
|
||||||
|
"trend_rider_v9_vt24"] + extra)
|
||||||
|
lab.report(curves, "PT hold + band grid")
|
||||||
|
lab.yearly_table(curves, ["trend_rider_v9_vt32", "q34_h10", "q34_b06_h10", "q32_b06_h10"])
|
||||||
|
elif suite == "pt4":
|
||||||
|
def rv(tv, ml, **kw):
|
||||||
|
return M(target_vol=tv, min_lev=ml, trail_pct=0.20, pt_min_hold=10, **kw)
|
||||||
|
|
||||||
|
extra = []
|
||||||
|
for tv, ml in ((0.33, 0.71), (0.34, 0.72)):
|
||||||
|
for thr in (0.26, 0.27, 0.28, 0.29, 0.30):
|
||||||
|
extra.append(lab.register(
|
||||||
|
f"r{int(tv*100)}_thr{int(thr*100)}",
|
||||||
|
lambda tv=tv, ml=ml, thr=thr, **kw: rv(tv, ml, pt_threshold=thr)))
|
||||||
|
extra.append(lab.register("r34_thr28_b08", lambda **kw: rv(0.34, 0.72, pt_threshold=0.28,
|
||||||
|
pt_band=0.08)))
|
||||||
|
extra.append(lab.register("r34_thr28_t22", lambda **kw: M(
|
||||||
|
target_vol=0.34, min_lev=0.72, trail_pct=0.22, pt_min_hold=10, pt_threshold=0.28)))
|
||||||
|
curves = lab.run(["trend_rider_v9", "trend_rider_v9_vt32", "trend_rider_v9_vt36"] + extra)
|
||||||
|
lab.report(curves, "thr neighborhood")
|
||||||
|
lab.yearly_table(curves, ["trend_rider_v9_vt36", "r34_thr28", "r33_thr28", "r34_thr27"])
|
||||||
|
elif suite == "final":
|
||||||
|
def fv(tv, ml, **kw):
|
||||||
|
return M(target_vol=tv, min_lev=ml, trail_pct=0.20, pt_min_hold=10,
|
||||||
|
pt_threshold=0.28, pt_band=0.08, **kw)
|
||||||
|
|
||||||
|
extra = [
|
||||||
|
lab.register("f10_34", lambda **kw: fv(0.34, 0.72)),
|
||||||
|
lab.register("f10_32", lambda **kw: fv(0.32, 0.70)),
|
||||||
|
lab.register("f10_36", lambda **kw: fv(0.36, 0.75)),
|
||||||
|
lab.register("f10_34_t22", lambda **kw: M(target_vol=0.34, min_lev=0.72, trail_pct=0.22,
|
||||||
|
pt_min_hold=10, pt_threshold=0.28,
|
||||||
|
pt_band=0.08)),
|
||||||
|
]
|
||||||
|
curves = lab.run(["trend_rider_v9", "trend_rider_v9_vt32", "trend_rider_v9_vt36",
|
||||||
|
"trend_rider_v9_vt24"] + extra, end="2026-10-07")
|
||||||
|
lab.report(curves, "finalists @ official endpoint 2026-10-07")
|
||||||
|
elif suite == "v10robe":
|
||||||
|
def w(tv, ml, **kw):
|
||||||
|
return M(target_vol=tv, min_lev=ml, pt_min_hold=10, **kw)
|
||||||
|
|
||||||
|
extra = []
|
||||||
|
for t in (0.18, 0.19, 0.20, 0.21, 0.22):
|
||||||
|
extra.append(lab.register(f"w34_t{int(t*100)}", lambda t=t, **kw: w(
|
||||||
|
0.34, 0.72, trail_pct=t, pt_threshold=0.28, pt_band=0.08)))
|
||||||
|
for thr in (0.26, 0.27, 0.28, 0.29, 0.30):
|
||||||
|
for band in (0.06, 0.08, 0.10):
|
||||||
|
extra.append(lab.register(f"w34_{int(thr*100)}_{int(band*100)}", lambda thr=thr, band=band, **kw: w(
|
||||||
|
0.34, 0.72, trail_pct=0.20, pt_threshold=thr, pt_band=band)))
|
||||||
|
curves = lab.run(["trend_rider_v9", "trend_rider_v9_vt32", "trend_rider_v10"] + extra,
|
||||||
|
end="2026-10-07")
|
||||||
|
lab.report(curves, "v10 robustness grids @2026-10-07")
|
||||||
|
else:
|
||||||
|
raise SystemExit(f"unknown suite {suite}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
93
research/tr9_trials.py
Normal file
93
research/tr9_trials.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Record every TrendRider V9 search trial on the official window.
|
||||||
|
|
||||||
|
The repo keeps an append-only trial log (DSR discipline): each searched
|
||||||
|
configuration is recorded with its result. This script re-runs the trial
|
||||||
|
set explored for V9 on the 2017-12-26..2026-09-30 engine window and appends
|
||||||
|
JSONL records to data/research/results/trials_us.jsonl.
|
||||||
|
|
||||||
|
PYTHONPATH=. uv run python -m research.tr9_trials [--dry-run]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from research.lab.trials import append_trials
|
||||||
|
|
||||||
|
import research.tr9_lab as lab
|
||||||
|
|
||||||
|
M = lab.TrendRiderV9
|
||||||
|
OUT = os.path.join("data", "research", "results", "trials_us.jsonl")
|
||||||
|
PROTOCOL = "tr9-v1-2026-10-07"
|
||||||
|
|
||||||
|
# (name, kwargs) — every configuration judged on the design window.
|
||||||
|
TRIALS = [
|
||||||
|
("v9_base(v7 replica)", {}),
|
||||||
|
("v9_soxl", {"risk_on": ("TQQQ", "UPRO", "TECL", "SOXL")}),
|
||||||
|
("v9_soxl_tna", {"risk_on": ("TQQQ", "UPRO", "TECL", "SOXL", "TNA")}),
|
||||||
|
("v9_tmf", {"tmf": "TMF"}),
|
||||||
|
("v9_sqqq", {"sqqq": "SQQQ"}),
|
||||||
|
("v9_tmf_sqqq", {"tmf": "TMF", "sqqq": "SQQQ"}),
|
||||||
|
("v9_park_qqq", {"pt_park_mix": {"SHY": 0.5, "QQQ": 0.5}}),
|
||||||
|
("v9_ladder", {"pt_mode": "ladder"}),
|
||||||
|
("v9_no_pt", {"pt_mode": "none"}),
|
||||||
|
("v9_assetvt", {"vt_mode": "asset"}),
|
||||||
|
("v9_ewma", {"vt_ewma": 20}),
|
||||||
|
("v9_momdiv", {"mom_pick_divisor": True}),
|
||||||
|
("v9_trail18", {"trail_pct": 0.18}),
|
||||||
|
("v9_trail22", {"trail_pct": 0.22}),
|
||||||
|
("v9_trail26", {"trail_pct": 0.26}),
|
||||||
|
("vt36", {"target_vol": 0.36, "min_lev": 0.75}),
|
||||||
|
("vt36_t18", {"target_vol": 0.36, "min_lev": 0.75, "trail_pct": 0.18}),
|
||||||
|
("vt36_t19", {"target_vol": 0.36, "min_lev": 0.75, "trail_pct": 0.19}),
|
||||||
|
("vt36_t20", {"target_vol": 0.36, "min_lev": 0.75, "trail_pct": 0.20}),
|
||||||
|
("vt36_t21", {"target_vol": 0.36, "min_lev": 0.75, "trail_pct": 0.21}),
|
||||||
|
("vt36_t22", {"target_vol": 0.36, "min_lev": 0.75, "trail_pct": 0.22}),
|
||||||
|
("vt36_t23", {"target_vol": 0.36, "min_lev": 0.75, "trail_pct": 0.23}),
|
||||||
|
("vt36_t24", {"target_vol": 0.36, "min_lev": 0.75, "trail_pct": 0.24}),
|
||||||
|
("vt36_b15", {"target_vol": 0.36, "min_lev": 0.75, "pt_band": 0.15}),
|
||||||
|
("vt36_thr25", {"target_vol": 0.36, "min_lev": 0.75, "pt_threshold": 0.25}),
|
||||||
|
("vt36_thr35", {"target_vol": 0.36, "min_lev": 0.75, "pt_threshold": 0.35}),
|
||||||
|
("vt32_t18", {"target_vol": 0.32, "min_lev": 0.70, "trail_pct": 0.18}),
|
||||||
|
("vt32_t20", {"target_vol": 0.32, "min_lev": 0.70, "trail_pct": 0.20}),
|
||||||
|
("vt32_t22", {"target_vol": 0.32, "min_lev": 0.70, "trail_pct": 0.22}),
|
||||||
|
("vt34_t20", {"target_vol": 0.34, "min_lev": 0.72, "trail_pct": 0.20}),
|
||||||
|
("vt34_t22", {"target_vol": 0.34, "min_lev": 0.72, "trail_pct": 0.22}),
|
||||||
|
("vt38_t20", {"target_vol": 0.38, "min_lev": 0.78, "trail_pct": 0.20}),
|
||||||
|
("vt30_t20", {"target_vol": 0.30, "min_lev": 0.65, "trail_pct": 0.20}),
|
||||||
|
("vt24_t20", {"target_vol": 0.24, "min_lev": 0.50, "trail_pct": 0.20}),
|
||||||
|
("vt36_t20_w20", {"target_vol": 0.36, "min_lev": 0.75, "trail_pct": 0.20, "trail_window": 20}),
|
||||||
|
("vt36_t20_w40", {"target_vol": 0.36, "min_lev": 0.75, "trail_pct": 0.20, "trail_window": 40}),
|
||||||
|
("vt36_t22_r10", {"target_vol": 0.36, "min_lev": 0.75, "trail_pct": 0.22, "trail_reentry_days": 10}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dry = "--dry-run" in sys.argv
|
||||||
|
names = []
|
||||||
|
for i, (label, kw) in enumerate(TRIALS):
|
||||||
|
name = f"tr9t{i:02d}_{label}"
|
||||||
|
lab.register(name, lambda kw=kw, **_: M(**kw))
|
||||||
|
names.append(name)
|
||||||
|
curves = lab.run(names)
|
||||||
|
rows = []
|
||||||
|
for i, (label, kw) in enumerate(TRIALS):
|
||||||
|
name = f"tr9t{i:02d}_{label}"
|
||||||
|
if name not in curves:
|
||||||
|
rows.append({"trial": label, "kwargs": kw, "error": "run failed"})
|
||||||
|
continue
|
||||||
|
m = lab.metrics(curves[name])
|
||||||
|
rows.append({"trial": label, "kwargs": {k: str(v) for k, v in kw.items()},
|
||||||
|
"cagr": m["cagr"], "vol": m["vol"], "sharpe": m["sharpe"], "mdd": m["mdd"],
|
||||||
|
"window": "2017-12-26..2026-09-30", "capital": 10000.0,
|
||||||
|
"fixed_fee": 2.0, "tx_cost": 0.001})
|
||||||
|
df = lab.report(curves, "trials")
|
||||||
|
if dry:
|
||||||
|
print("dry run: not writing", OUT)
|
||||||
|
return
|
||||||
|
append_trials(OUT, rows, PROTOCOL)
|
||||||
|
print(f"appended {len(rows)} trials to {OUT} (protocol {PROTOCOL})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
327
strategies/trend_rider_v9.py
Normal file
327
strategies/trend_rider_v9.py
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
"""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"]
|
||||||
20
trader.py
20
trader.py
@@ -71,6 +71,7 @@ from strategies.trend_rider_voltgt import (
|
|||||||
TrendRiderV5VolTarget,
|
TrendRiderV5VolTarget,
|
||||||
)
|
)
|
||||||
from strategies.trend_rider_v7 import TrendRiderV7
|
from strategies.trend_rider_v7 import TrendRiderV7
|
||||||
|
from strategies.trend_rider_v9 import TrendRiderV9
|
||||||
from strategies.ensemble_alpha import (
|
from strategies.ensemble_alpha import (
|
||||||
EnsembleAlphaStrategy,
|
EnsembleAlphaStrategy,
|
||||||
EnhancedFactorComboStrategy,
|
EnhancedFactorComboStrategy,
|
||||||
@@ -265,6 +266,18 @@ STRATEGY_REGISTRY = {
|
|||||||
"trend_rider_v7_vt24": lambda **kw: TrendRiderV7(target_vol=0.24, min_lev=0.5),
|
"trend_rider_v7_vt24": lambda **kw: TrendRiderV7(target_vol=0.24, min_lev=0.5),
|
||||||
"trend_rider_v7_vt32": lambda **kw: TrendRiderV7(target_vol=0.32, min_lev=0.7),
|
"trend_rider_v7_vt32": lambda **kw: TrendRiderV7(target_vol=0.32, min_lev=0.7),
|
||||||
"trend_rider_v7_vt36": lambda **kw: TrendRiderV7(target_vol=0.36, min_lev=0.75),
|
"trend_rider_v7_vt36": lambda **kw: TrendRiderV7(target_vol=0.36, min_lev=0.75),
|
||||||
|
# --- V9: V7 + trailing stop on the leveraged risk-on leg ---
|
||||||
|
"trend_rider_v9": lambda **kw: TrendRiderV9(),
|
||||||
|
"trend_rider_v9_vt24": lambda **kw: TrendRiderV9(target_vol=0.24, min_lev=0.5),
|
||||||
|
"trend_rider_v9_vt32": lambda **kw: TrendRiderV9(target_vol=0.32, min_lev=0.7),
|
||||||
|
"trend_rider_v9_vt36": lambda **kw: TrendRiderV9(target_vol=0.36, min_lev=0.75),
|
||||||
|
# --- V10: V9 + PT refinement (clear 28% / restore band 8pp / 10-day re-clear lockout) ---
|
||||||
|
"trend_rider_v10": lambda **kw: TrendRiderV9(
|
||||||
|
pt_threshold=0.28, pt_band=0.08, pt_min_hold=10),
|
||||||
|
"trend_rider_v10_vt32": lambda **kw: TrendRiderV9(
|
||||||
|
target_vol=0.32, min_lev=0.70, pt_threshold=0.28, pt_band=0.08, pt_min_hold=10),
|
||||||
|
"trend_rider_v10_vt36": lambda **kw: TrendRiderV9(
|
||||||
|
target_vol=0.36, min_lev=0.75, pt_threshold=0.28, pt_band=0.08, pt_min_hold=10),
|
||||||
# --- Stock-picker ensemble strategies (S&P 500 universe) ---
|
# --- Stock-picker ensemble strategies (S&P 500 universe) ---
|
||||||
"ensemble_alpha_top10": lambda **kw: EnsembleAlphaStrategy(top_n=10),
|
"ensemble_alpha_top10": lambda **kw: EnsembleAlphaStrategy(top_n=10),
|
||||||
"ensemble_alpha_top12": lambda **kw: EnsembleAlphaStrategy(top_n=12),
|
"ensemble_alpha_top12": lambda **kw: EnsembleAlphaStrategy(top_n=12),
|
||||||
@@ -380,6 +393,13 @@ ETF_STRATEGY_UNIVERSES = {
|
|||||||
"trend_rider_v7_vt24": sorted(set(ETF_UNIVERSE)),
|
"trend_rider_v7_vt24": sorted(set(ETF_UNIVERSE)),
|
||||||
"trend_rider_v7_vt32": sorted(set(ETF_UNIVERSE)),
|
"trend_rider_v7_vt32": sorted(set(ETF_UNIVERSE)),
|
||||||
"trend_rider_v7_vt36": sorted(set(ETF_UNIVERSE)),
|
"trend_rider_v7_vt36": sorted(set(ETF_UNIVERSE)),
|
||||||
|
"trend_rider_v9": sorted(set(ETF_UNIVERSE)),
|
||||||
|
"trend_rider_v9_vt24": sorted(set(ETF_UNIVERSE)),
|
||||||
|
"trend_rider_v9_vt32": sorted(set(ETF_UNIVERSE)),
|
||||||
|
"trend_rider_v9_vt36": sorted(set(ETF_UNIVERSE)),
|
||||||
|
"trend_rider_v10": sorted(set(ETF_UNIVERSE)),
|
||||||
|
"trend_rider_v10_vt32": sorted(set(ETF_UNIVERSE)),
|
||||||
|
"trend_rider_v10_vt36": sorted(set(ETF_UNIVERSE)),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Strategies that use the market's stock universe PLUS fixed extra ETF tickers.
|
# Strategies that use the market's stock universe PLUS fixed extra ETF tickers.
|
||||||
|
|||||||
Reference in New Issue
Block a user