diff --git a/strategies/trend_rider_v7.py b/strategies/trend_rider_v7.py new file mode 100644 index 0000000..89c37b4 --- /dev/null +++ b/strategies/trend_rider_v7.py @@ -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"] diff --git a/strategies/trend_rider_voltgt.py b/strategies/trend_rider_voltgt.py new file mode 100644 index 0000000..e222125 --- /dev/null +++ b/strategies/trend_rider_voltgt.py @@ -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"] diff --git a/trader.py b/trader.py index f22379b..ee1cea5 100644 --- a/trader.py +++ b/trader.py @@ -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"])