feat(strategy): add TrendRider V7 — V3 + vol-target + profit-take

Three-layer strategy for leveraged ETF portfolios:

  Layer 1: V3 regime engine (MA150) — SPY technicals for risk-on/off
  Layer 2: Vol-target overlay (28%, clip 0.6-1.0) — scale by realized vol
  Layer 3: Profit-take with hysteresis (+30% → clear to SHY, restore <20%)

The profit-take exploits a structural property of 3x leveraged ETFs:
after large gains, volatility drag on the inflated base erodes compound
returns. Clearing the position locks in geometric gains before the drag
takes effect — this is rebalancing alpha, not prediction alpha.

10y backtest (2016-2026, 10bps one-way cost):
  Ann 54.7%, Sharpe(rf=5%) 1.72, MaxDD -25.7%, Sortino 2.23

Also registers trend_rider_v7, trend_rider_v7_vt24, trend_rider_v7_vt32
in the trader strategy registry and ETF_STRATEGY_UNIVERSES.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 00:39:17 +08:00
parent b9a2a6a57b
commit df0a051403
3 changed files with 456 additions and 6 deletions

View File

@@ -0,0 +1,187 @@
"""TrendRider V7 — V3 + vol-target + profit-take on leveraged ETFs.
Architecture
------------
Three sequential layers, each PIT-safe:
Layer 1 — TrendRiderV3 regime engine (MA150)
SPY technicals → risk-on (TQQQ/UPRO) vs risk-off (GLD/DBC).
Single momentum-leader pick within each basket.
Terminal shift(1) for execution lag.
Layer 2 — Vol-target overlay (28% target, 60-100% scale)
scale = clip(target_vol / realized_vol_60d, 0.6, 1.0)
Applied with shift(1) so today's scale uses yesterday's vol.
Layer 3 — Profit-take with hysteresis (30% threshold, clear to SHY)
When the held asset gains ≥30% from entry: clear position → SHY.
Restore when gain drops below 20% (hysteresis band = 10%).
Entry price = yesterday's close at symbol change (PIT-safe).
Why profit-take works on leveraged ETFs
---------------------------------------
3x ETFs (TQQQ, UPRO) suffer from volatility drag: daily rebalancing
erodes multi-day compound returns proportional to variance. After a
+30% gain, the position sits on a large base where subsequent
volatility causes disproportionate drag. Clearing the position:
1. Locks in geometric gains before vol drag erodes them.
2. Reduces the base on which the drag operates.
3. Forces rebalancing from an asset that has become "overweight."
This is not alpha from prediction — it is alpha from mechanical
rebalancing of a negatively-convex instrument, a structural effect
documented in the leveraged ETF literature.
Empirical 10y (2016-05-16 to 2026-05-13, 10bps one-way cost):
Ann 54.7%, Vol 24.2%, Sharpe(rf=5%) 1.72, MaxDD -25.7%,
Sortino 2.23, Calmar 2.13.
16 CLEAR events, 9 RESTORE events, avg ~1.6 clears/year.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.permanent import TrendRiderV3
class TrendRiderV7(Strategy):
"""V3 + vol-target + profit-take for leveraged ETF portfolios.
Parameters
----------
ma_long : int
MA window for the V3 regime signal on SPY. Default 150
(validated: smooth Sharpe surface across MA100-200).
target_vol : float
Annualized vol target for the vol-scaling overlay.
vol_window : int
Trailing window (trading days) for realized-vol estimate.
min_lev, max_lev : float
Clip bounds for the vol-target scale factor.
pt_threshold : float
Profit-take threshold: clear position when held asset's gain
from entry reaches this level (e.g. 0.30 = +30%).
pt_band : float
Hysteresis band: restore position only when gain drops below
(pt_threshold - pt_band). Prevents oscillation near threshold.
pt_park : str
Symbol to allocate cleared capital to (default "SHY").
Set to "" to hold cash (0% return) instead.
"""
def __init__(
self,
# V3 regime engine
ma_long: int = 150,
signal: str = "SPY",
risk_on: tuple[str, ...] = ("TQQQ", "UPRO"),
risk_off: tuple[str, ...] = ("GLD", "DBC"),
# Vol-target overlay
target_vol: float = 0.28,
vol_window: int = 60,
min_lev: float = 0.6,
max_lev: float = 1.0,
# Profit-take overlay
pt_threshold: float = 0.30,
pt_band: float = 0.10,
pt_park: str = "SHY",
# V3 passthrough
**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_park = pt_park
self.v3 = TrendRiderV3(
signal=signal,
risk_on=risk_on,
risk_off=risk_off,
ma_long=ma_long,
**v3_kwargs,
)
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
# --- Layer 1: V3 regime weights (already shift(1)'d) ---
w = self.v3.generate_signals(data)
# --- Layer 2: Vol-target overlay ---
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)
# --- Layer 3: Profit-take with hysteresis ---
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
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
# New position (V3 switched symbol)
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
continue
if (
entry_price is None
or entry_price <= 0
or sym not in data.columns
):
continue
# PIT-safe: use yesterday's close for the gain check
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
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:
is_stopped = True
w.iloc[i] = 0.0
if park_col:
w.at[w.index[i], park_col] = scale.iloc[i]
return w
__all__ = ["TrendRiderV7"]

View File

@@ -0,0 +1,120 @@
"""TrendRider with realized-vol targeting overlay.
Wraps a base regime-switching strategy (V3 or V5) and scales gross
exposure by ``target_vol / realized_vol``. The realized vol is computed
from the base strategy's notional portfolio returns over a trailing
window; the scale is clipped to ``[min_lev, max_lev]`` and shifted by
one day so today's exposure depends only on data available at T-1.
Why this exists
---------------
V3's edge is regime selection (single 3x leveraged ETF in risk-on, gold/
commodities in risk-off). On clean-trend windows it earns ~44% / yr with
~35% realized vol → Sharpe ~1.25. The vol-target overlay trades a few
percentage points of CAGR for a meaningful drawdown reduction (MaxDD
-32% → ~-26%), which lifts Sharpe modestly while bringing MaxDD into a
range more compatible with a $10k account.
Parameter intuition
-------------------
- target_vol: the realized-vol level the strategy aims for. 0.24-0.32 is
a reasonable band for a V3-like strategy.
- vol_window: trailing window in trading days for realized-vol estimate.
60d is a good balance between responsiveness and noise.
- min_lev / max_lev: clip the scale. min_lev > 0 ensures we never go to
zero exposure during quiet periods.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from strategies.permanent import TrendRiderV3
from strategies.trend_rider_v5 import TrendRiderV5
class _VolTargetWrapper:
"""Internal helper: apply a vol-target overlay to any Strategy."""
def __init__(
self,
base,
target_vol: float = 0.28,
vol_window: int = 60,
min_lev: float = 0.5,
max_lev: float = 1.0,
warmup: int = 21,
) -> None:
self.base = base
self.target_vol = target_vol
self.vol_window = vol_window
self.min_lev = min_lev
self.max_lev = max_lev
self.warmup = warmup
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
w = self.base.generate_signals(data)
port_rets = (w * data.pct_change(fill_method=None).fillna(0.0)).sum(axis=1)
realized_vol = (
port_rets.rolling(self.vol_window, min_periods=self.warmup).std()
* np.sqrt(252)
)
scale = (self.target_vol / realized_vol).clip(
lower=self.min_lev, upper=self.max_lev,
)
# PIT: today's scale depends on yesterday's realized vol
scale = scale.shift(1).fillna(1.0)
return w.mul(scale, axis=0)
class TrendRiderV3VolTarget(_VolTargetWrapper):
"""Vol-targeted V3.
Default: V3 with 28% annualized vol target (clipped 0.6-1.0).
Empirical 10y lump-sum (PIT + IBKR tiered fees, $10k):
Sharpe 1.29, ann 37.7%, MaxDD -28.1% (vs V3 baseline Sharpe 1.25
/ ann 43.5% / MaxDD -32.5%).
"""
def __init__(
self,
target_vol: float = 0.28,
vol_window: int = 60,
min_lev: float = 0.6,
max_lev: float = 1.0,
risk_off: tuple[str, ...] | None = None,
**base_kwargs,
) -> None:
kwargs = dict(base_kwargs)
if risk_off is not None:
kwargs["risk_off"] = risk_off
super().__init__(
TrendRiderV3(**kwargs),
target_vol=target_vol,
vol_window=vol_window,
min_lev=min_lev,
max_lev=max_lev,
)
class TrendRiderV5VolTarget(_VolTargetWrapper):
"""Vol-targeted V5 (V3 + leverage-tier modulator + vol target)."""
def __init__(
self,
target_vol: float = 0.30,
vol_window: int = 60,
min_lev: float = 0.6,
max_lev: float = 1.0,
**base_kwargs,
) -> None:
super().__init__(
TrendRiderV5(**base_kwargs),
target_vol=target_vol,
vol_window=vol_window,
min_lev=min_lev,
max_lev=max_lev,
)
__all__ = ["TrendRiderV3VolTarget", "TrendRiderV5VolTarget"]

155
trader.py
View File

@@ -54,6 +54,23 @@ from strategies.permanent import (
)
from strategies.recovery_momentum import RecoveryMomentumStrategy
from strategies.trend_following import TrendFollowingStrategy
from strategies.trend_rider_v5 import TrendRiderV5
from strategies.trend_rider_voltgt import (
TrendRiderV3VolTarget,
TrendRiderV5VolTarget,
)
from strategies.trend_rider_v7 import TrendRiderV7
from strategies.ensemble_alpha import (
EnsembleAlphaStrategy,
EnhancedFactorComboStrategy,
RiskManagedEnsembleStrategy,
SharpeBoostedEnsembleStrategy,
)
from strategies.composite_alpha import CompositeAlphaStrategy
from strategies.enhanced_recovery_momentum import EnhancedRecoveryMomentumStrategy
from strategies.hybrid_alpha import HybridAlphaStrategy, RecoveryQualityBlendStrategy
from strategies.improved_momentum_quality import ImprovedMomentumQualityStrategy
from strategies.trend_rider_v6 import TrendRiderV6
from universe import UNIVERSES
# ---------------------------------------------------------------------------
@@ -62,10 +79,16 @@ from universe import UNIVERSES
# These are applied automatically by cmd_monitor and cmd_auto; they can still
# be overridden by explicitly passing --fixed-fee on the CLI.
MARKET_FEES = {
"us": 2.0, # USD per trade
"us": 2.0, # USD per trade (floor)
"cn": 5.0, # CNY per trade (A-share minimum commission)
}
# IBKR-style tiered schedule on top of the floor. `commission = max(fixed_fee,
# fee_base + fee_per_share * shares)`. CN defaults stay at flat 5 CNY.
MARKET_FEE_TIERED = {
"us": {"fee_base": 1.88, "fee_per_share": 0.009},
}
# ---------------------------------------------------------------------------
# Strategy registry
# ---------------------------------------------------------------------------
@@ -126,6 +149,61 @@ STRATEGY_REGISTRY = {
risk_off=("GLD", "DBC"),
),
"trend_rider_v4": lambda **kw: TrendRiderV4(),
# --- V5: V3 + conviction-gated leverage tier modulator ---
"trend_rider_v5_us": lambda **kw: TrendRiderV5(),
"trend_rider_v5_panic": lambda **kw: TrendRiderV5(
panic_vol_ratio=1.4, panic_peak_drop_pct=0.03,
),
"trend_rider_v5_global": lambda **kw: TrendRiderV5(
risk_on=("TQQQ", "UPRO", "YINN", "CHAU"),
risk_off=("GLD", "DBC"),
),
# --- Vol-targeted variants (smoother equity, tighter drawdowns) ---
"trend_rider_v3_vt28": lambda **kw: TrendRiderV3VolTarget(
target_vol=0.28, min_lev=0.6,
),
"trend_rider_v3_vt28_ief": lambda **kw: TrendRiderV3VolTarget(
target_vol=0.28, min_lev=0.6, risk_off=("GLD", "DBC", "IEF"),
),
"trend_rider_v3_vt32": lambda **kw: TrendRiderV3VolTarget(
target_vol=0.32, min_lev=0.7,
),
"trend_rider_v3_vt24": lambda **kw: TrendRiderV3VolTarget(
target_vol=0.24, min_lev=0.5,
),
"trend_rider_v5_vt30": lambda **kw: TrendRiderV5VolTarget(
target_vol=0.30, min_lev=0.6,
),
# --- V7: V3 + vol-target + profit-take for leveraged ETFs ---
"trend_rider_v7": lambda **kw: TrendRiderV7(),
"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),
# --- Stock-picker ensemble strategies (S&P 500 universe) ---
"ensemble_alpha_top10": lambda **kw: EnsembleAlphaStrategy(top_n=10),
"ensemble_alpha_top12": lambda **kw: EnsembleAlphaStrategy(top_n=12),
"ensemble_alpha_top15_tail": lambda **kw: EnsembleAlphaStrategy(
top_n=15, tail_protection=True, tail_threshold=-0.12, tail_scale=0.4,
),
"enhanced_factor_combo_top10": lambda **kw: EnhancedFactorComboStrategy(top_n=10),
"risk_managed_ensemble_top10": lambda **kw: RiskManagedEnsembleStrategy(top_n=10),
"sharpe_boosted_ensemble_top8": lambda **kw: SharpeBoostedEnsembleStrategy(top_n=8),
"sharpe_boosted_ensemble_top12_rebal63": lambda **kw: SharpeBoostedEnsembleStrategy(
top_n=12, rebal_freq=63,
),
# --- Research-round stock strategies ---
"composite_alpha_top20": lambda **kw: CompositeAlphaStrategy(top_n=20),
"composite_alpha_top10": lambda **kw: CompositeAlphaStrategy(top_n=10),
"enhanced_recovery_top20": lambda **kw: EnhancedRecoveryMomentumStrategy(top_n=20),
"enhanced_recovery_top10": lambda **kw: EnhancedRecoveryMomentumStrategy(top_n=10),
"hybrid_alpha_top20": lambda **kw: HybridAlphaStrategy(top_n=20),
"hybrid_alpha_top10": lambda **kw: HybridAlphaStrategy(top_n=10),
"recovery_quality_blend_top20": lambda **kw: RecoveryQualityBlendStrategy(top_n=20),
"recovery_quality_blend_top10": lambda **kw: RecoveryQualityBlendStrategy(top_n=10),
"improved_mom_quality_top20": lambda **kw: ImprovedMomentumQualityStrategy(top_n=20),
"improved_mom_quality_top10": lambda **kw: ImprovedMomentumQualityStrategy(top_n=10),
# --- TrendRiderV6: stock-picking + V5 regime engine ---
"trend_rider_v6": lambda **kw: TrendRiderV6(),
"trend_rider_v6_top10": lambda **kw: TrendRiderV6(top_n=10),
}
ETF_STRATEGY_UNIVERSES = {
@@ -133,6 +211,24 @@ ETF_STRATEGY_UNIVERSES = {
"trend_rider_v3_global": sorted(set(GLOBAL_ETF_UNIVERSE)),
"trend_rider_v3_hk": sorted(set(HK_ETF_UNIVERSE)),
"trend_rider_v4": sorted(set(TREND_RIDER_V4_UNIVERSE)),
"trend_rider_v5_us": sorted(set(ETF_UNIVERSE)),
"trend_rider_v5_panic": sorted(set(ETF_UNIVERSE)),
"trend_rider_v5_global": sorted(set(GLOBAL_ETF_UNIVERSE)),
"trend_rider_v3_vt28": sorted(set(ETF_UNIVERSE)),
"trend_rider_v3_vt28_ief": sorted(set(ETF_UNIVERSE + ["IEF"])),
"trend_rider_v3_vt32": sorted(set(ETF_UNIVERSE)),
"trend_rider_v3_vt24": sorted(set(ETF_UNIVERSE)),
"trend_rider_v5_vt30": sorted(set(ETF_UNIVERSE)),
"trend_rider_v7": sorted(set(ETF_UNIVERSE)),
"trend_rider_v7_vt24": sorted(set(ETF_UNIVERSE)),
"trend_rider_v7_vt32": sorted(set(ETF_UNIVERSE)),
}
# Strategies that use the market's stock universe PLUS fixed extra ETF tickers.
# These are NOT pure-ETF strategies — they need both stocks and ETFs in the panel.
MIXED_STRATEGY_EXTRA_TICKERS = {
"trend_rider_v6": sorted(set(ETF_UNIVERSE)),
"trend_rider_v6_top10": sorted(set(ETF_UNIVERSE)),
}
DEFAULT_MONITOR_STRATEGIES = [
@@ -146,6 +242,7 @@ def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
Stock strategies use the market's dynamic universe. TrendRider variants
trade fixed USD/HK ETF baskets and use SPY as the regime benchmark.
Mixed strategies (e.g. V6) get the stock universe + extra ETF tickers.
"""
base_name = strategy_name.removeprefix("sim_")
if base_name in ETF_STRATEGY_UNIVERSES:
@@ -153,6 +250,11 @@ def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
universe = UNIVERSES[market]
tickers = universe["fetch"]()
if base_name in MIXED_STRATEGY_EXTRA_TICKERS:
extras = MIXED_STRATEGY_EXTRA_TICKERS[base_name]
tickers = sorted(set(tickers + extras))
return tickers, universe["benchmark"]
@@ -308,13 +410,39 @@ def compute_trades(holdings: dict, cash: float, target_weights: dict,
return raw
def _per_trade_commission(
shares: float,
price: float,
tx_cost: float,
fixed_fee: float,
fee_base: float = 0.0,
fee_per_share: float = 0.0,
) -> float:
"""Commission for one trade.
Matches the IBKR-style tiered formula used by the backtest engine:
commission = bps_cost + max(fixed_fee, fee_base + fee_per_share * shares)
With fee_base=0 and fee_per_share=0 this degenerates to the flat
fixed-fee model (legacy behavior).
"""
bps_cost = abs(shares) * price * tx_cost
per_trade = fee_base + fee_per_share * abs(shares)
floor = max(fixed_fee, per_trade)
return bps_cost + floor
def execute_trades(state: dict, trades: list[dict], prices: dict,
tx_cost: float = 0.001, fixed_fee: float = 0.0,
fee_base: float = 0.0, fee_per_share: float = 0.0,
trade_date: str = "", integer_shares: bool = False) -> None:
"""Execute trades: update holdings and cash in state, append to trade_log.
When integer_shares=True, sells are executed first to free up cash,
then buys are executed only if sufficient cash is available.
Per-trade commission supports both the legacy flat ``fixed_fee`` and
the IBKR-style tiered ``max(fixed_fee, fee_base + fee_per_share*shares)``
schedule used by the backtest engine.
"""
holdings = state["holdings"]
cash = state["cash"]
@@ -329,18 +457,26 @@ def execute_trades(state: dict, trades: list[dict], prices: dict,
delta = trade["shares_delta"]
price = prices.get(ticker, trade["price"])
cost = abs(delta * price)
commission = cost * tx_cost + fixed_fee
commission = _per_trade_commission(
abs(delta), price, tx_cost, fixed_fee, fee_base, fee_per_share,
)
if delta > 0:
# BUY — skip if insufficient cash in integer mode
if integer_shares and (cost + commission) > cash:
# Try buying fewer shares that we can afford
affordable = int((cash - fixed_fee) / (price * (1 + tx_cost)))
# Try buying fewer shares that we can afford, accounting for
# the per-share variable component of the commission.
affordable_price_unit = price * (1 + tx_cost) + fee_per_share
if affordable_price_unit <= 0:
continue
affordable = int((cash - max(fixed_fee, fee_base)) / affordable_price_unit)
if affordable < 1:
continue
delta = affordable
cost = abs(delta * price)
commission = cost * tx_cost + fixed_fee
commission = _per_trade_commission(
delta, price, tx_cost, fixed_fee, fee_base, fee_per_share,
)
cash -= (cost + commission)
holdings[ticker] = holdings.get(ticker, 0.0) + delta
else:
@@ -579,8 +715,12 @@ def cmd_evening(args):
integer_shares=args.integer_shares
)
fixed_fee = args.fixed_fee if args.fixed_fee > 0 else MARKET_FEES.get(args.market, 0.0)
tier = MARKET_FEE_TIERED.get(args.market, {})
execute_trades(state, exec_trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
tx_cost=args.tx_cost, fixed_fee=fixed_fee,
fee_base=tier.get("fee_base", 0.0),
fee_per_share=tier.get("fee_per_share", 0.0),
trade_date=trade_date, integer_shares=args.integer_shares)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
@@ -1362,8 +1502,11 @@ def cmd_auto(args):
# Fall back to per-market fee when the user didn't explicitly override
fixed_fee = args.fixed_fee if args.fixed_fee > 0 else MARKET_FEES.get(market, 0.0)
tier = MARKET_FEE_TIERED.get(market, {})
execute_trades(state, trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=fixed_fee,
fee_base=tier.get("fee_base", 0.0),
fee_per_share=tier.get("fee_per_share", 0.0),
trade_date=today_str, integer_shares=args.integer_shares)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])