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:
187
strategies/trend_rider_v7.py
Normal file
187
strategies/trend_rider_v7.py
Normal 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"]
|
||||||
120
strategies/trend_rider_voltgt.py
Normal file
120
strategies/trend_rider_voltgt.py
Normal 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
155
trader.py
@@ -54,6 +54,23 @@ from strategies.permanent import (
|
|||||||
)
|
)
|
||||||
from strategies.recovery_momentum import RecoveryMomentumStrategy
|
from strategies.recovery_momentum import RecoveryMomentumStrategy
|
||||||
from strategies.trend_following import TrendFollowingStrategy
|
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
|
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
|
# These are applied automatically by cmd_monitor and cmd_auto; they can still
|
||||||
# be overridden by explicitly passing --fixed-fee on the CLI.
|
# be overridden by explicitly passing --fixed-fee on the CLI.
|
||||||
MARKET_FEES = {
|
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)
|
"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
|
# Strategy registry
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -126,6 +149,61 @@ STRATEGY_REGISTRY = {
|
|||||||
risk_off=("GLD", "DBC"),
|
risk_off=("GLD", "DBC"),
|
||||||
),
|
),
|
||||||
"trend_rider_v4": lambda **kw: TrendRiderV4(),
|
"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 = {
|
ETF_STRATEGY_UNIVERSES = {
|
||||||
@@ -133,6 +211,24 @@ ETF_STRATEGY_UNIVERSES = {
|
|||||||
"trend_rider_v3_global": sorted(set(GLOBAL_ETF_UNIVERSE)),
|
"trend_rider_v3_global": sorted(set(GLOBAL_ETF_UNIVERSE)),
|
||||||
"trend_rider_v3_hk": sorted(set(HK_ETF_UNIVERSE)),
|
"trend_rider_v3_hk": sorted(set(HK_ETF_UNIVERSE)),
|
||||||
"trend_rider_v4": sorted(set(TREND_RIDER_V4_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 = [
|
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
|
Stock strategies use the market's dynamic universe. TrendRider variants
|
||||||
trade fixed USD/HK ETF baskets and use SPY as the regime benchmark.
|
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_")
|
base_name = strategy_name.removeprefix("sim_")
|
||||||
if base_name in ETF_STRATEGY_UNIVERSES:
|
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]
|
universe = UNIVERSES[market]
|
||||||
tickers = universe["fetch"]()
|
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"]
|
return tickers, universe["benchmark"]
|
||||||
|
|
||||||
|
|
||||||
@@ -308,13 +410,39 @@ def compute_trades(holdings: dict, cash: float, target_weights: dict,
|
|||||||
return raw
|
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,
|
def execute_trades(state: dict, trades: list[dict], prices: dict,
|
||||||
tx_cost: float = 0.001, fixed_fee: float = 0.0,
|
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:
|
trade_date: str = "", integer_shares: bool = False) -> None:
|
||||||
"""Execute trades: update holdings and cash in state, append to trade_log.
|
"""Execute trades: update holdings and cash in state, append to trade_log.
|
||||||
|
|
||||||
When integer_shares=True, sells are executed first to free up cash,
|
When integer_shares=True, sells are executed first to free up cash,
|
||||||
then buys are executed only if sufficient cash is available.
|
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"]
|
holdings = state["holdings"]
|
||||||
cash = state["cash"]
|
cash = state["cash"]
|
||||||
@@ -329,18 +457,26 @@ def execute_trades(state: dict, trades: list[dict], prices: dict,
|
|||||||
delta = trade["shares_delta"]
|
delta = trade["shares_delta"]
|
||||||
price = prices.get(ticker, trade["price"])
|
price = prices.get(ticker, trade["price"])
|
||||||
cost = abs(delta * 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:
|
if delta > 0:
|
||||||
# BUY — skip if insufficient cash in integer mode
|
# BUY — skip if insufficient cash in integer mode
|
||||||
if integer_shares and (cost + commission) > cash:
|
if integer_shares and (cost + commission) > cash:
|
||||||
# Try buying fewer shares that we can afford
|
# Try buying fewer shares that we can afford, accounting for
|
||||||
affordable = int((cash - fixed_fee) / (price * (1 + tx_cost)))
|
# 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:
|
if affordable < 1:
|
||||||
continue
|
continue
|
||||||
delta = affordable
|
delta = affordable
|
||||||
cost = abs(delta * price)
|
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)
|
cash -= (cost + commission)
|
||||||
holdings[ticker] = holdings.get(ticker, 0.0) + delta
|
holdings[ticker] = holdings.get(ticker, 0.0) + delta
|
||||||
else:
|
else:
|
||||||
@@ -579,8 +715,12 @@ def cmd_evening(args):
|
|||||||
integer_shares=args.integer_shares
|
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,
|
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)
|
trade_date=trade_date, integer_shares=args.integer_shares)
|
||||||
|
|
||||||
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
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
|
# 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)
|
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,
|
execute_trades(state, trades, close_prices,
|
||||||
tx_cost=args.tx_cost, fixed_fee=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=today_str, integer_shares=args.integer_shares)
|
trade_date=today_str, integer_shares=args.integer_shares)
|
||||||
|
|
||||||
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||||||
|
|||||||
Reference in New Issue
Block a user