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

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"])