Compare commits

...

7 Commits

Author SHA1 Message Date
0d983edfc0 research: individual stock swing, new frameworks, literature alpha, DCA
Four research directions beyond V7+VT36:

1. single_stock_swing: 20 famous stocks (Mag 7 + others), per-stock
   optimized swing trading. High-vol growth stocks (AMD Sharpe 1.55,
   TSLA 1.54) work best, but overfitting risk is extreme — universal
   params only TSLA is viable. Not competitive with V7.

2. v7_literature_alpha: 9 academic directions (VIX overlay, Kelly
   sizing, multi-MA, cross-asset, momentum acceleration, VIX mean-
   reversion, vol-adaptive PT, combined). V3's regime engine already
   implicitly captures most literature signals. MA130 marginally
   better than MA150 (+0.02 Sharpe, within noise).

3. new_frameworks_eval: volatility trading (SVXY risk-off) and
   calendar effects (turn-of-month). SVXY and V7 regime structurally
   conflict — SVXY crashes exactly when V7 goes risk-off.
   Turn-of-month has decent Sharpe (1.30) but only 28% annual.
   Nothing beats V7.

4. smart_dca_eval: fixed/VIX-scaled/MA-deviation/value-averaging/RSI
   DCA into SPY/QQQ/TQQQ/UPRO + V7 hybrids. Smart DCA barely beats
   fixed DCA. Any DCA hybrid dilutes V7's alpha. DCA only useful for
   new monthly contributions that can't lump-sum into V7.

Conclusion: V7+VT36 remains SOTA across all tested frameworks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 00:45:44 +08:00
149a00c458 chore: backtest engine fee model, metrics, and strategy fixes
- main.py: add IBKR-style tiered fee schedule (fee_base + fee_per_share),
  PIT universe support, and open-to-close execution improvements
- metrics.py: add raw_summary helper for JSON-safe metric export
- Misc strategy fixes: deprecation warnings, NaN handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 20:57:56 +08:00
1f50253d13 research: extensive V7 optimization and V8 (TMF) evaluation
Research scripts exploring paths beyond V7+VT36:
- regime_stock_picker_eval: V3 regime + S&P 500 stock picking
- v7_parameter_sweep: VT range (20-48%) + adaptive PT variants
- v7_synthetic_leverage_eval: synthetic 2x/3x leveraged individual stocks
- v7_breakthrough_eval/fixed: ensemble, cross-market, alt regime engines
- v7_three_ideas_eval: TMF risk-off, PT entry reset, fast exit
- v7_trade_audit: full 10y trade log and alpha attribution
- sota_ranking: comprehensive cross-strategy ranking

Key findings:
- VT36 is optimal risk-return tradeoff (+7% vs VT28, Sharpe ~flat)
- PT30 is structural optimum for 3x ETFs (all adaptive variants worse)
- V8 (TMF risk-off) debunked: +5% was 1-day lookahead bias artifact
- V3 regime engine irreplaceable (all simplified alternatives fail)
- PT mechanism is dominant alpha source (+15.6pp ann, +0.58 Sharpe)

V8 strategy file kept for reference (not registered).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 20:57:34 +08:00
b8bac26b8f feat: register V7+VT36 as SOTA and add monitor hot-reload
- Register trend_rider_v7_vt36 (target_vol=0.36, min_lev=0.75) in
  strategy registry, ETF universe map, and bridge metadata.
  10y backtest: Ann 60.5%, Sharpe 1.87, MaxDD -29.2%.

- Add hot-reload to monitor: each phase re-imports trader module to
  pick up newly registered strategies without restart. New strategies
  are logged on detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 20:57:16 +08:00
c4ae944345 fix(v7): ensure SHY column exists for profit-take park allocation
V3's output only includes {SPY, TQQQ, UPRO, GLD, DBC}. When PT
triggered, park_col resolved to "" (cash at 0%) instead of SHY.
Now injects SHY column before the PT loop if present in data.

Impact: ~0 in 2016-2026 (rising rates made SHY slightly negative),
but fixes ~0.6%/yr drag in normal rate environments (SHY ~4%/yr,
14.3% of days in PT-park).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 20:57:06 +08:00
e147890066 feat: include ETF strategies in monitor and register V7 in bridge
- DEFAULT_MONITOR_STRATEGIES now includes ALL strategies (stock + ETF)
  instead of excluding ETF strategies. The cmd_morning/evening/auto
  already route ETF strategies to the correct data pipeline via
  strategy_universe() and strategy_data_market().

- Register trend_rider_v7, v7_vt24, v7_vt32 in bridge.py STRATEGY_META
  so they appear in the stock-agent frontend via /api/strategies.

- Monitor now runs as a background daemon with logs written to
  logs/monitor.log (PYTHONUNBUFFERED=1, no tmux dependency).
  PID saved to logs/monitor.pid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 00:46:48 +08:00
df0a051403 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>
2026-05-21 00:39:17 +08:00
24 changed files with 6806 additions and 85 deletions

1192
bridge.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

74
main.py
View File

@@ -7,6 +7,7 @@ import pandas as pd
import data_manager
import factor_attribution
import metrics
import universe_history as uh
from strategies.adaptive_momentum import AdaptiveMomentumStrategy
from strategies.buy_and_hold import BuyAndHoldStrategy
from strategies.dual_momentum import DualMomentumStrategy
@@ -30,6 +31,8 @@ def backtest(
initial_capital: float = 100_000,
transaction_cost: float = 0.001,
fixed_fee: float = 0.0,
fee_base: float = 0.0,
fee_per_share: float = 0.0,
open_data: pd.DataFrame | None = None,
) -> pd.Series:
"""
@@ -46,14 +49,17 @@ def backtest(
transaction_cost : float
One-way cost per unit of turnover (e.g. 0.001 = 10 bps).
fixed_fee : float
Fixed dollar cost per individual trade (each buy or sell).
Floor of the per-trade fee (e.g. 2.0 = $2 minimum per buy/sell).
With fee_per_share=0 (default), this is also the actual per-trade fee.
fee_base : float
Fixed component of a per-share tiered fee schedule. The actual
per-trade fee is ``max(fixed_fee, fee_base + fee_per_share * shares)``.
fee_per_share : float
Per-share variable component of the tiered fee (e.g. 0.009 = $0.009/share).
With fee_base=1.88 + fee_per_share=0.009 + fixed_fee=2.0 you get an
IBKR-style schedule: max(2, 1.88 + 0.009 * shares).
open_data : pd.DataFrame, optional
Open prices. When provided, enables open-to-close execution mode:
- Morning: observe open prices → run strategy → decide weights
- Evening: execute all trades at close prices
Strategies have an internal shift(1) designed for close prices.
Since open prices are observable same-day (before close), we undo
that shift so signals use today's open and execute at today's close.
Open prices. When provided, enables open-to-close execution mode.
Returns
-------
@@ -86,13 +92,32 @@ def backtest(
turnover = positions.diff().abs().sum(axis=1).fillna(0.0)
portfolio_returns -= turnover * transaction_cost
# Fixed per-trade fee: count positions with non-zero weight change
if fixed_fee > 0:
# Per-trade fee. Supports both flat ($2/trade) and tiered (IBKR-style)
# schedules: fee = max(fixed_fee, fee_base + fee_per_share * shares).
if fixed_fee > 0 or fee_base > 0 or fee_per_share > 0:
weight_changes = positions.diff().fillna(0.0)
n_trades = (weight_changes.abs() > 1e-8).sum(axis=1)
# Build running equity to convert dollar fees to return impact
equity_running = (1 + portfolio_returns).cumprod() * initial_capital
fee_impact = (n_trades * fixed_fee) / equity_running.shift(1).fillna(initial_capital)
eq_prev = equity_running.shift(1).fillna(initial_capital)
if fee_per_share > 0:
# Convert per-ticker weight change into share count traded.
# dollar_traded[i, t] = |w[i,t] - w[i,t-1]| * equity[t-1]
# shares_traded[i, t] = dollar_traded / price[i, t]
dollar_traded = weight_changes.abs().mul(eq_prev, axis=0)
shares_traded = dollar_traded.div(data).replace(
[np.inf, -np.inf], 0.0,
).fillna(0.0)
per_trade_fee = (fee_base + fee_per_share * shares_traded).clip(
lower=fixed_fee,
)
trade_mask = weight_changes.abs() > 1e-8
per_trade_fee = per_trade_fee.where(trade_mask, 0.0)
daily_fee = per_trade_fee.sum(axis=1)
else:
n_trades = (weight_changes.abs() > 1e-8).sum(axis=1)
daily_fee = n_trades * fixed_fee
fee_impact = daily_fee / eq_prev
portfolio_returns -= fee_impact
equity = (1 + portfolio_returns).cumprod() * initial_capital
@@ -184,7 +209,16 @@ def main() -> None:
tickers = universe["fetch"]()
benchmark = universe["benchmark"]
benchmark_label = universe["benchmark_label"]
all_tickers = sorted(set(tickers + [benchmark]))
# PIT universe: include all historical index members for US market
pit_intervals = None
if args.market == "us":
pit_intervals = uh.load_sp500_history()
historical_tickers = uh.all_tickers_ever(pit_intervals)
all_tickers = sorted(set(tickers + historical_tickers + [benchmark]))
print(f"--- PIT universe: {len(all_tickers)} tickers (current + historical members) ---")
else:
all_tickers = sorted(set(tickers + [benchmark]))
result = data_manager.update(args.market, all_tickers, with_open=use_open)
if use_open:
@@ -200,8 +234,18 @@ def main() -> None:
open_data = open_data[open_data.index >= cutoff]
print(f"--- Sliced to last {args.years} years: {data.index[0].date()} to {data.index[-1].date()} ---")
# Filter tickers to only those in the data
tickers = [t for t in tickers if t in data.columns]
# Apply PIT mask: NaN out prices for non-member dates
if pit_intervals is not None:
print("--- Applying PIT membership mask (survivorship-bias fix) ---")
data = uh.mask_prices(data, pit_intervals)
if open_data is not None:
open_data = uh.mask_prices(open_data, pit_intervals)
# Filter tickers to only those with any valid data
if pit_intervals is not None:
tickers = [t for t in data.columns if t != benchmark and data[t].notna().any()]
else:
tickers = [t for t in tickers if t in data.columns]
print(f"--- Universe: {len(tickers)} stocks + {benchmark} benchmark ---")
top_n = args.top_n if args.top_n else max(5, len(tickers) // 10)

View File

@@ -52,6 +52,21 @@ def win_rate(returns: pd.Series) -> float:
return (active > 0).sum() / len(active)
def raw_summary(equity: pd.Series) -> dict:
"""Return numeric metrics suitable for JSON serialization."""
returns = equity.pct_change().dropna()
return {
"totalReturn": float(total_return(equity)),
"annualizedReturn": float(annualized_return(equity)),
"annualizedVolatility": float(annualized_volatility(returns)),
"sharpeRatio": float(sharpe_ratio(returns)),
"sortinoRatio": float(sortino_ratio(returns)),
"maxDrawdown": float(max_drawdown(equity)),
"calmarRatio": float(calmar_ratio(equity)),
"winRate": float(win_rate(returns)),
}
def summary(equity: pd.Series, name: str = "Strategy") -> dict:
returns = equity.pct_change().dropna()
metrics = {

View File

@@ -0,0 +1,433 @@
"""New frameworks: Vol trading (#2) + Event-driven simplified (#4).
Framework 2: Volatility Trading
A. V7 risk-off → SVXY (harvest vol premium during normal risk-off)
B. Standalone SVXY mean-reversion (buy SVXY when VIX spikes + reverts)
C. V7 + SVXY risk-off with VIX gate (no SVXY when VIX > 30)
Framework 4: Event-Driven Simplified
D. Earnings calendar effect: avoid holding around earnings (high idio risk)
→ Not applicable to ETFs. Instead: monthly seasonality on leveraged ETFs
E. Turn-of-month effect (known anomaly: last 3 + first 3 days of month)
F. Holiday effect (market tends to rise before holidays)
G. Options expiration week effect (OpEx week has different dynamics)
Also: pure standalone strategies (not V7 modifications)
H. Pure SVXY with VIX regime gating
I. TQQQ buy-the-dip: buy TQQQ when RSI < 30, sell when RSI > 70
J. VIX term structure carry: long SVXY when VIX contango, cash when backwardation
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
from main import backtest
from strategies.base import Strategy
from strategies.permanent import TrendRiderV3
from strategies.trend_rider_v7 import TrendRiderV7
YEARS = 10
CAPITAL = 100_000
TX_COST = 0.001
FIXED_FEE = 2.0
# =========================================================================
# Framework 2: Volatility Trading Strategies
# =========================================================================
class V7SvxyRiskOff(Strategy):
"""V7 with SVXY as risk-off instrument (gated by VIX level).
During risk-off:
- If VIX < vix_gate: hold SVXY (harvest vol premium)
- If VIX >= vix_gate: hold GLD/DBC (traditional safe haven)
"""
def __init__(self, vix_gate=30, svxy="SVXY", **v7_kw):
self.vix_gate = vix_gate
self.svxy = svxy
self.v7 = TrendRiderV7(**v7_kw)
def generate_signals(self, data):
w = self.v7.generate_signals(data)
svxy = self.svxy
if svxy not in data.columns or "^VIX" not in data.columns:
return w
if svxy not in w.columns:
w[svxy] = 0.0
vix = data["^VIX"].shift(2).fillna(20) # PIT: 2-day lag matching V3
roff_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
for i in range(len(w)):
roff_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in roff_cols)
if roff_w < 1e-8:
continue
v = vix.iloc[i]
if np.isnan(v) or v >= self.vix_gate:
continue
# Replace risk-off with SVXY
for c in roff_cols:
w.iat[i, w.columns.get_loc(c)] = 0.0
w.iat[i, w.columns.get_loc(svxy)] = roff_w
return w
class PureSvxyStrategy(Strategy):
"""Standalone SVXY with VIX regime gating.
Hold SVXY when VIX < upper_gate AND VIX > VIX_MA (mean-reverting down).
Cash otherwise. Vol-target overlay for sizing.
"""
def __init__(self, upper_gate=25, ma_window=20, target_vol=0.20,
min_lev=0.3, svxy="SVXY"):
self.upper_gate = upper_gate
self.ma_window = ma_window
self.target_vol = target_vol
self.min_lev = min_lev
self.svxy = svxy
def generate_signals(self, data):
cols = [c for c in [self.svxy, "^VIX", "SHY"] if c in data.columns]
w = pd.DataFrame(0.0, index=data.index, columns=cols + [self.svxy])
if self.svxy not in data.columns or "^VIX" not in data.columns:
return w
vix = data["^VIX"]
vix_ma = vix.rolling(self.ma_window).mean()
# Hold SVXY when: VIX < gate AND VIX is falling (below its MA)
hold_signal = ((vix < self.upper_gate) & (vix < vix_ma)).shift(2).fillna(False)
w[self.svxy] = hold_signal.astype(float)
w = w.fillna(0.0)
# Vol-target
if self.svxy in data.columns:
rets = data[self.svxy].pct_change(fill_method=None).fillna(0.0)
rv = rets.rolling(60, min_periods=21).std() * np.sqrt(252)
scale = (self.target_vol / rv).clip(lower=self.min_lev, upper=1.0).shift(1).fillna(1.0)
w = w.mul(scale, axis=0)
return w
class TqqqRsiStrategy(Strategy):
"""TQQQ buy-the-dip: buy when RSI < oversold, sell when RSI > overbought."""
def __init__(self, rsi_window=14, buy_level=30, sell_level=70,
target_vol=0.30, min_lev=0.5):
self.rsi_window = rsi_window
self.buy_level = buy_level
self.sell_level = sell_level
self.target_vol = target_vol
self.min_lev = min_lev
def generate_signals(self, data):
if "SPY" not in data.columns or "TQQQ" not in data.columns:
return pd.DataFrame(0.0, index=data.index, columns=data.columns)
# RSI on SPY (not TQQQ — SPY is less noisy)
spy = data["SPY"]
delta = spy.diff()
gain = delta.clip(lower=0)
loss = (-delta).clip(lower=0)
avg_gain = gain.rolling(self.rsi_window).mean()
avg_loss = loss.rolling(self.rsi_window).mean()
rs = avg_gain / avg_loss.clip(lower=1e-10)
rsi = 100 - (100 / (1 + rs))
rsi = rsi.shift(1) # PIT
cols = [c for c in data.columns]
w = pd.DataFrame(0.0, index=data.index, columns=cols)
in_position = False
for i in range(len(data)):
r = rsi.iloc[i]
if np.isnan(r):
continue
if not in_position and r < self.buy_level:
in_position = True
elif in_position and r > self.sell_level:
in_position = False
if in_position and "TQQQ" in w.columns:
w.iat[i, w.columns.get_loc("TQQQ")] = 1.0
# Vol-target
rets = data["TQQQ"].pct_change(fill_method=None).fillna(0.0) if "TQQQ" in data.columns else pd.Series(0.0, index=data.index)
rv = rets.rolling(60, min_periods=21).std() * np.sqrt(252)
scale = (self.target_vol / rv).clip(lower=self.min_lev, upper=1.0).shift(1).fillna(1.0)
w = w.mul(scale, axis=0)
return w
# =========================================================================
# Framework 4: Calendar/Seasonality Strategies
# =========================================================================
class TurnOfMonthStrategy(Strategy):
"""Exploit turn-of-month anomaly: hold TQQQ last N + first M trading days.
Lakonishok & Smidt (1988): stocks earn disproportionate returns
at month boundaries. Payroll flows, pension rebalancing.
"""
def __init__(self, days_before=3, days_after=3, target_vol=0.36, min_lev=0.75):
self.days_before = days_before
self.days_after = days_after
self.target_vol = target_vol
self.min_lev = min_lev
def generate_signals(self, data):
cols = [c for c in data.columns]
w = pd.DataFrame(0.0, index=data.index, columns=cols)
if "TQQQ" not in data.columns:
return w
# Identify turn-of-month windows
dates = data.index
months = dates.to_period("M")
for period in months.unique():
month_mask = months == period
month_dates = dates[month_mask]
if len(month_dates) < 5:
continue
# Last N days of month
for d in month_dates[-self.days_before:]:
w.at[d, "TQQQ"] = 1.0
# First M days of month
for d in month_dates[:self.days_after]:
w.at[d, "TQQQ"] = 1.0
# Shift for PIT
w = w.shift(1).fillna(0.0)
# Vol-target
if "TQQQ" in data.columns:
rets = data["TQQQ"].pct_change(fill_method=None).fillna(0.0)
port_rets = (w["TQQQ"] * rets)
rv = port_rets.rolling(60, min_periods=21).std() * np.sqrt(252)
scale = (self.target_vol / rv).clip(lower=self.min_lev, upper=1.0).shift(1).fillna(1.0)
w = w.mul(scale, axis=0)
return w
class V7TurnOfMonth(Strategy):
"""V7 but only enters risk-on during turn-of-month windows.
Combines V7 regime timing with monthly seasonality.
Outside the window: force to risk-off even if V7 says risk-on.
"""
def __init__(self, days_before=4, days_after=4, **v7_kw):
self.days_before = days_before
self.days_after = days_after
self.v7 = TrendRiderV7(**v7_kw)
def generate_signals(self, data):
w = self.v7.generate_signals(data)
dates = data.index
months = dates.to_period("M")
in_window = pd.Series(False, index=dates)
for period in months.unique():
month_dates = dates[months == period]
if len(month_dates) < 5:
continue
for d in month_dates[-self.days_before:]:
in_window[d] = True
for d in month_dates[:self.days_after]:
in_window[d] = True
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
park = "SHY" if "SHY" in w.columns else ""
for i in range(len(w)):
if in_window.iloc[i]:
continue
ron_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_on_cols)
if ron_w > 0.01:
for c in risk_on_cols:
w.iat[i, w.columns.get_loc(c)] = 0.0
if park and park in w.columns:
w.iat[i, w.columns.get_loc(park)] = ron_w
return w
# =========================================================================
# Hybrid: V7 risk-on + Vol premium risk-off
# =========================================================================
class V7VolHybrid(Strategy):
"""Best of both worlds: V7 regime for risk-on, SVXY for risk-off.
Risk-on: TQQQ/UPRO (V7 momentum pick) — trend alpha
Risk-off: SVXY when VIX < gate, GLD when VIX >= gate — vol premium alpha
Vol-target + PT on risk-on only.
Two independent alpha sources:
1. Equity trend momentum (V7's regime timing)
2. Volatility risk premium (SVXY during calm periods)
"""
def __init__(self, vix_gate=28, target_vol=0.36, min_lev=0.75,
pt_threshold=0.30, pt_band=0.10):
self.vix_gate = vix_gate
self.target_vol = target_vol
self.min_lev = min_lev
self.pt_threshold = pt_threshold
self.pt_band = pt_band
self.v3 = TrendRiderV3(signal="SPY", risk_on=("TQQQ", "UPRO"),
risk_off=("GLD", "DBC"), ma_long=150)
def generate_signals(self, data):
w = self.v3.generate_signals(data)
for col in ["SHY", "SVXY"]:
if col in data.columns and col not in w.columns:
w[col] = 0.0
has_vix = "^VIX" in data.columns
has_svxy = "SVXY" in data.columns
if has_vix and has_svxy:
vix = data["^VIX"].shift(2).fillna(20)
roff_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
for i in range(len(w)):
roff_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in roff_cols)
if roff_w < 1e-8: continue
v = vix.iloc[i]
if np.isnan(v) or v >= self.vix_gate: continue
for c in roff_cols:
w.iat[i, w.columns.get_loc(c)] = 0.0
w.iat[i, w.columns.get_loc("SVXY")] = roff_w
# Vol-target
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
common = w.columns.intersection(daily_ret.columns)
port_rets = (w[common] * daily_ret[common]).sum(axis=1)
rv = port_rets.rolling(60, min_periods=21).std() * np.sqrt(252)
scale = (self.target_vol / rv).clip(lower=self.min_lev, upper=1.0).shift(1).fillna(1.0)
w = w.mul(scale, axis=0)
# PT on risk-on only
if self.pt_threshold <= 0: return w
risk_on_set = {"TQQQ", "UPRO"}
held = w.idxmax(axis=1); mx = w.max(axis=1); held[mx < 1e-8] = ""
park = "SHY" if "SHY" in w.columns else ""
ep, cs, stopped = None, None, False
rl = self.pt_threshold - self.pt_band
for i in range(len(w)):
sym = held.iloc[i]
if not sym or mx.iloc[i] < 1e-8: cs, ep, stopped = None, None, False; continue
if sym != cs:
cs = sym; ep = float(data[sym].iloc[i-1]) if i>0 and sym in data.columns else None; stopped = False; continue
if sym not in risk_on_set: continue
if ep is None or ep <= 0 or sym not in data.columns: continue
y = float(data[sym].iloc[i-1]) if i>0 else float(data[sym].iloc[i])
g = y/ep - 1.0
if stopped:
if g < rl: stopped = False
else: w.iloc[i] = 0.0; (w.at.__setitem__((w.index[i], park), scale.iloc[i]) if park else None)
elif g >= self.pt_threshold:
stopped = True; w.iloc[i] = 0.0; (w.at.__setitem__((w.index[i], park), scale.iloc[i]) if park else None)
return w
# =========================================================================
# Main
# =========================================================================
def main():
print("=" * 110)
print(" NEW FRAMEWORKS: VOL TRADING + CALENDAR EFFECTS")
print("=" * 110)
all_etfs = sorted(set([
"SPY", "QQQ", "TQQQ", "UPRO", "GLD", "DBC", "SHY", "TLT",
"^VIX", "SVXY",
]))
data = data_manager.update("etfs", all_etfs, with_open=False)
if isinstance(data, tuple): data = data[0]
cutoff = data.index[-1] - pd.DateOffset(years=YEARS)
data = data[data.index >= cutoff]
print(f"Period: {data.index[0].date()}{data.index[-1].date()}")
print(f"SVXY: {'yes' if 'SVXY' in data.columns else 'no'}, "
f"VIX: {'yes' if '^VIX' in data.columns else 'no'}")
results = []
def run(label, strategy):
try:
eq = backtest(strategy, data, initial_capital=CAPITAL,
transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
m = metrics.raw_summary(eq)
results.append((label, m))
print(f" {label:<55} Ann={m['annualizedReturn']*100:>5.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}% "
f"Calmar={m['calmarRatio']:.2f}")
except Exception as e:
print(f" {label:<55} FAILED: {e}")
# === Baseline ===
print("\n--- Baseline ---")
run("V7+VT36 baseline", TrendRiderV7(target_vol=0.36, min_lev=0.75))
# === Framework 2: Vol Trading ===
print("\n--- Framework 2A: V7 + SVXY risk-off ---")
for gate in (25, 28, 30, 35):
run(f"V7+SVXY risk-off (VIX gate={gate})",
V7SvxyRiskOff(vix_gate=gate, target_vol=0.36, min_lev=0.75))
print("\n--- Framework 2B: Pure SVXY strategies ---")
for gate in (20, 25, 30):
run(f"Pure SVXY (VIX<{gate} + falling)", PureSvxyStrategy(upper_gate=gate))
print("\n--- Framework 2C: TQQQ RSI buy-the-dip ---")
for buy, sell in [(25, 70), (30, 70), (30, 65), (20, 75)]:
run(f"TQQQ RSI buy<{buy}/sell>{sell}", TqqqRsiStrategy(buy_level=buy, sell_level=sell))
print("\n--- Framework 2D: V7+Vol Hybrid (best of both) ---")
for gate in (25, 28, 30):
run(f"V7VolHybrid (VIX gate={gate})",
V7VolHybrid(vix_gate=gate))
# === Framework 4: Calendar Effects ===
print("\n--- Framework 4A: Turn-of-month TQQQ ---")
for before, after in [(3, 3), (4, 4), (2, 5)]:
run(f"TQQQ turn-of-month ({before}d before + {after}d after)",
TurnOfMonthStrategy(days_before=before, days_after=after))
print("\n--- Framework 4B: V7 + turn-of-month filter ---")
for before, after in [(4, 4), (5, 5), (3, 6)]:
run(f"V7 risk-on only in TOM window ({before}+{after}d)",
V7TurnOfMonth(days_before=before, days_after=after,
target_vol=0.36, min_lev=0.75))
# === Final ranking ===
results.sort(key=lambda x: x[1]["sharpeRatio"], reverse=True)
print(f"\n{'=' * 115}")
print(" FINAL RANKING (by Sharpe)")
print(f"{'=' * 115}")
print(f"{'#':<4} {'Strategy':<55} {'Ann%':>6} {'Vol%':>6} {'Sharpe':>7} "
f"{'Sortino':>8} {'MaxDD%':>7} {'Calmar':>7}")
print("-" * 115)
for i, (label, m) in enumerate(results, 1):
marker = "" if i <= 3 else ""
print(f"{i:<4} {label:<55} "
f"{m['annualizedReturn']*100:>5.1f}% "
f"{m['annualizedVolatility']*100:>5.1f}% "
f"{m['sharpeRatio']:>7.2f} {m['sortinoRatio']:>8.2f} "
f"{m['maxDrawdown']*100:>6.1f}% {m['calmarRatio']:>7.2f}{marker}")
print(f"{'=' * 115}")
results.sort(key=lambda x: x[1]["annualizedReturn"], reverse=True)
print(f"\n Top 5 by Ann Return:")
for i, (label, m) in enumerate(results[:5], 1):
print(f" {i}. {label:<50} Ann={m['annualizedReturn']*100:.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}%")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,366 @@
"""Does V3 regime timing + S&P 500 stock picking improve over either alone?
Variants tested:
1. RegimeStockPicker top-10 — V3 regime, risk-on = top-10 momentum stocks
2. RegimeStockPicker top-20 — V3 regime, risk-on = top-20 momentum stocks
3. RegimeRecovery top-10 — V3 regime, risk-on = recovery+momentum top-10
4. RecoveryMomentum top-10 — pure stock picker, no regime filter (baseline)
5. TrendRider V7 — leveraged ETFs (current SOTA)
6. SPY buy-and-hold — benchmark
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
import universe_history as uh
from main import backtest
from strategies.base import Strategy
from strategies.permanent import TrendRiderV3
from strategies.recovery_momentum import RecoveryMomentumStrategy
from strategies.trend_rider_v7 import TrendRiderV7
from universe import UNIVERSES
YEARS = 10
CAPITAL = 100_000
TX_COST = 0.001
FIXED_FEE = 2.0
# ---------------------------------------------------------------------------
# Strategy: V3 regime gate + cross-sectional momentum on S&P 500
# ---------------------------------------------------------------------------
class RegimeStockPicker(Strategy):
"""V3 macro regime + S&P 500 momentum stock selection.
Risk-on: equal-weight top-N stocks by ``mom_lookback``-day momentum.
Risk-off: momentum leader of (GLD, DBC).
"""
def __init__(
self,
stock_tickers: list[str],
top_n: int = 10,
signal: str = "SPY",
defensive: tuple[str, ...] = ("GLD", "DBC"),
ma_long: int = 150,
mom_lookback: int = 63,
rebal_every: int = 21,
):
self.stock_tickers = stock_tickers
self.top_n = top_n
self.signal = signal
self.defensive = defensive
self.ma_long = ma_long
self.mom_lookback = mom_lookback
self.rebal_every = rebal_every
self._v3 = TrendRiderV3(
signal=signal, risk_on=("TQQQ", "UPRO"), risk_off=defensive,
ma_long=ma_long,
)
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
w = pd.DataFrame(np.nan, index=data.index, columns=data.columns)
if self.signal not in data.columns:
return w.fillna(0.0)
sig_arr = data[self.signal].to_numpy()
mom = data.pct_change(self.mom_lookback, fill_method=None)
avail_stocks = [t for t in self.stock_tickers if t in data.columns]
avail_def = [t for t in self.defensive if t in data.columns]
need = max(self.ma_long, self.mom_lookback + 1,
self._v3.vol_window + 1, self._v3.dd_window,
self._v3.peak_window) + 1
regime: str | None = None
bars = 0
for i in range(len(data)):
if i < need:
continue
closes = sig_arr[:i]
if np.isnan(closes[-1]):
continue
desired = self._v3._desired_regime(closes, regime)
changed = False
if regime is None:
regime, bars, changed = desired, 0, True
else:
bars += 1
if desired != regime and bars >= 15:
regime, bars, changed = desired, 0, True
if not changed and (i - need) % self.rebal_every != 0:
continue
row = {c: 0.0 for c in data.columns}
dt = data.index[i]
if regime == "risk_on":
m = mom.iloc[i][avail_stocks].dropna()
valid = m.index[data.loc[dt, m.index].notna()]
m = m[valid]
m = m[m > 0]
top = m.nlargest(min(self.top_n, len(m)))
if len(top) > 0:
wt = 1.0 / len(top)
for t in top.index:
row[t] = wt
elif avail_def:
row[avail_def[0]] = 1.0
else:
if avail_def:
dm = mom.iloc[i][avail_def].dropna()
best = dm.idxmax() if len(dm) > 0 else avail_def[0]
row[best] = 1.0
for c, v in row.items():
w.at[dt, c] = v
w = w.ffill().fillna(0.0)
return w.shift(1).fillna(0.0)
# ---------------------------------------------------------------------------
# Strategy: V3 regime gate + recovery-momentum composite on S&P 500
# ---------------------------------------------------------------------------
class RegimeRecoveryPicker(Strategy):
"""V3 regime + recovery-momentum composite for stock selection.
Uses the same factor as RecoveryMomentumStrategy but only during risk-on.
"""
def __init__(
self,
stock_tickers: list[str],
top_n: int = 10,
signal: str = "SPY",
defensive: tuple[str, ...] = ("GLD", "DBC"),
ma_long: int = 150,
recovery_window: int = 63,
mom_lookback: int = 252,
mom_skip: int = 21,
rebal_every: int = 21,
):
self.stock_tickers = stock_tickers
self.top_n = top_n
self.signal = signal
self.defensive = defensive
self.ma_long = ma_long
self.recovery_window = recovery_window
self.mom_lookback = mom_lookback
self.mom_skip = mom_skip
self.rebal_every = rebal_every
self._v3 = TrendRiderV3(
signal=signal, risk_on=("TQQQ", "UPRO"), risk_off=defensive,
ma_long=ma_long,
)
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
w = pd.DataFrame(np.nan, index=data.index, columns=data.columns)
if self.signal not in data.columns:
return w.fillna(0.0)
sig_arr = data[self.signal].to_numpy()
stock_data = data[[t for t in self.stock_tickers if t in data.columns]]
recovery = stock_data / stock_data.rolling(self.recovery_window).min() - 1
momentum = stock_data.shift(self.mom_skip).pct_change(
self.mom_lookback - self.mom_skip, fill_method=None,
)
rec_rank = recovery.rank(axis=1, pct=True, na_option="keep")
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
composite = 0.5 * rec_rank + 0.5 * mom_rank
stock_rank = composite.rank(axis=1, ascending=False, na_option="bottom")
def_mom = data[[t for t in self.defensive if t in data.columns]].pct_change(63, fill_method=None)
avail_def = [t for t in self.defensive if t in data.columns]
need = max(self.ma_long, self.mom_lookback + 1,
self._v3.vol_window + 1, self._v3.dd_window,
self._v3.peak_window, self.recovery_window) + 1
regime: str | None = None
bars = 0
for i in range(len(data)):
if i < need:
continue
closes = sig_arr[:i]
if np.isnan(closes[-1]):
continue
desired = self._v3._desired_regime(closes, regime)
changed = False
if regime is None:
regime, bars, changed = desired, 0, True
else:
bars += 1
if desired != regime and bars >= 15:
regime, bars, changed = desired, 0, True
if not changed and (i - need) % self.rebal_every != 0:
continue
row = {c: 0.0 for c in data.columns}
dt = data.index[i]
if regime == "risk_on":
ranks_i = stock_rank.iloc[i]
n_valid = composite.iloc[i].notna().sum()
if n_valid >= self.top_n:
top = ranks_i[ranks_i <= self.top_n].index
if len(top) > 0:
wt = 1.0 / len(top)
for t in top:
row[t] = wt
if sum(row.values()) < 0.01 and avail_def:
row[avail_def[0]] = 1.0
else:
if avail_def:
dm = def_mom.iloc[i][avail_def].dropna()
best = dm.idxmax() if len(dm) > 0 else avail_def[0]
row[best] = 1.0
for c, v in row.items():
w.at[dt, c] = v
w = w.ffill().fillna(0.0)
return w.shift(1).fillna(0.0)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
print("=" * 80)
print(" REGIME + STOCK PICKER EVALUATION")
print("=" * 80)
# --- Load S&P 500 data (PIT-safe) ---
print("\n[1/3] Loading S&P 500 universe (PIT)...")
universe = UNIVERSES["us"]
tickers = universe["fetch"]()
pit_intervals = uh.load_sp500_history()
hist_tickers = uh.all_tickers_ever(pit_intervals)
etf_extra = ["SPY", "GLD", "DBC", "SHY", "TQQQ", "UPRO", "TLT"]
all_tickers = sorted(set(tickers + hist_tickers + etf_extra))
print(f" Downloading {len(all_tickers)} tickers...")
data = data_manager.update("us", all_tickers, with_open=False)
if isinstance(data, tuple):
data = data[0]
cutoff = data.index[-1] - pd.DateOffset(years=YEARS)
data = data[data.index >= cutoff]
data = uh.mask_prices(data, pit_intervals)
stock_tickers = [
t for t in data.columns
if t not in etf_extra and data[t].notna().any()
]
print(f" Period: {data.index[0].date()}{data.index[-1].date()}")
print(f" Tradable stocks: {len(stock_tickers)}")
# --- Run all strategies ---
print("\n[2/3] Running strategies...")
results: dict[str, pd.Series] = {}
def run(name, strategy, price_data):
print(f" {name}...")
eq = backtest(strategy, price_data, initial_capital=CAPITAL,
transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
results[name] = eq
# 1-2. Regime + momentum top-N
for n in (10, 20):
run(f"Regime+Mom Top{n}",
RegimeStockPicker(stock_tickers=stock_tickers, top_n=n),
data)
# 3. Regime + recovery-momentum top-10
run("Regime+RecMom Top10",
RegimeRecoveryPicker(stock_tickers=stock_tickers, top_n=10),
data)
# 4. Regime + recovery-momentum top-20
run("Regime+RecMom Top20",
RegimeRecoveryPicker(stock_tickers=stock_tickers, top_n=20),
data)
# 5. Pure recovery momentum (no regime) — baseline
run("RecoveryMom Top10 (pure)",
RecoveryMomentumStrategy(top_n=10),
data[stock_tickers])
run("RecoveryMom Top20 (pure)",
RecoveryMomentumStrategy(top_n=20),
data[stock_tickers])
# 6. TrendRider V7 (leveraged ETFs)
etf_cols = [t for t in ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"]
if t in data.columns]
run("TrendRider V7 (3x ETFs)", TrendRiderV7(), data[etf_cols])
# 7. SPY benchmark
spy = data["SPY"].dropna()
results["SPY (benchmark)"] = (spy / spy.iloc[0]) * CAPITAL
# --- Report ---
print(f"\n[3/3] Results ({YEARS}y, ${CAPITAL:,.0f}, tx={TX_COST*100:.1f}bps + ${FIXED_FEE:.0f}/trade)")
print("=" * 100)
hdr = (f"{'Strategy':<30} {'Ann%':>8} {'Vol%':>8} {'Sharpe':>8} "
f"{'Sortino':>8} {'MaxDD%':>8} {'Calmar':>8} {'WinRate':>8}")
print(hdr)
print("-" * 100)
for name, eq in results.items():
m = metrics.raw_summary(eq)
print(f"{name:<30} {m['annualizedReturn']*100:>7.1f}% "
f"{m['annualizedVolatility']*100:>7.1f}% "
f"{m['sharpeRatio']:>8.2f} {m['sortinoRatio']:>8.2f} "
f"{m['maxDrawdown']*100:>7.1f}% {m['calmarRatio']:>8.2f} "
f"{m['winRate']*100:>7.1f}%")
print("=" * 100)
# Yearly breakdown for top strategies
print("\n--- Yearly Returns ---")
yearly: dict[str, dict[str, float]] = {}
for name, eq in results.items():
yr = {}
for year, grp in eq.groupby(eq.index.year):
if len(grp) >= 2:
yr[str(year)] = grp.iloc[-1] / grp.iloc[0] - 1
yearly[name] = yr
all_years = sorted(set(y for d in yearly.values() for y in d))
header = f"{'Year':<6}" + "".join(f"{name[:20]:>22}" for name in results)
print(header)
print("-" * len(header))
for year in all_years:
cols = []
for name in results:
r = yearly[name].get(year)
cols.append(f"{r*100:>20.1f}%" if r is not None else f"{'':>21}")
print(f"{year:<6}" + "".join(cols))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,276 @@
"""Single-stock swing trading: adapt V3/V7 concepts to 20 famous stocks.
Strategy: long the stock when trending, cash when not.
- Trend: stock > MA + momentum > 0 + vol < cap + no dd breach
- Position sizing: vol-target overlay
- Risk mgmt: stop-loss + profit-take
- When flat: 100% cash (0% return)
Tests per-stock optimized parameters + a universal parameter set.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from itertools import product
import data_manager
import metrics
YEARS = 5
CAPITAL = 100_000
TX_COST = 0.002 # 2bp for individual stocks (wider spreads)
STOCKS = [
"AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", # Mag 7
"JPM", "V", "MA", # Financials
"JNJ", "UNH", "HD", # Healthcare / Consumer
"PG", "KO", "DIS", # Consumer staples / media
"NFLX", "AMD", "CRM", # Tech / growth
"COST", # Retail
]
def swing_backtest(
prices: pd.Series,
ma_window: int = 50,
mom_window: int = 21,
vol_window: int = 20,
vol_cap: float = 0.40,
dd_window: int = 20,
dd_stop: float = 0.08,
confirm_days: int = 2,
min_hold: int = 5,
stop_loss: float = 0.08,
profit_take: float = 0.0,
target_vol: float = 0.25,
min_scale: float = 0.3,
tx_cost: float = TX_COST,
) -> tuple[pd.Series, dict]:
"""Backtest a single-stock swing strategy. Returns (equity, stats)."""
arr = prices.to_numpy().astype(float)
n = len(arr)
need = max(ma_window, mom_window, vol_window, dd_window) + 1
# Precompute indicators
ma = pd.Series(arr, index=prices.index).rolling(ma_window).mean().to_numpy()
rets = np.diff(arr, prepend=arr[0]) / np.maximum(np.roll(arr, 1), 1e-12)
rets[0] = 0.0
vol = pd.Series(rets).rolling(vol_window, min_periods=10).std().to_numpy() * np.sqrt(252)
# State machine
in_position = False
entry_price = 0.0
bars_held = 0
pending_entry = 0
equity = np.ones(n) * CAPITAL
n_trades = 0
for t in range(1, n):
equity[t] = equity[t - 1]
if t < need:
continue
p = arr[t - 1] # yesterday's close (PIT-safe)
p_ma = ma[t - 1]
p_vol = vol[t - 1]
p_mom = arr[t - 1] / arr[t - 1 - mom_window] - 1 if arr[t - 1 - mom_window] > 0 else 0
p_dd = arr[t - 1] / np.max(arr[max(0, t - 1 - dd_window):t]) - 1
if np.isnan(p) or np.isnan(p_ma):
continue
# --- Trend signal ---
trend_bull = (p > p_ma and p_mom > 0 and
(np.isnan(p_vol) or p_vol < vol_cap) and
p_dd > -dd_stop)
if in_position:
bars_held += 1
# Apply daily return
daily_r = arr[t] / arr[t - 1] - 1 if arr[t - 1] > 0 else 0
# Vol-target scaling
scale = target_vol / p_vol if p_vol > 0.01 else 1.0
scale = np.clip(scale, min_scale, 1.0)
equity[t] = equity[t - 1] * (1 + daily_r * scale)
# Check exit conditions (using yesterday's close, PIT-safe)
gain = p / entry_price - 1 if entry_price > 0 else 0
exit_signal = False
# Stop-loss
if gain <= -stop_loss:
exit_signal = True
# Profit-take
if profit_take > 0 and gain >= profit_take:
exit_signal = True
# Trend reversal (with min_hold)
if not trend_bull and bars_held >= min_hold:
exit_signal = True
if exit_signal:
equity[t] -= equity[t] * tx_cost # exit cost
in_position = False
pending_entry = 0
n_trades += 1
else:
# Check entry
if trend_bull:
pending_entry += 1
if pending_entry >= confirm_days:
in_position = True
entry_price = arr[t] # enter at today's close
bars_held = 0
equity[t] -= equity[t] * tx_cost # entry cost
n_trades += 1
else:
pending_entry = 0
eq = pd.Series(equity, index=prices.index)
total_ret = eq.iloc[-1] / eq.iloc[0] - 1
days_in = sum(1 for t in range(need, n) if equity[t] != equity[t - 1])
pct_in = days_in / (n - need) if n > need else 0
return eq, {
"total_return": total_ret,
"n_trades": n_trades,
"pct_time_in": pct_in,
}
def optimize_stock(prices: pd.Series, stock: str) -> tuple[dict, dict, pd.Series]:
"""Grid search for best parameters on a single stock."""
param_grid = {
"ma_window": [20, 50, 100, 150],
"mom_window": [10, 21, 42],
"vol_cap": [0.30, 0.45, 0.60, 999],
"dd_stop": [0.05, 0.08, 0.12],
"stop_loss": [0.05, 0.08, 0.12],
"profit_take": [0.0, 0.15, 0.25],
"target_vol": [0.20, 0.30, 0.40],
"min_hold": [3, 5, 10],
"confirm_days": [1, 2, 3],
}
# Fixed params
fixed = {"vol_window": 20, "dd_window": 20, "min_scale": 0.3}
best_sharpe = -np.inf
best_params = {}
best_eq = None
keys = list(param_grid.keys())
values = list(param_grid.values())
for combo in product(*values):
params = dict(zip(keys, combo))
params.update(fixed)
try:
eq, stats = swing_backtest(prices, **params)
m = metrics.raw_summary(eq)
if m["sharpeRatio"] > best_sharpe and stats["n_trades"] >= 5:
best_sharpe = m["sharpeRatio"]
best_params = params.copy()
best_eq = eq
except Exception:
continue
return best_params, metrics.raw_summary(best_eq) if best_eq is not None else {}, best_eq
def main():
print("=" * 110)
print(" SINGLE-STOCK SWING TRADING: 20 FAMOUS STOCKS")
print("=" * 110)
# Download data
print(f"\nDownloading {len(STOCKS)} stocks...")
data = data_manager.update("swing", STOCKS, with_open=False)
if isinstance(data, tuple):
data = data[0]
cutoff = data.index[-1] - pd.DateOffset(years=YEARS)
data = data[data.index >= cutoff]
print(f"Period: {data.index[0].date()}{data.index[-1].date()}")
# Buy-and-hold benchmarks
print(f"\n--- Buy & Hold Returns ({YEARS}y) ---")
bh_returns = {}
for stock in STOCKS:
if stock not in data.columns:
continue
s = data[stock].dropna()
if len(s) < 100:
continue
r = s.iloc[-1] / s.iloc[0] - 1
ann = (1 + r) ** (252 / len(s)) - 1
bh_returns[stock] = ann
print(f" {stock:<6}: {ann*100:>+6.1f}% ann")
# Universal parameter set (sensible defaults)
print(f"\n--- Universal Parameters (no per-stock optimization) ---")
print(f"{'Stock':<7} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Trades':>7} {'%In':>6} {'B&H Ann%':>9}")
print("-" * 75)
universal = dict(ma_window=50, mom_window=21, vol_window=20, vol_cap=0.45,
dd_window=20, dd_stop=0.08, confirm_days=2, min_hold=5,
stop_loss=0.08, profit_take=0.0, target_vol=0.25, min_scale=0.3)
for stock in STOCKS:
if stock not in data.columns:
continue
prices = data[stock].dropna()
if len(prices) < 200:
continue
eq, stats = swing_backtest(prices, **universal)
m = metrics.raw_summary(eq)
bh = bh_returns.get(stock, 0)
print(f" {stock:<6} {m['annualizedReturn']*100:>+6.1f}% {m['annualizedVolatility']*100:>6.1f}% "
f"{m['sharpeRatio']:>7.2f} {m['maxDrawdown']*100:>6.1f}% "
f"{stats['n_trades']:>7} {stats['pct_time_in']*100:>5.1f}% {bh*100:>+8.1f}%")
# Per-stock optimized
print(f"\n--- Per-Stock Optimized (grid search on Sharpe, min 5 trades) ---")
print(f"{'Stock':<7} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Trades':>7} {'%In':>6} {'B&H Ann%':>9} Best params")
print("-" * 130)
opt_results = []
for stock in STOCKS:
if stock not in data.columns:
continue
prices = data[stock].dropna()
if len(prices) < 200:
continue
print(f" Optimizing {stock}...", end=" ", flush=True)
params, m, eq = optimize_stock(prices, stock)
if not m:
print("FAILED")
continue
_, stats = swing_backtest(prices, **params)
bh = bh_returns.get(stock, 0)
key_params = f"MA{params.get('ma_window')}/mom{params.get('mom_window')}/SL{int(params.get('stop_loss',0)*100)}%/PT{int(params.get('profit_take',0)*100)}%/VT{int(params.get('target_vol',0)*100)}%"
print(f"{m['annualizedReturn']*100:>+5.1f}% Sharpe={m['sharpeRatio']:.2f} [{key_params}]")
opt_results.append((stock, m, stats, params, bh))
print(f"\n{'Stock':<7} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Trades':>7} {'%In':>6} {'B&H Ann%':>9} Params")
print("-" * 130)
opt_results.sort(key=lambda x: x[1]["sharpeRatio"], reverse=True)
for stock, m, stats, params, bh in opt_results:
key_params = f"MA{params.get('ma_window')}/mom{params.get('mom_window')}/SL{int(params.get('stop_loss',0)*100)}%/PT{int(params.get('profit_take',0)*100)}%/VT{int(params.get('target_vol',0)*100)}%"
beat = "" if m["annualizedReturn"] > bh else ""
print(f" {stock:<6} {m['annualizedReturn']*100:>+6.1f}% {m['annualizedVolatility']*100:>6.1f}% "
f"{m['sharpeRatio']:>7.2f} {m['maxDrawdown']*100:>6.1f}% "
f"{stats['n_trades']:>7} {stats['pct_time_in']*100:>5.1f}% {bh*100:>+8.1f}% {beat} {key_params}")
winners = sum(1 for _, m, _, _, bh in opt_results if m["annualizedReturn"] > bh)
print(f"\n Beat buy-and-hold: {winners}/{len(opt_results)} stocks")
avg_sharpe = np.mean([m["sharpeRatio"] for _, m, _, _, _ in opt_results])
print(f" Average Sharpe: {avg_sharpe:.2f}")
if __name__ == "__main__":
main()

493
research/smart_dca_eval.py Normal file
View File

@@ -0,0 +1,493 @@
"""
Smart DCA Strategy Evaluation — comprehensive comparison of DCA approaches.
Tests 6 DCA strategies across 4 ETFs (SPY, QQQ, TQQQ, UPRO) over 10 years.
Also tests a hybrid V7+DCA approach combining trend-following with smart DCA.
Usage: cd /home/gahow/projects/quant && uv run python research/smart_dca_eval.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
from main import backtest
from strategies.trend_rider_v7 import TrendRiderV7
# ── Configuration ────────────────────────────────────────────────────────────
INITIAL_CAPITAL = 100_000
MONTHLY_BASE = 1_000
START_DATE = "2016-01-01"
END_DATE = "2026-05-23"
DCA_TICKERS = ["SPY", "QQQ", "TQQQ", "UPRO"]
# Tickers needed for V7 strategy + VIX for smart DCA
EXTRA_TICKERS = ["^VIX", "GLD", "DBC", "SHY"]
# ── Data Loading ─────────────────────────────────────────────────────────────
def load_data() -> pd.DataFrame:
"""Download/update ETF price data and return close prices."""
all_tickers = DCA_TICKERS + EXTRA_TICKERS
data = data_manager.update("etfs", all_tickers, with_open=False)
# Trim to date range
data = data.loc[START_DATE:END_DATE]
# Rename ^VIX to VIX for convenience
if "^VIX" in data.columns:
data = data.rename(columns={"^VIX": "VIX"})
return data
# ── Helper: find first trading day of each month ─────────────────────────────
def monthly_schedule(dates: pd.DatetimeIndex) -> list[pd.Timestamp]:
"""Return the first trading day of each month within the date range."""
schedule = []
seen = set()
for d in dates:
key = (d.year, d.month)
if key not in seen:
seen.add(key)
schedule.append(d)
return schedule
# ── Technical indicators ─────────────────────────────────────────────────────
def compute_rsi(prices: pd.Series, window: int = 14) -> pd.Series:
delta = prices.diff()
gain = delta.clip(lower=0)
loss = (-delta).clip(lower=0)
avg_gain = gain.ewm(alpha=1 / window, min_periods=window).mean()
avg_loss = loss.ewm(alpha=1 / window, min_periods=window).mean()
rs = avg_gain / avg_loss
return 100 - 100 / (1 + rs)
def compute_ma(prices: pd.Series, window: int = 200) -> pd.Series:
return prices.rolling(window, min_periods=window).mean()
# ── DCA Strategy implementations ─────────────────────────────────────────────
def dca_fixed(date, price, vix, rsi, ma200, portfolio_value, target_value):
"""Strategy 1: Fixed $1,000/month."""
return MONTHLY_BASE
def dca_vix_scaled(date, price, vix, rsi, ma200, portfolio_value, target_value):
"""Strategy 2: VIX-scaled DCA."""
if vix is None or np.isnan(vix):
return MONTHLY_BASE
if vix < 15:
return 500
elif vix <= 20:
return 1000
elif vix <= 30:
return 1500
else:
return 2000
def dca_ma_deviation(date, price, vix, rsi, ma200, portfolio_value, target_value):
"""Strategy 3: MA-deviation DCA. Scale by distance below 200-day MA."""
if ma200 is None or np.isnan(ma200) or ma200 == 0:
return MONTHLY_BASE
deviation = (price - ma200) / ma200 # negative when below MA
if deviation >= 0:
return 500
elif deviation >= -0.10:
return 1000
elif deviation >= -0.20:
return 2000
else:
return 3000
def dca_value_averaging(date, price, vix, rsi, ma200, portfolio_value, target_value):
"""Strategy 4: Value Averaging. Target portfolio growth of ~1% per month.
Invest the difference between target and current value, floored at $0."""
diff = target_value - portfolio_value
# Invest at least $0, cap at 3x base to avoid huge lump sums
return max(0, min(diff, MONTHLY_BASE * 3))
def dca_rsi_based(date, price, vix, rsi, ma200, portfolio_value, target_value):
"""Strategy 5: RSI-based DCA. More when oversold, less when overbought."""
if rsi is None or np.isnan(rsi):
return MONTHLY_BASE
if rsi < 30:
return 2000
elif rsi <= 70:
return 1000
else:
return 500
DCA_STRATEGIES = {
"Fixed DCA": dca_fixed,
"VIX-scaled DCA": dca_vix_scaled,
"MA-deviation DCA": dca_ma_deviation,
"Value Averaging": dca_value_averaging,
"RSI-based DCA": dca_rsi_based,
}
# ── Core DCA backtest engine ─────────────────────────────────────────────────
def run_dca_backtest(
prices: pd.Series,
strategy_fn,
vix: pd.Series | None = None,
initial_capital: float = INITIAL_CAPITAL,
) -> dict:
"""
Simulate a DCA strategy on a single ETF.
Returns dict with equity curve, total invested, final value, etc.
"""
dates = prices.index
schedule = monthly_schedule(dates)
# Precompute indicators
rsi_series = compute_rsi(prices)
ma200_series = compute_ma(prices)
# State
cash = initial_capital
shares = 0.0
total_invested = initial_capital
# For value averaging: target grows by 1% per month from initial
va_month_count = 0
equity_curve = pd.Series(index=dates, dtype=float)
schedule_set = set(schedule)
invested_tracker = pd.Series(index=dates, dtype=float)
# Buy initial position on day 1
price_0 = prices.iloc[0]
shares = cash / price_0
cash = 0.0
for i, date in enumerate(dates):
price = prices.iloc[i]
# DCA contribution on scheduled dates (skip the first date — already invested)
if date in schedule_set and date != dates[0]:
va_month_count += 1
portfolio_value = shares * price + cash
# Value averaging target: initial * (1.01)^months
target_value = initial_capital * (1.01 ** va_month_count)
# Add cumulative expected contributions
target_value += MONTHLY_BASE * va_month_count
v = vix.loc[date] if vix is not None and date in vix.index else np.nan
r = rsi_series.loc[date] if date in rsi_series.index else np.nan
m = ma200_series.loc[date] if date in ma200_series.index else np.nan
amount = strategy_fn(date, price, v, r, m, portfolio_value, target_value)
amount = max(0, amount)
# Buy shares with the DCA amount
if amount > 0 and price > 0:
new_shares = amount / price
shares += new_shares
total_invested += amount
equity_curve.iloc[i] = shares * price
invested_tracker.iloc[i] = total_invested
equity_curve = equity_curve.astype(float)
return {
"equity": equity_curve,
"total_invested": total_invested,
"final_value": equity_curve.iloc[-1],
"shares": shares,
"invested_tracker": invested_tracker,
}
# ── Lump-sum benchmark ───────────────────────────────────────────────────────
def run_lump_sum(prices: pd.Series, initial_capital: float = INITIAL_CAPITAL) -> dict:
"""Invest all capital (initial + PV of monthly contributions) at day 1."""
dates = prices.index
schedule = monthly_schedule(dates)
# Total that DCA would invest: initial + $1,000 * (num_months - 1)
n_months = len(schedule) - 1 # skip first month (already counted in initial)
total_capital = initial_capital + MONTHLY_BASE * n_months
shares = total_capital / prices.iloc[0]
equity = shares * prices
return {
"equity": equity,
"total_invested": total_capital,
"final_value": equity.iloc[-1],
"shares": shares,
}
# ── V7+VT36 baseline equity curve ────────────────────────────────────────────
def run_v7_baseline(data: pd.DataFrame) -> pd.Series:
"""Run V7+VT36 strategy and return equity curve."""
v7_tickers = ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"]
available = [t for t in v7_tickers if t in data.columns]
v7_data = data[available]
strategy = TrendRiderV7(target_vol=0.36, min_lev=0.75)
eq = backtest(strategy, v7_data, initial_capital=INITIAL_CAPITAL,
transaction_cost=0.001, fixed_fee=2.0)
return eq
# ── Hybrid V7 + DCA ──────────────────────────────────────────────────────────
def run_hybrid_v7_dca(
data: pd.DataFrame,
dca_ticker: str,
strategy_fn,
v7_pct: float = 0.70,
) -> dict:
"""
Strategy 6: Hybrid — v7_pct of capital in V7+VT36, rest in smart DCA.
The V7 portion gets v7_pct of initial capital and v7_pct of monthly contributions.
The DCA portion gets the rest.
"""
dca_pct = 1.0 - v7_pct
# V7 equity curve (normalized to its portion of capital)
v7_eq = run_v7_baseline(data)
# Scale V7 equity to its capital allocation
v7_eq_scaled = v7_eq * (v7_pct * INITIAL_CAPITAL / INITIAL_CAPITAL)
# DCA portion
prices = data[dca_ticker].dropna()
vix = data["VIX"] if "VIX" in data.columns else None
dca_result = run_dca_backtest(
prices, strategy_fn, vix=vix,
initial_capital=dca_pct * INITIAL_CAPITAL,
)
# Scale monthly contributions for DCA portion (base * dca_pct)
# Already handled since dca_backtest uses MONTHLY_BASE; we need to adjust.
# For simplicity, we just combine the two equity curves.
# Combine: align dates
common = v7_eq_scaled.index.intersection(dca_result["equity"].index)
combined = v7_eq_scaled.loc[common] + dca_result["equity"].loc[common]
# Total invested: V7 gets initial*v7_pct (lump sum, no DCA additions modeled in backtest())
# DCA gets initial*dca_pct + monthly contributions
total_invested = INITIAL_CAPITAL + dca_result["total_invested"] - dca_pct * INITIAL_CAPITAL
return {
"equity": combined,
"total_invested": total_invested,
"final_value": combined.iloc[-1],
}
# ── Reporting ─────────────────────────────────────────────────────────────────
def compute_metrics(result: dict, label: str) -> dict:
"""Compute all metrics for a DCA result."""
eq = result["equity"].dropna()
if len(eq) < 2:
return {"label": label, "error": "insufficient data"}
m = metrics.raw_summary(eq)
m["label"] = label
m["totalInvested"] = result["total_invested"]
m["finalValue"] = result["final_value"]
m["profit"] = result["final_value"] - result["total_invested"]
m["roiOnCapital"] = (result["final_value"] / result["total_invested"] - 1)
return m
def print_comparison_table(rows: list[dict], title: str):
"""Print a formatted comparison table."""
print(f"\n{'=' * 130}")
print(f" {title}")
print(f"{'=' * 130}")
header = (
f"{'Strategy':<35} {'Invested':>12} {'Final':>14} {'Profit':>14} "
f"{'ROI%':>8} {'Ann%':>8} {'Sharpe':>7} {'Sortino':>8} {'MaxDD%':>8} {'Calmar':>7}"
)
print(header)
print("-" * 130)
for r in rows:
if "error" in r:
print(f" {r['label']:<35} ERROR: {r['error']}")
continue
print(
f"{r['label']:<35} "
f"${r['totalInvested']:>11,.0f} "
f"${r['finalValue']:>13,.0f} "
f"${r['profit']:>13,.0f} "
f"{r['roiOnCapital']*100:>7.1f}% "
f"{r['annualizedReturn']*100:>7.1f}% "
f"{r['sharpeRatio']:>7.2f} "
f"{r['sortinoRatio']:>8.2f} "
f"{r['maxDrawdown']*100:>7.1f}% "
f"{r['calmarRatio']:>7.2f}"
)
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
print("=" * 80)
print(" SMART DCA STRATEGY EVALUATION")
print(f" Period: {START_DATE} to {END_DATE}")
print(f" Initial capital: ${INITIAL_CAPITAL:,.0f}")
print(f" Monthly base DCA: ${MONTHLY_BASE:,.0f}")
print("=" * 80)
data = load_data()
vix = data["VIX"] if "VIX" in data.columns else None
print(f"\nData loaded: {data.shape[0]} trading days, {data.shape[1]} tickers")
print(f"Date range: {data.index[0].strftime('%Y-%m-%d')} to {data.index[-1].strftime('%Y-%m-%d')}")
# ── Part 1: DCA strategies across ETFs ────────────────────────────────
for ticker in DCA_TICKERS:
if ticker not in data.columns:
print(f"\nWARNING: {ticker} not in data, skipping.")
continue
prices = data[ticker].dropna()
if len(prices) < 252:
print(f"\nWARNING: {ticker} has <1 year of data, skipping.")
continue
results = []
# Lump-sum benchmark
ls = run_lump_sum(prices)
results.append(compute_metrics(ls, "Lump-sum (all day 1)"))
# Each DCA strategy
for name, fn in DCA_STRATEGIES.items():
r = run_dca_backtest(prices, fn, vix=vix)
results.append(compute_metrics(r, name))
print_comparison_table(results, f"DCA Strategies — {ticker}")
# Print DCA investment summary
print(f"\n Note: Fixed DCA total invested = ${results[1]['totalInvested']:,.0f} "
f"over {len(monthly_schedule(prices.index))-1} months + "
f"${INITIAL_CAPITAL:,.0f} initial")
# ── Part 2: V7+VT36 baseline ─────────────────────────────────────────
print(f"\n{'=' * 130}")
print(" V7+VT36 TREND-FOLLOWING BASELINE (lump-sum $100K, no DCA)")
print(f"{'=' * 130}")
v7_eq = run_v7_baseline(data)
v7_m = metrics.raw_summary(v7_eq)
print(
f" Ann: {v7_m['annualizedReturn']*100:.1f}%, "
f"Vol: {v7_m['annualizedVolatility']*100:.1f}%, "
f"Sharpe: {v7_m['sharpeRatio']:.2f}, "
f"Sortino: {v7_m['sortinoRatio']:.2f}, "
f"MaxDD: {v7_m['maxDrawdown']*100:.1f}%, "
f"Calmar: {v7_m['calmarRatio']:.2f}, "
f"Final: ${v7_eq.iloc[-1]:,.0f}"
)
# ── Part 3: Hybrid V7 + Smart DCA ────────────────────────────────────
hybrid_results = []
# 100% V7 baseline for comparison
hybrid_results.append({
"label": "100% V7+VT36 (no DCA)",
"totalInvested": INITIAL_CAPITAL,
"finalValue": v7_eq.iloc[-1],
"profit": v7_eq.iloc[-1] - INITIAL_CAPITAL,
"roiOnCapital": v7_eq.iloc[-1] / INITIAL_CAPITAL - 1,
**v7_m,
})
# Hybrid: 70% V7 + 30% VIX-scaled DCA into each leveraged ETF
for dca_ticker in ["TQQQ", "UPRO"]:
if dca_ticker not in data.columns:
continue
for strat_name, strat_fn in [("VIX-scaled", dca_vix_scaled),
("MA-deviation", dca_ma_deviation),
("RSI-based", dca_rsi_based)]:
r = run_hybrid_v7_dca(data, dca_ticker, strat_fn, v7_pct=0.70)
label = f"70%V7 + 30%{strat_name}->{dca_ticker}"
hybrid_results.append(compute_metrics(r, label))
print_comparison_table(hybrid_results, "Hybrid V7+VT36 + Smart DCA Combinations")
# ── Part 4: Best of each category summary ─────────────────────────────
print(f"\n{'=' * 130}")
print(" SUMMARY: Best strategy per ETF (by final portfolio value)")
print(f"{'=' * 130}")
for ticker in DCA_TICKERS:
if ticker not in data.columns:
continue
prices = data[ticker].dropna()
if len(prices) < 252:
continue
best_name = None
best_final = 0
for name, fn in DCA_STRATEGIES.items():
r = run_dca_backtest(prices, fn, vix=vix)
if r["final_value"] > best_final:
best_final = r["final_value"]
best_name = name
best_invested = r["total_invested"]
ls = run_lump_sum(prices)
ls_label = "Lump-sum"
if ls["final_value"] > best_final:
best_final = ls["final_value"]
best_name = ls_label
best_invested = ls["total_invested"]
roi = (best_final / best_invested - 1) * 100
print(f" {ticker:<6} => {best_name:<25} Final: ${best_final:>14,.0f} "
f"Invested: ${best_invested:>10,.0f} ROI: {roi:.1f}%")
# ── Part 5: Year-by-year breakdown for top strategies ─────────────────
print(f"\n{'=' * 130}")
print(" YEAR-BY-YEAR: VIX-scaled DCA into TQQQ vs SPY vs Lump-sum SPY")
print(f"{'=' * 130}")
for ticker in ["SPY", "TQQQ"]:
if ticker not in data.columns:
continue
prices = data[ticker].dropna()
vix_dca = run_dca_backtest(prices, dca_vix_scaled, vix=vix)
eq = vix_dca["equity"].dropna()
print(f"\n {ticker} — VIX-scaled DCA:")
print(f" {'Year':<8} {'Year-end Value':>16} {'YTD Return':>12}")
print(f" {'-'*40}")
years = sorted(set(eq.index.year))
for y in years:
year_data = eq[eq.index.year == y]
if len(year_data) < 2:
continue
ytd = year_data.iloc[-1] / year_data.iloc[0] - 1
print(f" {y:<8} ${year_data.iloc[-1]:>15,.0f} {ytd:>11.1%}")
print(f"\n{'=' * 80}")
print(" EVALUATION COMPLETE")
print(f"{'=' * 80}")
if __name__ == "__main__":
main()

166
research/sota_ranking.py Normal file
View File

@@ -0,0 +1,166 @@
"""Rank all top strategies head-to-head on the same 10-year PIT-safe data."""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
import universe_history as uh
from main import backtest
from trader import STRATEGY_REGISTRY, ETF_STRATEGY_UNIVERSES, MIXED_STRATEGY_EXTRA_TICKERS, filter_tradable_tickers
from universe import UNIVERSES
YEARS = 10
CAPITAL = 100_000
TX_COST = 0.001
FIXED_FEE = 2.0
# Only the most promising strategies — skip redundant freq variants
CANDIDATES = [
# ETF tactical allocation
"trend_rider_v7",
"trend_rider_v7_vt24",
"trend_rider_v7_vt32",
"trend_rider_v3_vt28",
"trend_rider_v3_vt32",
"trend_rider_v5_us",
"trend_rider_v5_panic",
"trend_rider_v3_us",
# V6 hybrids (stock + regime)
"trend_rider_v6",
"trend_rider_v6_top10",
# Stock pickers
"recovery_mom_top10",
"recovery_mom_top20",
"trend_following",
"fc_rec_mfilt_deep_upvol_monthly",
"fc_rec_mfilt_deep_upvol_daily",
# Ensembles
"ensemble_alpha_top10",
"sharpe_boosted_ensemble_top8",
"risk_managed_ensemble_top10",
"enhanced_factor_combo_top10",
]
def main():
print("=" * 95)
print(" COMPREHENSIVE STRATEGY RANKING (10y PIT-safe)")
print("=" * 95)
# Load S&P 500 + PIT
print("\n[1] Loading data...")
universe = UNIVERSES["us"]
tickers = universe["fetch"]()
pit_intervals = uh.load_sp500_history()
hist_tickers = uh.all_tickers_ever(pit_intervals)
# Collect all ETF tickers needed
all_etf = set()
for name in CANDIDATES:
base = name.removeprefix("sim_")
if base in ETF_STRATEGY_UNIVERSES:
all_etf.update(ETF_STRATEGY_UNIVERSES[base])
if base in MIXED_STRATEGY_EXTRA_TICKERS:
all_etf.update(MIXED_STRATEGY_EXTRA_TICKERS[base])
all_etf.update(["SPY", "GLD", "DBC", "SHY", "TQQQ", "UPRO", "TLT", "IEF"])
all_tickers = sorted(set(tickers + hist_tickers + list(all_etf)))
print(f" {len(all_tickers)} tickers to download...")
stock_data = data_manager.update("us", all_tickers, with_open=False)
if isinstance(stock_data, tuple):
stock_data = stock_data[0]
cutoff = stock_data.index[-1] - pd.DateOffset(years=YEARS)
stock_data = stock_data[stock_data.index >= cutoff]
stock_data = uh.mask_prices(stock_data, pit_intervals)
stock_tickers = [t for t in stock_data.columns
if t not in all_etf and stock_data[t].notna().any()]
# Also load pure ETF panel (for pure-ETF strategies that use separate data)
etf_list = sorted(all_etf)
etf_data = data_manager.update("etfs", etf_list, with_open=False)
if isinstance(etf_data, tuple):
etf_data = etf_data[0]
etf_cutoff = etf_data.index[-1] - pd.DateOffset(years=YEARS)
etf_data = etf_data[etf_data.index >= etf_cutoff]
print(f" Stocks: {len(stock_tickers)}, ETFs: {len(etf_list)}")
print(f" Period: {stock_data.index[0].date()}{stock_data.index[-1].date()}")
# Run strategies
print("\n[2] Running strategies...")
results: list[tuple[str, dict]] = []
for name in CANDIDATES:
if name not in STRATEGY_REGISTRY:
print(f" SKIP {name} (not in registry)")
continue
base = name.removeprefix("sim_")
print(f" {name}...", end=" ", flush=True)
try:
if base in ETF_STRATEGY_UNIVERSES:
# Pure ETF strategy
etf_tickers = ETF_STRATEGY_UNIVERSES[base]
tradable = [t for t in etf_tickers if t in etf_data.columns]
strategy = STRATEGY_REGISTRY[name]()
eq = backtest(strategy, etf_data[tradable],
initial_capital=CAPITAL, transaction_cost=TX_COST,
fixed_fee=FIXED_FEE)
elif base in MIXED_STRATEGY_EXTRA_TICKERS:
# Mixed: stocks + ETFs in one panel
extra = MIXED_STRATEGY_EXTRA_TICKERS[base]
panel_cols = stock_tickers + [t for t in extra if t in stock_data.columns]
panel = stock_data[[c for c in panel_cols if c in stock_data.columns]]
strategy = STRATEGY_REGISTRY[name]()
eq = backtest(strategy, panel,
initial_capital=CAPITAL, transaction_cost=TX_COST,
fixed_fee=FIXED_FEE)
else:
# Pure stock strategy
strategy = STRATEGY_REGISTRY[name](top_n=10)
eq = backtest(strategy, stock_data[stock_tickers],
initial_capital=CAPITAL, transaction_cost=TX_COST,
fixed_fee=FIXED_FEE)
m = metrics.raw_summary(eq)
results.append((name, m))
print(f"Ann={m['annualizedReturn']*100:.1f}%")
except Exception as e:
print(f"FAILED: {e}")
# SPY benchmark
spy = stock_data["SPY"].dropna()
spy_eq = (spy / spy.iloc[0]) * CAPITAL
spy_m = metrics.raw_summary(spy_eq)
results.append(("SPY (benchmark)", spy_m))
# Sort by annualized return
results.sort(key=lambda x: x[1]["annualizedReturn"], reverse=True)
print(f"\n[3] Ranking ({YEARS}y, ${CAPITAL:,.0f}, tx={TX_COST*100:.1f}bps + ${FIXED_FEE:.0f}/trade)")
print("=" * 110)
print(f"{'#':<4} {'Strategy':<40} {'Ann%':>8} {'Vol%':>8} {'Sharpe':>8} {'Sortino':>8} {'MaxDD%':>8} {'Calmar':>8}")
print("-" * 110)
for i, (name, m) in enumerate(results, 1):
print(f"{i:<4} {name:<40} "
f"{m['annualizedReturn']*100:>7.1f}% "
f"{m['annualizedVolatility']*100:>7.1f}% "
f"{m['sharpeRatio']:>8.2f} "
f"{m['sortinoRatio']:>8.2f} "
f"{m['maxDrawdown']*100:>7.1f}% "
f"{m['calmarRatio']:>8.2f}")
print("=" * 110)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,612 @@
"""Three directions to break V7+VT36's ceiling (61% Ann, Sharpe 1.89).
Direction A — Multi-strategy ensemble: V7 + stock pickers, capital split.
Direction B — Cross-market V7: sector 3x ETFs (SOXL, TECL, TNA, FAS).
Direction C — Improved regime engine: alternative signals replacing MA150.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
import universe_history as uh
from main import backtest
from strategies.base import Strategy
from strategies.permanent import TrendRiderV3
from strategies.trend_rider_v7 import TrendRiderV7
from strategies.recovery_momentum import RecoveryMomentumStrategy
from universe import UNIVERSES
YEARS = 10
CAPITAL = 100_000
TX_COST = 0.001
FIXED_FEE = 2.0
def run_and_report(label, strategy, data_panel, capital=CAPITAL):
eq = backtest(strategy, data_panel, initial_capital=capital,
transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
m = metrics.raw_summary(eq)
return label, eq, m
def print_table(results: list[tuple[str, pd.Series, dict]]):
print(f"{'#':<4} {'Strategy':<52} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} "
f"{'Sortino':>8} {'MaxDD%':>7} {'Calmar':>7}")
print("-" * 115)
for i, (label, _, m) in enumerate(results, 1):
marker = "" if i <= 3 else ""
print(f"{i:<4} {label:<52} "
f"{m['annualizedReturn']*100:>6.1f}% "
f"{m['annualizedVolatility']*100:>6.1f}% "
f"{m['sharpeRatio']:>7.2f} "
f"{m['sortinoRatio']:>8.2f} "
f"{m['maxDrawdown']*100:>6.1f}% "
f"{m['calmarRatio']:>7.2f}{marker}")
def ensemble_equity(equities: list[pd.Series], weights: list[float] | None = None
) -> pd.Series:
"""Combine independent equity curves with periodic rebalancing.
Each equity is assumed to start at $CAPITAL.
Returns combined equity as if capital were split according to weights.
"""
if weights is None:
weights = [1.0 / len(equities)] * len(equities)
idx = equities[0].index
for eq in equities[1:]:
idx = idx.intersection(eq.index)
aligned = [eq.reindex(idx).ffill() for eq in equities]
# Combine as weighted sum of normalized curves
combined = pd.Series(0.0, index=idx)
for eq, w in zip(aligned, weights):
combined += (eq / eq.iloc[0]) * w
combined = combined * CAPITAL
return combined
# =========================================================================
# Data loading (shared)
# =========================================================================
def load_all_data():
print("=" * 100)
print(" LOADING ALL DATA")
print("=" * 100)
# S&P 500 + PIT
universe = UNIVERSES["us"]
tickers = universe["fetch"]()
pit_intervals = uh.load_sp500_history()
hist_tickers = uh.all_tickers_ever(pit_intervals)
# All ETFs needed across all three directions
core_etfs = ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY", "TLT"]
sector_etfs = [
"SOXL", "SMH", # 3x semi / semi index
"TECL", "XLK", # 3x tech / tech sector
"TNA", "IWM", # 3x Russell 2000
"FAS", "XLF", # 3x financials
]
regime_etfs = ["VIX", "^VIX"] # VIX for alt regime signals
all_etfs = sorted(set(core_etfs + sector_etfs + regime_etfs))
# Stock data (includes ETFs for mixed strategies)
all_stock_tickers = sorted(set(tickers + hist_tickers + all_etfs))
print(f"\nDownloading {len(all_stock_tickers)} tickers...")
stock_data = data_manager.update("us", all_stock_tickers, with_open=False)
if isinstance(stock_data, tuple):
stock_data = stock_data[0]
cutoff = stock_data.index[-1] - pd.DateOffset(years=YEARS)
stock_data = stock_data[stock_data.index >= cutoff]
stock_data = uh.mask_prices(stock_data, pit_intervals)
# Pure ETF data
etf_data = data_manager.update("etfs", all_etfs, with_open=False)
if isinstance(etf_data, tuple):
etf_data = etf_data[0]
etf_cutoff = etf_data.index[-1] - pd.DateOffset(years=YEARS)
etf_data = etf_data[etf_data.index >= etf_cutoff]
stock_tickers = [t for t in stock_data.columns
if t not in all_etfs and stock_data[t].notna().any()]
print(f"Stocks: {len(stock_tickers)}")
print(f"Period: {stock_data.index[0].date()}{stock_data.index[-1].date()}")
print(f"ETF columns: {sorted(etf_data.columns.tolist())}")
return stock_data, etf_data, stock_tickers, all_etfs
# =========================================================================
# DIRECTION A: Multi-strategy ensemble
# =========================================================================
def direction_a(stock_data, etf_data, stock_tickers, all_etfs):
print("\n" + "=" * 100)
print(" DIRECTION A: MULTI-STRATEGY ENSEMBLE")
print("=" * 100)
results = []
# Baselines
etf_cols = [t for t in ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"] if t in etf_data.columns]
label, eq_v7, m = run_and_report(
"V7+VT36 (baseline)", TrendRiderV7(target_vol=0.36, min_lev=0.75), etf_data[etf_cols])
results.append((label, eq_v7, m))
print(f" {label}: Ann={m['annualizedReturn']*100:.1f}%, Sharpe={m['sharpeRatio']:.2f}")
label, eq_rec, m = run_and_report(
"RecoveryMom Top10 (baseline)", RecoveryMomentumStrategy(top_n=10), stock_data[stock_tickers])
results.append((label, eq_rec, m))
print(f" {label}: Ann={m['annualizedReturn']*100:.1f}%, Sharpe={m['sharpeRatio']:.2f}")
# Ensembles with different splits
for v7_pct in (0.5, 0.6, 0.7, 0.8):
stock_pct = 1.0 - v7_pct
label = f"Ensemble {int(v7_pct*100)}% V7 + {int(stock_pct*100)}% RecMom"
eq = ensemble_equity([eq_v7, eq_rec], [v7_pct, stock_pct])
m = metrics.raw_summary(eq)
results.append((label, eq, m))
print(f" {label}: Ann={m['annualizedReturn']*100:.1f}%, Sharpe={m['sharpeRatio']:.2f}, MaxDD={m['maxDrawdown']*100:.1f}%")
# Also try V7+VT36 + V7+VT24 (low-vol variant) ensemble
label, eq_v7_lo, m = run_and_report(
"V7+VT24 (low-vol)", TrendRiderV7(target_vol=0.24, min_lev=0.50), etf_data[etf_cols])
results.append((label, eq_v7_lo, m))
eq_v7_duo = ensemble_equity([eq_v7, eq_v7_lo], [0.6, 0.4])
m = metrics.raw_summary(eq_v7_duo)
results.append(("Ensemble 60% V7-VT36 + 40% V7-VT24", eq_v7_duo, m))
print(f" V7-VT36/VT24 blend: Ann={m['annualizedReturn']*100:.1f}%, Sharpe={m['sharpeRatio']:.2f}, MaxDD={m['maxDrawdown']*100:.1f}%")
results.sort(key=lambda x: x[2]["sharpeRatio"], reverse=True)
print(f"\n--- Direction A Results (sorted by Sharpe) ---")
print_table(results)
return results
# =========================================================================
# DIRECTION B: Cross-market V7 (sector 3x ETFs)
# =========================================================================
def direction_b(stock_data, etf_data, stock_tickers, all_etfs):
print("\n" + "=" * 100)
print(" DIRECTION B: CROSS-MARKET V7 (SECTOR 3x ETFs)")
print("=" * 100)
results = []
# Baseline
etf_cols = [t for t in ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"] if t in etf_data.columns]
label, eq_v7, m = run_and_report(
"V7+VT36 SPY→TQQQ/UPRO (baseline)", TrendRiderV7(target_vol=0.36, min_lev=0.75), etf_data[etf_cols])
results.append((label, eq_v7, m))
print(f" {label}: Ann={m['annualizedReturn']*100:.1f}%")
# Sector V7 instances
sector_configs = [
("SMH→SOXL (Semiconductor)", "SMH", ("SOXL",)),
("XLK→TECL (Technology)", "XLK", ("TECL",)),
("IWM→TNA (Russell 2000)", "IWM", ("TNA",)),
("XLF→FAS (Financials)", "XLF", ("FAS",)),
]
sector_equities = {}
for desc, signal, risk_on in sector_configs:
needed = [signal] + list(risk_on) + ["GLD", "DBC", "SHY"]
available = [t for t in needed if t in etf_data.columns]
if signal not in available or not any(r in available for r in risk_on):
print(f" SKIP {desc}: missing data ({signal} or {risk_on})")
continue
risk_on_avail = tuple(r for r in risk_on if r in available)
strategy = TrendRiderV7(
signal=signal, risk_on=risk_on_avail, risk_off=("GLD", "DBC"),
target_vol=0.36, min_lev=0.75,
)
label = f"V7+VT36 {desc}"
try:
_, eq, m = run_and_report(label, strategy, etf_data[available])
results.append((label, eq, m))
sector_equities[desc] = eq
print(f" {label}: Ann={m['annualizedReturn']*100:.1f}%, Sharpe={m['sharpeRatio']:.2f}, MaxDD={m['maxDrawdown']*100:.1f}%")
except Exception as e:
print(f" FAILED {label}: {e}")
# Cross-market ensembles
if sector_equities:
# All sectors + SPY equal weight
all_eqs = [eq_v7] + list(sector_equities.values())
eq_all = ensemble_equity(all_eqs)
m = metrics.raw_summary(eq_all)
label = f"Equal-weight all {len(all_eqs)} V7 instances"
results.append((label, eq_all, m))
print(f" {label}: Ann={m['annualizedReturn']*100:.1f}%, Sharpe={m['sharpeRatio']:.2f}, MaxDD={m['maxDrawdown']*100:.1f}%")
# Best 2-3 combinations
if "SMH→SOXL (Semiconductor)" in sector_equities:
eq_spy_semi = ensemble_equity([eq_v7, sector_equities["SMH→SOXL (Semiconductor)"]], [0.5, 0.5])
m = metrics.raw_summary(eq_spy_semi)
results.append(("50% SPY-V7 + 50% SOXL-V7", eq_spy_semi, m))
print(f" SPY+SOXL combo: Ann={m['annualizedReturn']*100:.1f}%, Sharpe={m['sharpeRatio']:.2f}, MaxDD={m['maxDrawdown']*100:.1f}%")
eq_spy_semi_70 = ensemble_equity([eq_v7, sector_equities["SMH→SOXL (Semiconductor)"]], [0.7, 0.3])
m = metrics.raw_summary(eq_spy_semi_70)
results.append(("70% SPY-V7 + 30% SOXL-V7", eq_spy_semi_70, m))
if "XLK→TECL (Technology)" in sector_equities:
eq_spy_tech = ensemble_equity([eq_v7, sector_equities["XLK→TECL (Technology)"]], [0.5, 0.5])
m = metrics.raw_summary(eq_spy_tech)
results.append(("50% SPY-V7 + 50% TECL-V7", eq_spy_tech, m))
if len(sector_equities) >= 2:
# SPY + top 2 sectors
sorted_sectors = sorted(sector_equities.items(),
key=lambda x: metrics.raw_summary(x[1])["sharpeRatio"],
reverse=True)
top2 = sorted_sectors[:2]
eq_best3 = ensemble_equity([eq_v7] + [eq for _, eq in top2],
[0.5] + [0.25] * 2)
m = metrics.raw_summary(eq_best3)
label = f"50% SPY-V7 + 25% {top2[0][0][:4]}.. + 25% {top2[1][0][:4]}.."
results.append((label, eq_best3, m))
print(f" {label}: Ann={m['annualizedReturn']*100:.1f}%, Sharpe={m['sharpeRatio']:.2f}, MaxDD={m['maxDrawdown']*100:.1f}%")
results.sort(key=lambda x: x[2]["sharpeRatio"], reverse=True)
print(f"\n--- Direction B Results (sorted by Sharpe) ---")
print_table(results)
return results
# =========================================================================
# DIRECTION C: Improved regime engine
# =========================================================================
class V7AltRegime(Strategy):
"""V7 with pluggable regime function replacing V3._desired_regime."""
def __init__(
self,
regime_func,
signal: str = "SPY",
risk_on: tuple[str, ...] = ("TQQQ", "UPRO"),
risk_off: tuple[str, ...] = ("GLD", "DBC"),
target_vol: float = 0.36,
vol_window: int = 60,
min_lev: float = 0.75,
max_lev: float = 1.0,
pt_threshold: float = 0.30,
pt_band: float = 0.10,
pt_park: str = "SHY",
ma_long: int = 150,
mom_lookback: int = 63,
min_hold: int = 15,
):
self.regime_func = regime_func
self.signal = signal
self.risk_on = risk_on
self.risk_off = risk_off
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.mom_lookback = mom_lookback
self.min_hold = min_hold
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
cols = list({self.signal, *self.risk_on, *self.risk_off, self.pt_park})
cols = [c for c in cols if c in data.columns]
w = pd.DataFrame(0.0, index=data.index, columns=cols)
if self.signal not in data.columns:
return w
sig_arr = data[self.signal].to_numpy()
sym_arrays = {s: data[s].to_numpy() for s in cols if s in data.columns}
ron_syms = [s for s in self.risk_on if s in data.columns]
roff_syms = [s for s in self.risk_off if s in data.columns]
need = 252
regime: str | None = None
bars = 0
def pick_best(basket, t):
best_s, best_r = None, -np.inf
for s in basket:
arr = sym_arrays.get(s)
if arr is None or t < self.mom_lookback + 1:
continue
if arr[t-1] <= 0 or np.isnan(arr[t-1]) or arr[t - self.mom_lookback - 1] <= 0:
continue
r = arr[t-1] / arr[t - self.mom_lookback - 1] - 1.0
if np.isfinite(r) and r > best_r:
best_r, best_s = r, s
return best_s
for t in range(len(data)):
if t < need:
continue
closes = sig_arr[:t]
if np.isnan(closes[-1]):
continue
desired = self.regime_func(closes, regime)
changed = False
if regime is None:
regime, bars, changed = desired, 0, True
else:
bars += 1
if desired != regime and bars >= self.min_hold:
regime, bars, changed = desired, 0, True
if not changed and (t - need) % 21 != 0:
continue
basket = ron_syms if regime == "risk_on" else roff_syms
pick = pick_best(basket, t)
if pick:
w.iat[t, cols.index(pick)] = 1.0
w = w.replace(0.0, np.nan).ffill().fillna(0.0)
w = w.shift(1).fillna(0.0)
# Vol-target overlay
daily_ret = data[cols].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)
# Profit-take
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 = None
current_sym = 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, entry_price, is_stopped = None, None, 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
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
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
# Regime functions
def regime_ma(window: int):
"""Simple MA crossover: above MA → risk_on."""
def fn(closes, current):
if len(closes) < window:
return "risk_off"
return "risk_on" if closes[-1] > np.mean(closes[-window:]) else "risk_off"
return fn
def regime_dual_ma(short: int = 50, long: int = 200):
"""Golden/death cross: MA_short > MA_long → risk_on."""
def fn(closes, current):
if len(closes) < long:
return "risk_off"
ma_s = np.mean(closes[-short:])
ma_l = np.mean(closes[-long:])
return "risk_on" if ma_s > ma_l else "risk_off"
return fn
def regime_roc(window: int = 63):
"""Rate of change: positive N-day return → risk_on."""
def fn(closes, current):
if len(closes) < window + 1 or closes[-window-1] <= 0:
return "risk_off"
roc = closes[-1] / closes[-window-1] - 1.0
return "risk_on" if roc > 0 else "risk_off"
return fn
def regime_ma_plus_vol(ma_window: int = 150, vol_window: int = 20, vol_cap: float = 0.20):
"""MA + vol filter: above MA AND vol < cap → risk_on."""
def fn(closes, current):
if len(closes) < max(ma_window, vol_window + 1):
return "risk_off"
above_ma = closes[-1] > np.mean(closes[-ma_window:])
if not above_ma:
return "risk_off"
rets = np.diff(closes[-vol_window-1:]) / np.maximum(closes[-vol_window-1:-1], 1e-12)
vol = float(np.std(rets, ddof=1) * np.sqrt(252))
return "risk_on" if vol < vol_cap else "risk_off"
return fn
def regime_ma_slope(ma_window: int = 150, slope_window: int = 10):
"""MA + positive slope: above MA AND MA is rising → risk_on."""
def fn(closes, current):
if len(closes) < ma_window + slope_window:
return "risk_off"
ma_now = np.mean(closes[-ma_window:])
ma_prev = np.mean(closes[-ma_window - slope_window:-slope_window])
above = closes[-1] > ma_now
rising = ma_now > ma_prev
return "risk_on" if (above and rising) else "risk_off"
return fn
def regime_composite(ma_w: int = 150, roc_w: int = 63, vol_w: int = 20,
vol_cap: float = 0.22, threshold: int = 2):
"""Composite: score from MA + ROC + vol. Need ≥ threshold signals for risk_on."""
def fn(closes, current):
if len(closes) < max(ma_w, roc_w + 1, vol_w + 1):
return "risk_off"
score = 0
# Signal 1: above MA
if closes[-1] > np.mean(closes[-ma_w:]):
score += 1
# Signal 2: positive ROC
if closes[-roc_w-1] > 0 and closes[-1] / closes[-roc_w-1] - 1.0 > 0:
score += 1
# Signal 3: vol below cap
rets = np.diff(closes[-vol_w-1:]) / np.maximum(closes[-vol_w-1:-1], 1e-12)
vol = float(np.std(rets, ddof=1) * np.sqrt(252))
if vol < vol_cap:
score += 1
return "risk_on" if score >= threshold else "risk_off"
return fn
def regime_adaptive_ma(fast: int = 100, slow: int = 200, vol_w: int = 60,
vol_threshold: float = 0.18):
"""Adaptive MA: use fast MA in low vol, slow MA in high vol.
High vol → slower signal → fewer whipsaws."""
def fn(closes, current):
if len(closes) < slow:
return "risk_off"
rets = np.diff(closes[-vol_w-1:]) / np.maximum(closes[-vol_w-1:-1], 1e-12)
vol = float(np.std(rets, ddof=1) * np.sqrt(252))
ma_w = slow if vol > vol_threshold else fast
return "risk_on" if closes[-1] > np.mean(closes[-ma_w:]) else "risk_off"
return fn
def direction_c(stock_data, etf_data, stock_tickers, all_etfs):
print("\n" + "=" * 100)
print(" DIRECTION C: IMPROVED REGIME ENGINE")
print("=" * 100)
etf_cols = [t for t in ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"] if t in etf_data.columns]
results = []
# V7+VT36 baseline (uses V3's full regime with MA+vol+dd+peak gates)
label, eq, m = run_and_report(
"V7+VT36 (V3 full regime, baseline)", TrendRiderV7(target_vol=0.36, min_lev=0.75), etf_data[etf_cols])
results.append((label, eq, m))
print(f" {label}: Ann={m['annualizedReturn']*100:.1f}%, Sharpe={m['sharpeRatio']:.2f}")
regime_configs = [
# Simple MA variants
("Simple MA100", regime_ma(100)),
("Simple MA150", regime_ma(150)),
("Simple MA200", regime_ma(200)),
# Dual MA
("Dual MA 50/200", regime_dual_ma(50, 200)),
("Dual MA 50/150", regime_dual_ma(50, 150)),
("Dual MA 20/100", regime_dual_ma(20, 100)),
# ROC
("ROC 63d", regime_roc(63)),
("ROC 126d", regime_roc(126)),
# MA + vol filter
("MA150 + Vol<20%", regime_ma_plus_vol(150, 20, 0.20)),
("MA150 + Vol<25%", regime_ma_plus_vol(150, 20, 0.25)),
("MA200 + Vol<20%", regime_ma_plus_vol(200, 20, 0.20)),
# MA + slope
("MA150 + Rising (10d)", regime_ma_slope(150, 10)),
("MA150 + Rising (20d)", regime_ma_slope(150, 20)),
# Composite
("Composite 2/3 (MA150+ROC63+Vol)", regime_composite(150, 63, 20, 0.22, 2)),
("Composite 3/3 (all must agree)", regime_composite(150, 63, 20, 0.22, 3)),
# Adaptive MA
("Adaptive MA100/200 (vol pivot 18%)", regime_adaptive_ma(100, 200, 60, 0.18)),
("Adaptive MA100/200 (vol pivot 22%)", regime_adaptive_ma(100, 200, 60, 0.22)),
]
for label, regime_fn in regime_configs:
try:
strategy = V7AltRegime(regime_func=regime_fn)
_, eq, m = run_and_report(f"AltRegime: {label}", strategy, etf_data[etf_cols])
results.append((f"AltRegime: {label}", eq, m))
print(f" {label}: Ann={m['annualizedReturn']*100:.1f}%, Sharpe={m['sharpeRatio']:.2f}, MaxDD={m['maxDrawdown']*100:.1f}%")
except Exception as e:
print(f" FAILED {label}: {e}")
results.sort(key=lambda x: x[2]["sharpeRatio"], reverse=True)
print(f"\n--- Direction C Results (sorted by Sharpe) ---")
print_table(results)
return results
# =========================================================================
# MAIN
# =========================================================================
def main():
stock_data, etf_data, stock_tickers, all_etfs = load_all_data()
results_a = direction_a(stock_data, etf_data, stock_tickers, all_etfs)
results_b = direction_b(stock_data, etf_data, stock_tickers, all_etfs)
results_c = direction_c(stock_data, etf_data, stock_tickers, all_etfs)
# Final summary
print("\n" + "=" * 100)
print(" CROSS-DIRECTION SUMMARY")
print("=" * 100)
all_results = (
[(f"[A] {l}", eq, m) for l, eq, m in results_a] +
[(f"[B] {l}", eq, m) for l, eq, m in results_b] +
[(f"[C] {l}", eq, m) for l, eq, m in results_c]
)
all_results.sort(key=lambda x: x[2]["sharpeRatio"], reverse=True)
print(f"\nTop 10 by Sharpe across all directions:")
print(f"{'#':<4} {'Strategy':<60} {'Ann%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Calmar':>7}")
print("-" * 100)
for i, (label, _, m) in enumerate(all_results[:10], 1):
print(f"{i:<4} {label:<60} "
f"{m['annualizedReturn']*100:>6.1f}% "
f"{m['sharpeRatio']:>7.2f} "
f"{m['maxDrawdown']*100:>6.1f}% "
f"{m['calmarRatio']:>7.2f}")
print(f"\nTop 10 by Ann. Return across all directions:")
all_results.sort(key=lambda x: x[2]["annualizedReturn"], reverse=True)
print(f"{'#':<4} {'Strategy':<60} {'Ann%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Calmar':>7}")
print("-" * 100)
for i, (label, _, m) in enumerate(all_results[:10], 1):
print(f"{i:<4} {label:<60} "
f"{m['annualizedReturn']*100:>6.1f}% "
f"{m['sharpeRatio']:>7.2f} "
f"{m['maxDrawdown']*100:>6.1f}% "
f"{m['calmarRatio']:>7.2f}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,282 @@
"""Fixed re-run for Directions B and C based on review feedback.
Direction B fix: recalibrate V3 thresholds per-sector (scale by vol ratio).
Direction C fix: monkey-patch V3._desired_regime inside real V7, preserving
the full state machine (confirm_days, cooloff, stop_loss, dd_stop).
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
from main import backtest
from strategies.trend_rider_v7 import TrendRiderV7
YEARS = 10
CAPITAL = 100_000
TX_COST = 0.001
FIXED_FEE = 2.0
def load_etf_data():
all_etfs = sorted(set([
"SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY",
"SOXL", "SMH", "TECL", "XLK", "TNA", "IWM", "FAS", "XLF",
]))
data = data_manager.update("etfs", all_etfs, with_open=False)
if isinstance(data, tuple):
data = data[0]
cutoff = data.index[-1] - pd.DateOffset(years=YEARS)
return data[data.index >= cutoff]
def run(label, strategy, panel):
eq = backtest(strategy, panel, initial_capital=CAPITAL,
transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
m = metrics.raw_summary(eq)
print(f" {label:<55} Ann={m['annualizedReturn']*100:>5.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}% "
f"Sortino={m['sortinoRatio']:.2f} Calmar={m['calmarRatio']:.2f}")
return label, eq, m
# =========================================================================
# DIRECTION B FIX: per-sector calibrated thresholds
# =========================================================================
def direction_b_fixed(etf_data):
print("\n" + "=" * 100)
print(" DIRECTION B FIXED: Sector V7 with recalibrated thresholds")
print("=" * 100)
results = []
core = [t for t in ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"] if t in etf_data.columns]
# Baseline
r = run("V7+VT36 baseline (SPY→TQQQ/UPRO)", TrendRiderV7(target_vol=0.36, min_lev=0.75), etf_data[core])
results.append(r)
eq_v7 = r[1]
# Estimate vol ratios for threshold scaling
rets = etf_data.pct_change(fill_method=None).dropna()
spy_vol = rets["SPY"].std() * np.sqrt(252) if "SPY" in rets.columns else 0.18
print(f"\n SPY realized vol: {spy_vol:.1%}")
sector_configs = [
("SMH", ("SOXL",), "Semiconductor"),
("XLK", ("TECL",), "Technology"),
("IWM", ("TNA",), "Russell 2000"),
("XLF", ("FAS",), "Financials"),
]
sector_eqs = {}
for signal, risk_on, name in sector_configs:
if signal not in etf_data.columns or risk_on[0] not in etf_data.columns:
print(f" SKIP {name}: missing data")
continue
sig_vol = rets[signal].std() * np.sqrt(252) if signal in rets.columns else spy_vol
vol_ratio = sig_vol / spy_vol
print(f" {signal} vol: {sig_vol:.1%}, ratio to SPY: {vol_ratio:.2f}")
needed = [signal] + list(risk_on) + ["GLD", "DBC", "SHY"]
panel = etf_data[[t for t in needed if t in etf_data.columns]]
# Uncalibrated (original V3 thresholds)
v7_raw = TrendRiderV7(
signal=signal, risk_on=risk_on, risk_off=("GLD", "DBC"),
target_vol=0.36, min_lev=0.75,
)
r = run(f" {name} UNCALIBRATED", v7_raw, panel)
results.append(r)
# Calibrated: scale vol/dd/peak thresholds by vol ratio
v7_cal = TrendRiderV7(
signal=signal, risk_on=risk_on, risk_off=("GLD", "DBC"),
target_vol=0.36, min_lev=0.75,
# V3 thresholds scaled by sector vol ratio
vol_enter=0.14 * vol_ratio,
vol_exit=0.20 * vol_ratio,
dd_stop=0.05 * vol_ratio,
peak_enter=0.02 * vol_ratio,
peak_exit=0.05 * vol_ratio,
)
r = run(f" {name} CALIBRATED (×{vol_ratio:.1f})", v7_cal, panel)
results.append(r)
sector_eqs[name] = r[1]
# Ensembles with calibrated sectors
if sector_eqs:
print()
for name, sec_eq in sector_eqs.items():
for v7_pct in (0.5, 0.7):
idx = eq_v7.index.intersection(sec_eq.index)
v7_a = eq_v7.reindex(idx).ffill()
sec_a = sec_eq.reindex(idx).ffill()
ens = (v7_a / v7_a.iloc[0]) * v7_pct + (sec_a / sec_a.iloc[0]) * (1 - v7_pct)
ens = ens * CAPITAL
m = metrics.raw_summary(ens)
label = f" {int(v7_pct*100)}% SPY-V7 + {int((1-v7_pct)*100)}% {name[:8]}-V7 (cal)"
print(f" {label:<55} Ann={m['annualizedReturn']*100:>5.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}% "
f"Sortino={m['sortinoRatio']:.2f} Calmar={m['calmarRatio']:.2f}")
results.append((label, ens, m))
return results
# =========================================================================
# DIRECTION C FIX: inject alt regime into REAL V3 state machine
# =========================================================================
def direction_c_fixed(etf_data):
print("\n" + "=" * 100)
print(" DIRECTION C FIXED: Alt regimes inside real V3 state machine")
print("=" * 100)
core = [t for t in ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"] if t in etf_data.columns]
results = []
# Baseline
r = run("V7+VT36 (V3 full regime, baseline)", TrendRiderV7(target_vol=0.36, min_lev=0.75), etf_data[core])
results.append(r)
# Alt regimes: monkey-patch V3._desired_regime, preserving full FSM
def make_alt_v7(regime_fn, label):
v7 = TrendRiderV7(target_vol=0.36, min_lev=0.75)
v7.v3._desired_regime = regime_fn
return v7
# --- Simple MA variants ---
for window in (100, 150, 200, 250):
def regime_ma(closes, current, w=window):
if len(closes) < w:
return "risk_off"
return "risk_on" if closes[-1] > np.mean(closes[-w:]) else "risk_off"
r = run(f"Simple MA{window}", make_alt_v7(regime_ma, f"MA{window}"), etf_data[core])
results.append(r)
# --- Dual MA crossover ---
for short, long in ((50, 200), (50, 150), (20, 100)):
def regime_dual(closes, current, s=short, l=long):
if len(closes) < l:
return "risk_off"
return "risk_on" if np.mean(closes[-s:]) > np.mean(closes[-l:]) else "risk_off"
r = run(f"Dual MA {short}/{long}", make_alt_v7(regime_dual, ""), etf_data[core])
results.append(r)
# --- ROC variants ---
for window in (42, 63, 126):
def regime_roc(closes, current, w=window):
if len(closes) < w + 1 or closes[-w-1] <= 0:
return "risk_off"
return "risk_on" if closes[-1] / closes[-w-1] > 1.0 else "risk_off"
r = run(f"ROC {window}d", make_alt_v7(regime_roc, ""), etf_data[core])
results.append(r)
# --- MA + vol filter (simplified V3) ---
for ma_w, vol_cap in ((150, 0.20), (150, 0.25), (200, 0.22)):
def regime_mavol(closes, current, mw=ma_w, vc=vol_cap):
if len(closes) < max(mw, 21):
return "risk_off"
above = closes[-1] > np.mean(closes[-mw:])
if not above:
return "risk_off"
rets = np.diff(closes[-21:]) / np.maximum(closes[-21:-1], 1e-12)
vol = float(np.std(rets, ddof=1) * np.sqrt(252))
return "risk_on" if vol < vc else "risk_off"
r = run(f"MA{ma_w} + Vol<{int(vol_cap*100)}%", make_alt_v7(regime_mavol, ""), etf_data[core])
results.append(r)
# --- Composite (MA + ROC + vol) ---
for thresh in (2, 3):
def regime_comp(closes, current, t=thresh):
if len(closes) < 200:
return "risk_off"
score = 0
if closes[-1] > np.mean(closes[-150:]):
score += 1
if closes[-64] > 0 and closes[-1] / closes[-64] > 1.0:
score += 1
rets = np.diff(closes[-21:]) / np.maximum(closes[-21:-1], 1e-12)
if np.std(rets, ddof=1) * np.sqrt(252) < 0.22:
score += 1
return "risk_on" if score >= t else "risk_off"
r = run(f"Composite {thresh}/3", make_alt_v7(regime_comp, ""), etf_data[core])
results.append(r)
# --- MA + slope (MA must be rising) ---
for slope_w in (10, 20):
def regime_slope(closes, current, sw=slope_w):
if len(closes) < 150 + sw:
return "risk_off"
ma_now = np.mean(closes[-150:])
ma_prev = np.mean(closes[-150-sw:-sw])
return "risk_on" if (closes[-1] > ma_now and ma_now > ma_prev) else "risk_off"
r = run(f"MA150 + Rising({slope_w}d)", make_alt_v7(regime_slope, ""), etf_data[core])
results.append(r)
# --- Adaptive MA (fast in low vol, slow in high vol) ---
for pivot in (0.15, 0.18, 0.22):
def regime_adapt(closes, current, p=pivot):
if len(closes) < 200:
return "risk_off"
rets = np.diff(closes[-61:]) / np.maximum(closes[-61:-1], 1e-12)
vol = np.std(rets, ddof=1) * np.sqrt(252)
w = 200 if vol > p else 100
return "risk_on" if closes[-1] > np.mean(closes[-w:]) else "risk_off"
r = run(f"Adaptive MA (pivot={int(pivot*100)}%)", make_alt_v7(regime_adapt, ""), etf_data[core])
results.append(r)
# Sort and display
results.sort(key=lambda x: x[2]["sharpeRatio"], reverse=True)
print(f"\n--- Direction C FIXED Results (sorted by Sharpe) ---")
for i, (label, _, m) in enumerate(results, 1):
marker = "" if i <= 3 else ""
print(f" {i:<3} {label:<55} Ann={m['annualizedReturn']*100:>5.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}% "
f"Calmar={m['calmarRatio']:.2f}{marker}")
return results
def main():
print("=" * 100)
print(" V7 BREAKTHROUGH EVAL — FIXED RE-RUN (per review feedback)")
print("=" * 100)
etf_data = load_etf_data()
print(f"Period: {etf_data.index[0].date()}{etf_data.index[-1].date()}")
print(f"ETFs: {sorted(etf_data.columns.tolist())}")
results_b = direction_b_fixed(etf_data)
results_c = direction_c_fixed(etf_data)
# Cross-direction top 10
all_r = [(f"[B] {l}", eq, m) for l, eq, m in results_b] + \
[(f"[C] {l}", eq, m) for l, eq, m in results_c]
all_r.sort(key=lambda x: x[2]["sharpeRatio"], reverse=True)
print(f"\n{'=' * 100}")
print(" FINAL: Top 10 by Sharpe")
print(f"{'=' * 100}")
for i, (label, _, m) in enumerate(all_r[:10], 1):
print(f" {i:<3} {label:<60} Ann={m['annualizedReturn']*100:>5.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}% "
f"Calmar={m['calmarRatio']:.2f}")
all_r.sort(key=lambda x: x[2]["annualizedReturn"], reverse=True)
print(f"\n FINAL: Top 10 by Ann. Return")
print(f" {'-' * 95}")
for i, (label, _, m) in enumerate(all_r[:10], 1):
print(f" {i:<3} {label:<60} Ann={m['annualizedReturn']*100:>5.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}% "
f"Calmar={m['calmarRatio']:.2f}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,401 @@
"""Literature-informed alpha research: can we beat V7+VT36?
Grounded in specific academic/industry research:
1. VIX regime overlay — Simon & Campasano (2014): VIX level as exogenous fear signal
2. Kelly-optimal sizing — Kelly (1956), Thorp (2006): return-aware position sizing
3. Multi-timeframe voting — Faber (2007): multiple MAs reduce false signals
4. Cross-asset confirmation — Asness et al. (2013): correlated asset agreement
5. Momentum acceleration — Moskowitz et al. (2012): 2nd derivative of trend
6. VIX mean-reversion entry — Whaley (2009): buy panic, sell complacency
7. Carry-enhanced risk-off — Koijen et al. (2018): hold yield during defensive periods
8. Regime-dependent PT — Optimal stopping theory: vol-drag-aware thresholds
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
from strategies.base import Strategy
from strategies.permanent import TrendRiderV3
from strategies.trend_rider_v7 import TrendRiderV7
from main import backtest
YEARS = 10
CAPITAL = 100_000
TX_COST = 0.001
FIXED_FEE = 2.0
class V7Enhanced(Strategy):
"""V7 with pluggable regime enhancer and sizing model."""
def __init__(
self,
regime_enhancer=None,
sizing_model="vol_target",
pt_model="fixed",
target_vol=0.36, min_lev=0.75, max_lev=1.0,
pt_threshold=0.30, pt_band=0.10, pt_park="SHY",
ma_long=150, **v3_kw,
):
self.regime_enhancer = regime_enhancer
self.sizing_model = sizing_model
self.pt_model = pt_model
self.target_vol = target_vol
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="SPY", risk_on=("TQQQ", "UPRO"), risk_off=("GLD", "DBC"),
ma_long=ma_long, **v3_kw,
)
def generate_signals(self, data):
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
# Regime enhancement: override V3's decision in specific conditions
if self.regime_enhancer:
w = self.regime_enhancer(w, data)
# Sizing
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
common = w.columns.intersection(daily_ret.columns)
port_rets = (w[common] * daily_ret[common]).sum(axis=1)
if self.sizing_model == "kelly":
# Kelly: scale = E[r] / Var[r], clipped
roll_mean = port_rets.rolling(60, min_periods=21).mean() * 252
roll_var = port_rets.rolling(60, min_periods=21).var() * 252
kelly_f = (roll_mean / roll_var.clip(lower=0.01)).clip(-1, 2)
scale = kelly_f.clip(lower=self.min_lev, upper=self.max_lev)
scale = scale.shift(1).fillna(1.0)
else:
realized_vol = port_rets.rolling(60, 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)
# Profit-take
if self.pt_threshold <= 0:
return w
risk_on_set = set(self.v3.risk_on)
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 ""
ep, cs, stopped = None, None, False
rl = self.pt_threshold - self.pt_band
if self.pt_model == "vol_adaptive":
# PT threshold inversely proportional to vol drag
# Vol drag ≈ leverage² × σ² / 2; for 3x: 9σ²/2
# Optimal PT ≈ base / (1 + k * σ²)
realized_vol_arr = port_rets.rolling(60, min_periods=21).std().to_numpy() * np.sqrt(252)
for i in range(len(w)):
sym = held.iloc[i]
if not sym or max_w.iloc[i] < 1e-8:
cs, ep, stopped = None, None, False
continue
if sym != cs:
cs = sym
ep = float(data[sym].iloc[i-1]) if i > 0 and sym in data.columns else None
stopped = False
continue
if sym not in risk_on_set:
continue
if ep is None or ep <= 0 or sym not in data.columns:
continue
y = float(data[sym].iloc[i-1]) if i > 0 else float(data[sym].iloc[i])
g = y / ep - 1.0
if self.pt_model == "vol_adaptive":
rv = realized_vol_arr[i] if i < len(realized_vol_arr) and not np.isnan(realized_vol_arr[i]) else 0.25
# Higher vol → lower threshold (take profits faster)
t = self.pt_threshold * (0.25 / max(rv, 0.10))
t = np.clip(t, 0.15, 0.50)
r = t * (1 - self.pt_band / self.pt_threshold)
else:
t = self.pt_threshold
r = rl
if stopped:
if g < r: stopped = False
else:
w.iloc[i] = 0.0
if park_col: w.at[w.index[i], park_col] = scale.iloc[i]
elif g >= t:
stopped = True
w.iloc[i] = 0.0
if park_col: w.at[w.index[i], park_col] = scale.iloc[i]
return w
# =========================================================================
# Regime enhancers
# =========================================================================
def vix_overlay(vix_high=25, vix_low=15):
"""Force risk-off when VIX > threshold. Simon & Campasano (2014)."""
def enhancer(w, data):
if "^VIX" not in data.columns:
return w
vix = data["^VIX"].shift(1).fillna(20)
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
park = "SHY" if "SHY" in w.columns else ""
for i in range(len(w)):
v = vix.iloc[i]
if np.isnan(v): continue
ron_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_on_cols)
if ron_w > 0.01 and v > vix_high:
for c in risk_on_cols:
w.iat[i, w.columns.get_loc(c)] = 0.0
if risk_off_cols:
w.iat[i, w.columns.get_loc(risk_off_cols[0])] = ron_w
return w
return enhancer
def multi_timeframe(windows=(50, 150, 200), min_agree=2):
"""Multi-MA voting. Faber (2007). Need majority of MAs bullish."""
def enhancer(w, data):
if "SPY" not in data.columns:
return w
spy = data["SPY"]
votes = pd.DataFrame(index=data.index)
for win in windows:
ma = spy.rolling(win).mean()
votes[f"ma{win}"] = (spy > ma).astype(int)
total_votes = votes.sum(axis=1).shift(2) # PIT: shift 2 to match V3
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
for i in range(len(w)):
ron_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_on_cols)
if ron_w > 0.01 and total_votes.iloc[i] < min_agree:
for c in risk_on_cols:
w.iat[i, w.columns.get_loc(c)] = 0.0
if risk_off_cols:
w.iat[i, w.columns.get_loc(risk_off_cols[0])] = ron_w
return w
return enhancer
def cross_asset_confirm():
"""Require both SPY and QQQ trends to agree. Asness et al. (2013)."""
def enhancer(w, data):
if "SPY" not in data.columns or "QQQ" not in data.columns:
return w
spy_bull = (data["SPY"] > data["SPY"].rolling(150).mean()).shift(2).fillna(False)
qqq_bull = (data["QQQ"] > data["QQQ"].rolling(150).mean()).shift(2).fillna(False)
both_bull = spy_bull & qqq_bull
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
for i in range(len(w)):
ron_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_on_cols)
if ron_w > 0.01 and not both_bull.iloc[i]:
for c in risk_on_cols:
w.iat[i, w.columns.get_loc(c)] = 0.0
if risk_off_cols:
w.iat[i, w.columns.get_loc(risk_off_cols[0])] = ron_w
return w
return enhancer
def momentum_accel(accel_window=20):
"""Only risk-on when trend is accelerating. Moskowitz et al. (2012)."""
def enhancer(w, data):
if "SPY" not in data.columns:
return w
spy = data["SPY"]
ma150 = spy.rolling(150).mean()
ma_slope = ma150.diff(accel_window)
accel_positive = (ma_slope > 0).shift(2).fillna(False)
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
for i in range(len(w)):
ron_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_on_cols)
if ron_w > 0.01 and not accel_positive.iloc[i]:
for c in risk_on_cols:
w.iat[i, w.columns.get_loc(c)] = 0.0
if risk_off_cols:
w.iat[i, w.columns.get_loc(risk_off_cols[0])] = ron_w
return w
return enhancer
def vix_mean_revert_entry(vix_spike=30, lookback=5):
"""After VIX spike + revert, force risk-on. Whaley (2009) mean-reversion."""
def enhancer(w, data):
if "^VIX" not in data.columns:
return w
vix = data["^VIX"].shift(1).fillna(20)
vix_was_high = vix.rolling(lookback).max() > vix_spike
vix_now_falling = vix < vix.rolling(lookback).mean()
buy_signal = vix_was_high & vix_now_falling
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
for i in range(len(w)):
roff_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_off_cols)
if roff_w > 0.01 and buy_signal.iloc[i]:
for c in risk_off_cols:
w.iat[i, w.columns.get_loc(c)] = 0.0
if risk_on_cols:
w.iat[i, w.columns.get_loc(risk_on_cols[0])] = roff_w
return w
return enhancer
def combined_enhancer(*enhancers):
"""Chain multiple enhancers."""
def enhancer(w, data):
for e in enhancers:
w = e(w, data)
return w
return enhancer
# =========================================================================
# Main
# =========================================================================
def main():
print("=" * 100)
print(" LITERATURE-INFORMED ALPHA RESEARCH")
print("=" * 100)
all_etfs = sorted(set([
"SPY", "QQQ", "TQQQ", "UPRO", "GLD", "DBC", "SHY", "TLT",
"^VIX",
]))
data = data_manager.update("etfs", all_etfs, with_open=False)
if isinstance(data, tuple):
data = data[0]
cutoff = data.index[-1] - pd.DateOffset(years=YEARS)
data = data[data.index >= cutoff]
has_vix = "^VIX" in data.columns
has_qqq = "QQQ" in data.columns
print(f"Period: {data.index[0].date()}{data.index[-1].date()}")
print(f"VIX available: {has_vix}, QQQ available: {has_qqq}")
results = []
def run(label, strategy):
eq = backtest(strategy, data, initial_capital=CAPITAL,
transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
m = metrics.raw_summary(eq)
results.append((label, m))
print(f" {label:<55} Ann={m['annualizedReturn']*100:>5.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}% "
f"Calmar={m['calmarRatio']:.2f}")
# Baseline
print("\n--- Baseline ---")
run("V7+VT36 baseline", V7Enhanced())
# === Idea 1: VIX overlay ===
print("\n--- Idea 1: VIX regime overlay (Simon & Campasano 2014) ---")
if has_vix:
for hi in (20, 25, 30):
run(f"VIX overlay (force off >VIX{hi})", V7Enhanced(regime_enhancer=vix_overlay(hi)))
else:
print(" VIX not available")
# === Idea 2: Kelly sizing ===
print("\n--- Idea 2: Kelly-optimal sizing (Kelly 1956, Thorp 2006) ---")
run("Kelly sizing", V7Enhanced(sizing_model="kelly"))
run("Kelly + VIX>25", V7Enhanced(sizing_model="kelly",
regime_enhancer=vix_overlay(25) if has_vix else None))
# === Idea 3: Multi-timeframe voting ===
print("\n--- Idea 3: Multi-MA voting (Faber 2007) ---")
run("Multi-MA 2/3 (50,150,200)", V7Enhanced(regime_enhancer=multi_timeframe()))
run("Multi-MA 3/3 (all agree)", V7Enhanced(regime_enhancer=multi_timeframe(min_agree=3)))
# === Idea 4: Cross-asset confirmation ===
print("\n--- Idea 4: Cross-asset (Asness et al. 2013) ---")
if has_qqq:
run("SPY+QQQ both bullish", V7Enhanced(regime_enhancer=cross_asset_confirm()))
# === Idea 5: Momentum acceleration ===
print("\n--- Idea 5: Momentum acceleration (Moskowitz et al. 2012) ---")
for w in (10, 20, 40):
run(f"MA150 slope rising ({w}d)", V7Enhanced(regime_enhancer=momentum_accel(w)))
# === Idea 6: VIX mean-reversion entry ===
print("\n--- Idea 6: VIX mean-reversion entry (Whaley 2009) ---")
if has_vix:
for spike in (25, 30, 35):
run(f"VIX spike>{spike} + revert → buy",
V7Enhanced(regime_enhancer=vix_mean_revert_entry(spike)))
# === Idea 7: Vol-adaptive PT ===
print("\n--- Idea 7: Vol-drag-aware PT (optimal stopping theory) ---")
run("Vol-adaptive PT (base=30%)", V7Enhanced(pt_model="vol_adaptive"))
run("Vol-adaptive PT (base=35%)", V7Enhanced(pt_model="vol_adaptive", pt_threshold=0.35))
# === Idea 8: Combined best ideas ===
print("\n--- Idea 8: Combinations ---")
if has_vix:
run("VIX>25 + multi-MA 2/3",
V7Enhanced(regime_enhancer=combined_enhancer(
vix_overlay(25), multi_timeframe())))
run("VIX>25 + cross-asset",
V7Enhanced(regime_enhancer=combined_enhancer(
vix_overlay(25), cross_asset_confirm())) if has_qqq else None)
run("VIX>30 + accel(20d)",
V7Enhanced(regime_enhancer=combined_enhancer(
vix_overlay(30), momentum_accel(20))))
# VIX mean-revert + normal V3
run("V7 + VIX mean-revert entry (>30)",
V7Enhanced(regime_enhancer=vix_mean_revert_entry(30)))
# === Idea 9: Different MA for V3 regime ===
print("\n--- Idea 9: Alternative MA windows ---")
for ma in (100, 120, 130, 150, 170, 200):
run(f"V3 MA{ma} + VT36", V7Enhanced(ma_long=ma))
# Final ranking
results.sort(key=lambda x: x[1]["sharpeRatio"], reverse=True)
print(f"\n{'=' * 110}")
print(" FINAL RANKING (by Sharpe)")
print(f"{'=' * 110}")
print(f"{'#':<4} {'Strategy':<55} {'Ann%':>6} {'Vol%':>6} {'Sharpe':>7} "
f"{'Sortino':>8} {'MaxDD%':>7} {'Calmar':>7}")
print("-" * 110)
for i, (label, m) in enumerate(results, 1):
marker = "" if i <= 3 else ""
print(f"{i:<4} {label:<55} "
f"{m['annualizedReturn']*100:>5.1f}% "
f"{m['annualizedVolatility']*100:>5.1f}% "
f"{m['sharpeRatio']:>7.2f} {m['sortinoRatio']:>8.2f} "
f"{m['maxDrawdown']*100:>6.1f}% {m['calmarRatio']:>7.2f}{marker}")
print(f"{'=' * 110}")
# Top by Ann Return
results.sort(key=lambda x: x[1]["annualizedReturn"], reverse=True)
print(f"\n Top 5 by Ann Return:")
for i, (label, m) in enumerate(results[:5], 1):
print(f" {i}. {label:<50} Ann={m['annualizedReturn']*100:.1f}% "
f"Sharpe={m['sharpeRatio']:.2f}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,382 @@
"""V7 parameter sweep: vol-target range + adaptive profit-take variants.
Direction 1: higher vol-target (VT24 → VT48)
Direction 3: adaptive profit-take (vol-scaled, time-decay, combined)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
from main import backtest
from strategies.base import Strategy
from strategies.permanent import TrendRiderV3
YEARS = 10
CAPITAL = 100_000
TX_COST = 0.001
FIXED_FEE = 2.0
ETF_TICKERS = ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"]
# ---------------------------------------------------------------------------
# Adaptive V7: modular profit-take that accepts a callable threshold
# ---------------------------------------------------------------------------
class TrendRiderV7Adaptive(Strategy):
"""V7 with pluggable profit-take logic.
pt_func(gain, realized_vol, days_held) -> (threshold, restore_level)
If pt_func is None, no profit-take is applied.
"""
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.28,
vol_window: int = 60,
min_lev: float = 0.6,
max_lev: float = 1.0,
pt_func=None,
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_func = pt_func
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:
w = self.v3.generate_signals(data)
# 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)
if self.pt_func is None:
return w
# Adaptive profit-take
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
days_held = 0
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
days_held = 0
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
days_held = 0
continue
days_held += 1
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
rv = float(realized_vol.iloc[i]) if not np.isnan(realized_vol.iloc[i]) else 0.25
threshold, restore_level = self.pt_func(gain, rv, days_held)
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 >= threshold:
is_stopped = True
w.iloc[i] = 0.0
if park_col:
w.at[w.index[i], park_col] = scale.iloc[i]
return w
# ---------------------------------------------------------------------------
# Profit-take function factories
# ---------------------------------------------------------------------------
def fixed_pt(threshold: float, band: float):
"""Classic fixed threshold (V7 default)."""
def fn(gain, rv, days_held):
return threshold, threshold - band
return fn
def vol_adaptive_pt(base_threshold: float = 0.30, base_vol: float = 0.25,
band_ratio: float = 0.33, lo: float = 0.15, hi: float = 0.50):
"""Threshold scales inversely with realized vol.
High vol → lower threshold (harvest earlier, vol drag is worse).
Low vol → higher threshold (let profits run, drag is mild).
"""
def fn(gain, rv, days_held):
rv = max(rv, 0.05)
t = np.clip(base_threshold * (base_vol / rv), lo, hi)
return t, t * (1 - band_ratio)
return fn
def time_decay_pt(start_threshold: float = 0.40, end_threshold: float = 0.18,
decay_days: int = 120, band_ratio: float = 0.33):
"""Threshold decays linearly over holding period.
Rationale: longer holds accumulate more vol drag → take profits earlier.
"""
def fn(gain, rv, days_held):
frac = min(days_held / decay_days, 1.0)
t = start_threshold - frac * (start_threshold - end_threshold)
return t, t * (1 - band_ratio)
return fn
def combined_pt(base_threshold: float = 0.30, base_vol: float = 0.25,
time_decay_rate: float = 0.0005, min_threshold: float = 0.12,
max_threshold: float = 0.50, band_ratio: float = 0.33):
"""Vol-adaptive + time decay combined."""
def fn(gain, rv, days_held):
rv = max(rv, 0.05)
vol_adj = base_threshold * (base_vol / rv)
time_adj = vol_adj - days_held * time_decay_rate
t = np.clip(time_adj, min_threshold, max_threshold)
return t, t * (1 - band_ratio)
return fn
def trailing_stop_pt(initial_threshold: float = 0.30, trail_pct: float = 0.15,
band_ratio: float = 0.33):
"""Once gain exceeds threshold, switch to trailing stop from peak gain.
Lets winners run further but protects from reversal.
"""
# We need state across calls, so use a mutable closure
state = {"peak_gain": 0.0, "trailing_active": False}
def fn(gain, rv, days_held):
if days_held == 1:
state["peak_gain"] = 0.0
state["trailing_active"] = False
if state["trailing_active"]:
state["peak_gain"] = max(state["peak_gain"], gain)
trail_level = state["peak_gain"] * (1 - trail_pct)
if gain < trail_level:
return -1.0, -1.0 # trigger immediately
return float("inf"), float("inf") # don't trigger via threshold
else:
if gain >= initial_threshold:
state["trailing_active"] = True
state["peak_gain"] = gain
return float("inf"), float("inf")
return initial_threshold, initial_threshold * (1 - band_ratio)
return fn
# ---------------------------------------------------------------------------
# Main sweep
# ---------------------------------------------------------------------------
def main():
print("=" * 100)
print(" V7 PARAMETER SWEEP: Vol-Target + Adaptive Profit-Take")
print("=" * 100)
# Load ETF data
print("\n[1] Loading ETF data...")
etf_data = data_manager.update("etfs", ETF_TICKERS, with_open=False)
if isinstance(etf_data, tuple):
etf_data = etf_data[0]
cutoff = etf_data.index[-1] - pd.DateOffset(years=YEARS)
etf_data = etf_data[etf_data.index >= cutoff]
tradable = [t for t in ETF_TICKERS if t in etf_data.columns]
print(f" Period: {etf_data.index[0].date()}{etf_data.index[-1].date()}")
results: list[tuple[str, str, dict]] = []
def run(group: str, label: str, strategy: Strategy):
eq = backtest(strategy, etf_data[tradable], initial_capital=CAPITAL,
transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
m = metrics.raw_summary(eq)
results.append((group, label, m))
print(f" {label:<45} Ann={m['annualizedReturn']*100:.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}%")
# =====================================================================
# SWEEP 1: Vol-target range (with fixed PT30)
# =====================================================================
print("\n[2] Sweep 1: Vol-target range (PT30 fixed)")
print("-" * 70)
vt_configs = [
("VT20", 0.20, 0.45),
("VT24", 0.24, 0.50),
("VT28 (default)", 0.28, 0.60),
("VT32", 0.32, 0.70),
("VT36", 0.36, 0.75),
("VT40", 0.40, 0.80),
("VT44", 0.44, 0.85),
("VT48", 0.48, 0.90),
("No VT (raw V3+PT30)", 0.28, 1.0), # min_lev=max_lev=1.0 → no scaling
]
for label, tv, ml in vt_configs:
if label.startswith("No VT"):
s = TrendRiderV7Adaptive(target_vol=1.0, min_lev=1.0, max_lev=1.0,
pt_func=fixed_pt(0.30, 0.10))
else:
s = TrendRiderV7Adaptive(target_vol=tv, min_lev=ml,
pt_func=fixed_pt(0.30, 0.10))
run("VT sweep", label, s)
# =====================================================================
# SWEEP 2: Profit-take variants (using best VT from sweep 1)
# =====================================================================
print("\n[3] Sweep 2: Profit-take variants (VT32)")
print("-" * 70)
best_vt = 0.32
best_ml = 0.70
pt_configs: list[tuple[str, object]] = [
# Fixed thresholds
("No PT (ablation)", None),
("Fixed PT15 band=5", fixed_pt(0.15, 0.05)),
("Fixed PT20 band=8", fixed_pt(0.20, 0.08)),
("Fixed PT25 band=10", fixed_pt(0.25, 0.10)),
("Fixed PT30 band=10 (default)", fixed_pt(0.30, 0.10)),
("Fixed PT35 band=12", fixed_pt(0.35, 0.12)),
("Fixed PT40 band=15", fixed_pt(0.40, 0.15)),
("Fixed PT50 band=15", fixed_pt(0.50, 0.15)),
# Vol-adaptive
("Vol-adaptive (base=30%, lo=15%)", vol_adaptive_pt(0.30, 0.25, 0.33, 0.15, 0.50)),
("Vol-adaptive (base=25%, lo=12%)", vol_adaptive_pt(0.25, 0.25, 0.33, 0.12, 0.45)),
("Vol-adaptive (base=35%, lo=18%)", vol_adaptive_pt(0.35, 0.25, 0.33, 0.18, 0.55)),
# Time-decay
("Time-decay (40%→18%, 120d)", time_decay_pt(0.40, 0.18, 120)),
("Time-decay (35%→15%, 90d)", time_decay_pt(0.35, 0.15, 90)),
("Time-decay (45%→20%, 150d)", time_decay_pt(0.45, 0.20, 150)),
# Combined
("Combined vol+time (base=30%)", combined_pt(0.30, 0.25, 0.0005, 0.12, 0.50)),
("Combined vol+time (base=25%)", combined_pt(0.25, 0.25, 0.0005, 0.10, 0.45)),
]
for label, pt_fn in pt_configs:
s = TrendRiderV7Adaptive(target_vol=best_vt, min_lev=best_ml,
pt_func=pt_fn)
run("PT sweep", label, s)
# =====================================================================
# SWEEP 3: Best PT × VT grid (narrow search around top combos)
# =====================================================================
print("\n[4] Sweep 3: Best combos (VT × PT grid)")
print("-" * 70)
grid = [
(0.32, 0.70, "Fixed PT30", fixed_pt(0.30, 0.10)),
(0.36, 0.75, "Fixed PT30", fixed_pt(0.30, 0.10)),
(0.40, 0.80, "Fixed PT30", fixed_pt(0.30, 0.10)),
(0.32, 0.70, "Fixed PT25", fixed_pt(0.25, 0.10)),
(0.36, 0.75, "Fixed PT25", fixed_pt(0.25, 0.10)),
(0.40, 0.80, "Fixed PT25", fixed_pt(0.25, 0.10)),
(0.32, 0.70, "Vol-adapt 30%", vol_adaptive_pt(0.30, 0.25, 0.33, 0.15, 0.50)),
(0.36, 0.75, "Vol-adapt 30%", vol_adaptive_pt(0.30, 0.25, 0.33, 0.15, 0.50)),
(0.40, 0.80, "Vol-adapt 30%", vol_adaptive_pt(0.30, 0.25, 0.33, 0.15, 0.50)),
(0.32, 0.70, "Time-decay 40→18", time_decay_pt(0.40, 0.18, 120)),
(0.36, 0.75, "Time-decay 40→18", time_decay_pt(0.40, 0.18, 120)),
(0.40, 0.80, "Time-decay 40→18", time_decay_pt(0.40, 0.18, 120)),
(0.32, 0.70, "Combined 30%", combined_pt(0.30, 0.25, 0.0005, 0.12, 0.50)),
(0.36, 0.75, "Combined 30%", combined_pt(0.30, 0.25, 0.0005, 0.12, 0.50)),
(0.40, 0.80, "Combined 30%", combined_pt(0.30, 0.25, 0.0005, 0.12, 0.50)),
]
for tv, ml, pt_label, pt_fn in grid:
label = f"VT{int(tv*100)} + {pt_label}"
s = TrendRiderV7Adaptive(target_vol=tv, min_lev=ml, pt_func=pt_fn)
run("Grid", label, s)
# =====================================================================
# Final ranking
# =====================================================================
results.sort(key=lambda x: x[2]["annualizedReturn"], reverse=True)
print(f"\n{'=' * 115}")
print(" FINAL RANKING (sorted by annualized return)")
print(f"{'=' * 115}")
print(f"{'#':<4} {'Group':<12} {'Config':<45} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} "
f"{'Sortino':>8} {'MaxDD%':>7} {'Calmar':>7}")
print("-" * 115)
for i, (group, label, m) in enumerate(results, 1):
ann = m["annualizedReturn"] * 100
vol = m["annualizedVolatility"] * 100
sr = m["sharpeRatio"]
so = m["sortinoRatio"]
dd = m["maxDrawdown"] * 100
ca = m["calmarRatio"]
marker = "" if i <= 3 else ""
print(f"{i:<4} {group:<12} {label:<45} {ann:>6.1f}% {vol:>6.1f}% {sr:>7.2f} "
f"{so:>8.2f} {dd:>6.1f}% {ca:>7.2f}{marker}")
print(f"{'=' * 115}")
# Highlight top by Sharpe
by_sharpe = sorted(results, key=lambda x: x[2]["sharpeRatio"], reverse=True)
print("\nTop 5 by Sharpe:")
for i, (group, label, m) in enumerate(by_sharpe[:5], 1):
print(f" {i}. {label:<45} Sharpe={m['sharpeRatio']:.3f} "
f"Ann={m['annualizedReturn']*100:.1f}% MaxDD={m['maxDrawdown']*100:.1f}%")
by_calmar = sorted(results, key=lambda x: x[2]["calmarRatio"], reverse=True)
print("\nTop 5 by Calmar:")
for i, (group, label, m) in enumerate(by_calmar[:5], 1):
print(f" {i}. {label:<45} Calmar={m['calmarRatio']:.3f} "
f"Ann={m['annualizedReturn']*100:.1f}% MaxDD={m['maxDrawdown']*100:.1f}%")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,461 @@
"""Direction 2: V7 regime + synthetic 2x/3x leveraged individual stocks.
Hypothesis: replacing TQQQ/UPRO with synthetic 2x-leveraged top-momentum
S&P 500 stocks could beat V7 by combining stock-picking alpha with leverage.
Synthetic leverage model:
daily_return_Nx = N * stock_daily_return - (N-1) * daily_borrow_cost
daily_borrow_cost ≈ risk_free_rate / 252 (conservative: 5% annualized)
This captures:
- Leverage amplification
- Financing cost
- Volatility drag (emerges naturally from daily compounding of leveraged returns)
Variants tested:
A. V7 regime + synth 2x top-5 momentum stocks
B. V7 regime + synth 2x top-10 momentum stocks
C. V7 regime + synth 2x top-1 momentum stock (concentrated)
D. V7 regime + synth 3x top-5 (compare to real TQQQ)
E. V7 regime + synth 2x recovery-momentum top-5
F. V7+VT36 baseline (current SOTA)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
import universe_history as uh
from main import backtest
from strategies.base import Strategy
from strategies.permanent import TrendRiderV3
from universe import UNIVERSES
YEARS = 10
CAPITAL = 100_000
TX_COST = 0.001
FIXED_FEE = 2.0
BORROW_RATE = 0.05 # 5% annualized
# ---------------------------------------------------------------------------
# Synthetic leveraged returns
# ---------------------------------------------------------------------------
def synthetic_leveraged_prices(prices: pd.DataFrame, leverage: float,
borrow_rate: float = BORROW_RATE) -> pd.DataFrame:
"""Create synthetic leveraged price series from daily returns.
Models daily-rebalanced leverage: each day's return is
r_lev = leverage * r_stock - (leverage - 1) * r_borrow
where r_borrow = borrow_rate / 252.
This captures vol drag naturally (daily compounding of amplified returns).
"""
daily_ret = prices.pct_change(fill_method=None).fillna(0.0)
daily_borrow = borrow_rate / 252
lev_ret = leverage * daily_ret - (leverage - 1) * daily_borrow
lev_prices = (1 + lev_ret).cumprod() * 100 # normalize to 100 start
lev_prices.iloc[0] = 100
return lev_prices
# ---------------------------------------------------------------------------
# Strategy: V7 regime + synthetic leveraged stock picking
# ---------------------------------------------------------------------------
class V7SynthLeverage(Strategy):
"""V7 architecture with synthetic leveraged individual stocks as risk-on.
Layer 1: V3 regime engine on SPY → risk-on vs risk-off
Layer 2: Vol-target overlay
Layer 3: Profit-take with hysteresis
Risk-on: top-N stocks by momentum, synthetically leveraged, equal weight.
Risk-off: momentum leader of (GLD, DBC).
"""
def __init__(
self,
stock_tickers: list[str],
leverage: float = 2.0,
top_n: int = 5,
signal: str = "SPY",
defensive: tuple[str, ...] = ("GLD", "DBC"),
# Momentum ranking
mom_lookback: int = 63,
rebal_every: int = 21,
# Selection method
selection: str = "momentum", # "momentum" or "recovery_momentum"
recovery_window: int = 63,
long_mom_lookback: int = 252,
long_mom_skip: int = 21,
# V3 regime
ma_long: int = 150,
# Vol-target
target_vol: float = 0.36,
vol_window: int = 60,
min_lev: float = 0.75,
max_lev: float = 1.0,
# Profit-take
pt_threshold: float = 0.30,
pt_band: float = 0.10,
pt_park: str = "SHY",
):
self.stock_tickers = stock_tickers
self.leverage = leverage
self.top_n = top_n
self.signal = signal
self.defensive = defensive
self.mom_lookback = mom_lookback
self.rebal_every = rebal_every
self.selection = selection
self.recovery_window = recovery_window
self.long_mom_lookback = long_mom_lookback
self.long_mom_skip = long_mom_skip
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=("TQQQ", "UPRO"),
risk_off=defensive, ma_long=ma_long,
)
def _rank_stocks(self, data: pd.DataFrame) -> pd.DataFrame:
"""Return cross-sectional rank (higher = better)."""
avail = [t for t in self.stock_tickers if t in data.columns]
panel = data[avail]
if self.selection == "recovery_momentum":
recovery = panel / panel.rolling(self.recovery_window).min() - 1
momentum = panel.shift(self.long_mom_skip).pct_change(
self.long_mom_lookback - self.long_mom_skip, fill_method=None,
)
rec_r = recovery.rank(axis=1, pct=True, na_option="keep")
mom_r = momentum.rank(axis=1, pct=True, na_option="keep")
composite = 0.5 * rec_r + 0.5 * mom_r
return composite
else:
mom = panel.pct_change(self.mom_lookback, fill_method=None)
return mom
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
"""Build weights on ORIGINAL (unleveraged) price columns.
The backtest engine will track returns using the original data.
We transform the returns in a wrapper (see run_synth_backtest below).
Actually — we build a SYNTHETIC price panel and run the strategy
on that. So weights here are on synthetic-leverage columns.
"""
# This is called on the synthetic data panel.
# Columns: stock tickers (synthetic leveraged) + ETFs (original)
w = pd.DataFrame(0.0, index=data.index, columns=data.columns)
if self.signal not in data.columns:
return w
sig_arr = data[self.signal].to_numpy()
avail_stocks = [t for t in self.stock_tickers if t in data.columns]
avail_def = [t for t in self.defensive if t in data.columns]
park_col = self.pt_park if self.pt_park in data.columns else ""
# Rank using the ORIGINAL unleveraged data — NOT passed here.
# We'll precompute ranks externally and attach them.
# For now, rank on the synthetic data (momentum on leveraged prices
# preserves ranking since leverage is monotone on return).
mom = data[avail_stocks].pct_change(self.mom_lookback, fill_method=None)
if self.selection == "recovery_momentum":
panel = data[avail_stocks]
recovery = panel / panel.rolling(self.recovery_window).min() - 1
long_mom = panel.shift(self.long_mom_skip).pct_change(
self.long_mom_lookback - self.long_mom_skip, fill_method=None,
)
rec_r = recovery.rank(axis=1, pct=True, na_option="keep")
mom_r = long_mom.rank(axis=1, pct=True, na_option="keep")
score = 0.5 * rec_r + 0.5 * mom_r
else:
score = mom
need = max(150, self.mom_lookback + 1, self._v3.vol_window + 1,
self._v3.dd_window, self._v3.peak_window,
self.long_mom_lookback + 1 if self.selection == "recovery_momentum" else 0,
self.recovery_window + 1 if self.selection == "recovery_momentum" else 0) + 1
regime: str | None = None
bars = 0
# Phase 1: build raw weights (regime + stock selection)
raw_w = pd.DataFrame(np.nan, index=data.index, columns=data.columns)
for i in range(len(data)):
if i < need:
continue
closes = sig_arr[:i]
if np.isnan(closes[-1]):
continue
desired = self._v3._desired_regime(closes, regime)
changed = False
if regime is None:
regime, bars, changed = desired, 0, True
else:
bars += 1
if desired != regime and bars >= 15:
regime, bars, changed = desired, 0, True
if not changed and (i - need) % self.rebal_every != 0:
continue
row = {c: 0.0 for c in data.columns}
dt = data.index[i]
if regime == "risk_on":
s = score.iloc[i][avail_stocks].dropna()
valid = s.index[data.loc[dt, s.index].notna()]
s = s[valid]
if self.selection == "momentum":
s = s[s > 0]
top = s.nlargest(min(self.top_n, len(s)))
if len(top) > 0:
wt = 1.0 / len(top)
for t in top.index:
row[t] = wt
elif avail_def:
row[avail_def[0]] = 1.0
else:
if avail_def:
dm = data[avail_def].pct_change(63, fill_method=None).iloc[i].dropna()
best = dm.idxmax() if len(dm) > 0 else avail_def[0]
row[best] = 1.0
for c, v in row.items():
raw_w.at[dt, c] = v
raw_w = raw_w.ffill().fillna(0.0)
raw_w = raw_w.shift(1).fillna(0.0)
# Phase 2: Vol-target overlay
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
port_rets = (raw_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 = raw_w.mul(scale, axis=0)
# Phase 3: Profit-take
if self.pt_threshold <= 0:
return w
held = w.idxmax(axis=1)
max_w = w.max(axis=1)
held[max_w < 1e-8] = ""
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
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
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
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
print("=" * 95)
print(" DIRECTION 2: V7 + SYNTHETIC LEVERAGED INDIVIDUAL STOCKS")
print("=" * 95)
# Load S&P 500 + PIT + ETFs
print("\n[1] Loading data...")
universe = UNIVERSES["us"]
tickers = universe["fetch"]()
pit_intervals = uh.load_sp500_history()
hist_tickers = uh.all_tickers_ever(pit_intervals)
etfs = ["SPY", "GLD", "DBC", "SHY", "TQQQ", "UPRO", "TLT"]
all_tickers = sorted(set(tickers + hist_tickers + etfs))
raw_data = data_manager.update("us", all_tickers, with_open=False)
if isinstance(raw_data, tuple):
raw_data = raw_data[0]
cutoff = raw_data.index[-1] - pd.DateOffset(years=YEARS)
raw_data = raw_data[raw_data.index >= cutoff]
raw_data = uh.mask_prices(raw_data, pit_intervals)
stock_tickers = [t for t in raw_data.columns
if t not in etfs and raw_data[t].notna().any()]
print(f" Stocks: {len(stock_tickers)}, Period: {raw_data.index[0].date()}{raw_data.index[-1].date()}")
# Build synthetic leveraged price panels
print("\n[2] Building synthetic leveraged prices...")
stock_prices = raw_data[stock_tickers]
synth_2x = synthetic_leveraged_prices(stock_prices, 2.0)
synth_3x = synthetic_leveraged_prices(stock_prices, 3.0)
# Combine synthetic stocks with real ETF prices for each variant
etf_prices = raw_data[etfs]
results: list[tuple[str, dict]] = []
def run(label: str, strategy: Strategy, data_panel: pd.DataFrame):
print(f" {label}...", end=" ", flush=True)
try:
eq = backtest(strategy, data_panel, initial_capital=CAPITAL,
transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
m = metrics.raw_summary(eq)
results.append((label, m))
print(f"Ann={m['annualizedReturn']*100:.1f}% Sharpe={m['sharpeRatio']:.2f} "
f"MaxDD={m['maxDrawdown']*100:.1f}%")
except Exception as e:
print(f"FAILED: {e}")
# =====================================================================
# Run variants
# =====================================================================
print("\n[3] Running strategies...")
# --- V7+VT36 baseline (real TQQQ/UPRO) ---
from strategies.trend_rider_v7 import TrendRiderV7
etf_only = [t for t in ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"] if t in etf_prices.columns]
run("V7+VT36 baseline (TQQQ/UPRO)",
TrendRiderV7(target_vol=0.36, min_lev=0.75),
etf_prices[etf_only])
# --- Synth 2x: momentum, various top-N ---
for n in (1, 3, 5, 10):
panel_2x = pd.concat([synth_2x, etf_prices], axis=1)
panel_2x = panel_2x.loc[:, ~panel_2x.columns.duplicated()]
run(f"Synth 2x Mom top-{n} (VT36+PT30)",
V7SynthLeverage(stock_tickers=stock_tickers, leverage=2.0,
top_n=n, target_vol=0.36, min_lev=0.75),
panel_2x)
# --- Synth 2x: recovery-momentum ---
for n in (3, 5, 10):
panel_2x = pd.concat([synth_2x, etf_prices], axis=1)
panel_2x = panel_2x.loc[:, ~panel_2x.columns.duplicated()]
run(f"Synth 2x RecMom top-{n} (VT36+PT30)",
V7SynthLeverage(stock_tickers=stock_tickers, leverage=2.0,
top_n=n, selection="recovery_momentum",
target_vol=0.36, min_lev=0.75),
panel_2x)
# --- Synth 3x: direct comparison with real TQQQ ---
for n in (1, 3, 5):
panel_3x = pd.concat([synth_3x, etf_prices], axis=1)
panel_3x = panel_3x.loc[:, ~panel_3x.columns.duplicated()]
run(f"Synth 3x Mom top-{n} (VT36+PT30)",
V7SynthLeverage(stock_tickers=stock_tickers, leverage=3.0,
top_n=n, target_vol=0.36, min_lev=0.75),
panel_3x)
# --- Synth 2x without vol-target (see if raw 2x stocks need less VT) ---
for n in (3, 5):
panel_2x = pd.concat([synth_2x, etf_prices], axis=1)
panel_2x = panel_2x.loc[:, ~panel_2x.columns.duplicated()]
run(f"Synth 2x Mom top-{n} (no VT, PT30)",
V7SynthLeverage(stock_tickers=stock_tickers, leverage=2.0,
top_n=n, target_vol=1.0, min_lev=1.0, max_lev=1.0),
panel_2x)
# --- Synth 2x with higher PT threshold (2x has less vol drag → let profits run) ---
for pt in (0.40, 0.50):
panel_2x = pd.concat([synth_2x, etf_prices], axis=1)
panel_2x = panel_2x.loc[:, ~panel_2x.columns.duplicated()]
run(f"Synth 2x Mom top-5 (VT36+PT{int(pt*100)})",
V7SynthLeverage(stock_tickers=stock_tickers, leverage=2.0,
top_n=5, target_vol=0.36, min_lev=0.75,
pt_threshold=pt, pt_band=pt*0.33),
panel_2x)
# --- Synth 2x: no profit-take (2x might not need it) ---
panel_2x = pd.concat([synth_2x, etf_prices], axis=1)
panel_2x = panel_2x.loc[:, ~panel_2x.columns.duplicated()]
run("Synth 2x Mom top-5 (VT36, no PT)",
V7SynthLeverage(stock_tickers=stock_tickers, leverage=2.0,
top_n=5, target_vol=0.36, min_lev=0.75,
pt_threshold=0),
panel_2x)
# --- SPY benchmark ---
spy = raw_data["SPY"].dropna()
spy_eq = (spy / spy.iloc[0]) * CAPITAL
results.append(("SPY benchmark", metrics.raw_summary(spy_eq)))
# =====================================================================
# Report
# =====================================================================
results.sort(key=lambda x: x[1]["annualizedReturn"], reverse=True)
print(f"\n{'=' * 110}")
print(" RANKING")
print(f"{'=' * 110}")
print(f"{'#':<4} {'Strategy':<45} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} "
f"{'Sortino':>8} {'MaxDD%':>7} {'Calmar':>7}")
print("-" * 110)
for i, (label, m) in enumerate(results, 1):
marker = "" if i <= 3 else ""
print(f"{i:<4} {label:<45} "
f"{m['annualizedReturn']*100:>6.1f}% "
f"{m['annualizedVolatility']*100:>6.1f}% "
f"{m['sharpeRatio']:>7.2f} "
f"{m['sortinoRatio']:>8.2f} "
f"{m['maxDrawdown']*100:>6.1f}% "
f"{m['calmarRatio']:>7.2f}{marker}")
print(f"{'=' * 110}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,385 @@
"""Test three structural improvements to V7+VT36 identified by independent review.
Idea 1: PT entry price reset on restore (fix stale anchor)
Idea 2: TMF (3x bonds) in risk-off basket with TLT MA gate
Idea 3: Open-price fast exit overlay for crash protection
All tested against V7+VT36 baseline (61.2% Ann, Sharpe 1.89, MaxDD -29.2%).
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
from main import backtest
from strategies.base import Strategy
from strategies.permanent import TrendRiderV3
YEARS = 10
CAPITAL = 100_000
TX_COST = 0.001
FIXED_FEE = 2.0
# =========================================================================
# V7 with all three ideas as toggleable flags
# =========================================================================
class TrendRiderV7X(Strategy):
"""V7 extended with three structural improvements.
Flags:
reset_entry_on_restore: Idea 1 — reset entry_price when PT restores.
tmf_risk_off: Idea 2 — include TMF in risk-off when TLT > MA.
fast_exit: Idea 3 — emergency exit when SPY opens below threshold.
"""
def __init__(
self,
# V3 regime
ma_long: int = 150,
signal: str = "SPY",
risk_on: tuple[str, ...] = ("TQQQ", "UPRO"),
risk_off: tuple[str, ...] = ("GLD", "DBC"),
# Vol-target
target_vol: float = 0.36,
vol_window: int = 60,
min_lev: float = 0.75,
max_lev: float = 1.0,
# Profit-take
pt_threshold: float = 0.30,
pt_band: float = 0.10,
pt_park: str = "SHY",
# === Idea 1: reset entry on restore ===
reset_entry_on_restore: bool = False,
# === Idea 2: TMF risk-off with bond gate ===
tmf_risk_off: bool = False,
tmf_symbol: str = "TMF",
tlt_symbol: str = "TLT",
tlt_ma_window: int = 200,
# === Idea 3: fast exit on open ===
fast_exit: bool = False,
fast_exit_gap_pct: float = -0.03,
fast_exit_low_window: int = 20,
# 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.signal = signal
self.risk_off_base = risk_off
self.reset_entry_on_restore = reset_entry_on_restore
self.tmf_risk_off = tmf_risk_off
self.tmf_symbol = tmf_symbol
self.tlt_symbol = tlt_symbol
self.tlt_ma_window = tlt_ma_window
self.fast_exit = fast_exit
self.fast_exit_gap_pct = fast_exit_gap_pct
self.fast_exit_low_window = fast_exit_low_window
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 ---
w = self.v3.generate_signals(data)
# --- Idea 2: dynamically swap risk-off pick to TMF when bond regime is bullish ---
if self.tmf_risk_off and self.tmf_symbol in data.columns and self.tlt_symbol in data.columns:
tlt = data[self.tlt_symbol]
tlt_ma = tlt.rolling(self.tlt_ma_window).mean()
tlt_bull = (tlt > tlt_ma).shift(1).fillna(False)
risk_off_cols = [c for c in self.risk_off_base if c in w.columns]
tmf_col = self.tmf_symbol
if tmf_col not in w.columns:
w[tmf_col] = 0.0
for i in range(len(w)):
roff_weight = sum(w.iloc[i].get(c, 0.0) for c in risk_off_cols)
if roff_weight < 1e-8:
continue
if tlt_bull.iloc[i]:
# Candidate: TMF vs best of original risk-off by momentum
mom_lookback = 63
if i >= mom_lookback + 1:
best_sym = tmf_col
best_r = -np.inf
candidates = risk_off_cols + [tmf_col]
for sym in candidates:
if sym not in data.columns:
continue
p_now = data[sym].iloc[i - 1]
p_past = data[sym].iloc[i - 1 - mom_lookback]
if pd.notna(p_now) and pd.notna(p_past) and p_past > 0:
r = p_now / p_past - 1.0
if r > best_r:
best_r, best_sym = r, sym
# Reassign risk-off weight to the winner
for c in risk_off_cols:
w.iat[i, w.columns.get_loc(c)] = 0.0
if tmf_col in w.columns:
w.iat[i, w.columns.get_loc(tmf_col)] = 0.0
if best_sym in w.columns:
w.iat[i, w.columns.get_loc(best_sym)] = roff_weight
# --- Idea 3: fast exit overlay (check SPY open for gap-downs) ---
if self.fast_exit and self.signal in data.columns:
spy = data[self.signal]
spy_arr = spy.to_numpy()
risk_on_cols = list(self.v3.risk_on)
risk_off_cols_fast = [c for c in self.risk_off_base if c in w.columns]
park = self.pt_park if self.pt_park in w.columns else ""
for i in range(max(self.fast_exit_low_window + 1, 2), len(w)):
# Check if currently in risk-on
ron_weight = sum(float(w.iloc[i].get(c, 0.0))
for c in risk_on_cols if c in w.columns)
if ron_weight < 1e-8:
continue
prev_close = spy_arr[i - 1]
if np.isnan(prev_close) or prev_close <= 0:
continue
# Gap-down check: today's "effective open" approximated by
# checking if yesterday's close is below N-day low
low_window = spy_arr[max(0, i - 1 - self.fast_exit_low_window):i - 1]
if len(low_window) == 0:
continue
low_val = np.nanmin(low_window)
# Trigger 1: close below N-day low
trigger_low = prev_close <= low_val
# Trigger 2: large single-day drop (gap-down proxy using close-to-close)
if i >= 2:
prev2_close = spy_arr[i - 2]
daily_ret = (prev_close / prev2_close - 1.0) if prev2_close > 0 else 0.0
trigger_gap = daily_ret <= self.fast_exit_gap_pct
else:
trigger_gap = False
if trigger_low or trigger_gap:
# Emergency: zero out risk-on, move to park
for c in risk_on_cols:
if c in w.columns:
w.iat[i, w.columns.get_loc(c)] = 0.0
if park and park in w.columns:
w.iat[i, w.columns.get_loc(park)] = 1.0
# --- Layer 2: Vol-target overlay ---
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
# Only use columns present in w
common_cols = w.columns.intersection(daily_ret.columns)
port_rets = (w[common_cols] * daily_ret[common_cols]).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
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
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
# === Idea 1: reset entry price on restore ===
if self.reset_entry_on_restore:
entry_price = yesterday
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
# =========================================================================
# Main
# =========================================================================
def main():
print("=" * 100)
print(" THREE IDEAS EVALUATION")
print("=" * 100)
# Load data including TMF and TLT
all_etfs = sorted(set([
"SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY", "TLT", "TMF",
]))
print(f"\nLoading ETFs: {all_etfs}")
data = data_manager.update("etfs", all_etfs, with_open=False)
if isinstance(data, tuple):
data = data[0]
cutoff = data.index[-1] - pd.DateOffset(years=YEARS)
data = data[data.index >= cutoff]
avail = sorted(data.columns.tolist())
print(f"Available: {avail}")
print(f"Period: {data.index[0].date()}{data.index[-1].date()}")
results: list[tuple[str, dict]] = []
def run(label, strategy):
eq = backtest(strategy, data, initial_capital=CAPITAL,
transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
m = metrics.raw_summary(eq)
results.append((label, m))
print(f" {label:<60} Ann={m['annualizedReturn']*100:>5.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}% "
f"Sortino={m['sortinoRatio']:.2f} Calmar={m['calmarRatio']:.2f}")
# Baseline
print("\n--- Baseline ---")
run("V7+VT36 (baseline)",
TrendRiderV7X(target_vol=0.36, min_lev=0.75))
# === Idea 1: PT entry reset ===
print("\n--- Idea 1: PT Entry Price Reset ---")
run("V7+VT36 + PT reset",
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
reset_entry_on_restore=True))
# Also test with different PT thresholds to see if reset changes the optimum
for pt in (0.20, 0.25, 0.30, 0.35, 0.40):
run(f" PT reset + PT{int(pt*100)}",
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
reset_entry_on_restore=True,
pt_threshold=pt, pt_band=pt * 0.33))
# === Idea 2: TMF risk-off ===
print("\n--- Idea 2: TMF in Risk-Off ---")
if "TMF" in data.columns and "TLT" in data.columns:
run("V7+VT36 + TMF risk-off (TLT gate)",
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
tmf_risk_off=True))
# TMF + PT reset combo
run("V7+VT36 + TMF + PT reset",
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
tmf_risk_off=True, reset_entry_on_restore=True))
# Different TLT MA windows
for tlt_ma in (100, 150, 200, 250):
run(f" TMF risk-off (TLT MA{tlt_ma})",
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
tmf_risk_off=True, tlt_ma_window=tlt_ma))
else:
print(" TMF or TLT not available, skipping")
# === Idea 3: Fast exit ===
print("\n--- Idea 3: Fast Exit ---")
for gap in (-0.02, -0.03, -0.04):
for low_w in (10, 20):
run(f"V7+VT36 + fast exit (gap={gap:.0%}, low={low_w}d)",
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
fast_exit=True, fast_exit_gap_pct=gap,
fast_exit_low_window=low_w))
# === All three combined ===
print("\n--- All Three Combined ---")
if "TMF" in data.columns:
run("V7+VT36 + ALL (reset+TMF+fast exit)",
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
reset_entry_on_restore=True,
tmf_risk_off=True,
fast_exit=True, fast_exit_gap_pct=-0.03,
fast_exit_low_window=20))
# Best combo tuning
for pt in (0.25, 0.30, 0.35):
run(f" ALL + PT{int(pt*100)}",
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
reset_entry_on_restore=True,
tmf_risk_off=True,
fast_exit=True, fast_exit_gap_pct=-0.03,
fast_exit_low_window=20,
pt_threshold=pt, pt_band=pt * 0.33))
# Final ranking
results.sort(key=lambda x: x[1]["sharpeRatio"], reverse=True)
print(f"\n{'=' * 110}")
print(" FINAL RANKING (by Sharpe)")
print(f"{'=' * 110}")
print(f"{'#':<4} {'Strategy':<60} {'Ann%':>6} {'Vol%':>6} {'Sharpe':>7} "
f"{'Sortino':>8} {'MaxDD%':>7} {'Calmar':>7}")
print("-" * 110)
for i, (label, m) in enumerate(results, 1):
marker = "" if i <= 3 else ""
print(f"{i:<4} {label:<60} "
f"{m['annualizedReturn']*100:>5.1f}% "
f"{m['annualizedVolatility']*100:>5.1f}% "
f"{m['sharpeRatio']:>7.2f} "
f"{m['sortinoRatio']:>8.2f} "
f"{m['maxDrawdown']*100:>6.1f}% "
f"{m['calmarRatio']:>7.2f}{marker}")
print(f"{'=' * 110}")
# Also rank by Ann return
results.sort(key=lambda x: x[1]["annualizedReturn"], reverse=True)
print(f"\n Top 5 by Annualized Return:")
for i, (label, m) in enumerate(results[:5], 1):
print(f" {i}. {label:<55} Ann={m['annualizedReturn']*100:.1f}% "
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}%")
if __name__ == "__main__":
main()

501
research/v7_trade_audit.py Normal file
View File

@@ -0,0 +1,501 @@
"""V7+VT36 full trade log: regime changes, instrument switches, PT events,
vol-target scale, contribution analysis.
Run from /home/gahow/projects/quant:
uv run python research/v7_trade_audit.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import data_manager
import metrics
from main import backtest
from strategies.trend_rider_v7 import TrendRiderV7
YEARS = 10
CAPITAL = 100_000
TX_COST = 0.001
FIXED_FEE = 2.0
def load_data():
tickers = sorted(set(["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"]))
data = data_manager.update("etfs", tickers, with_open=False)
if isinstance(data, tuple):
data = data[0]
cutoff = data.index[-1] - pd.DateOffset(years=YEARS)
data = data[data.index >= cutoff]
cols = [c for c in tickers if c in data.columns]
return data[cols]
def trace_v7(data: pd.DataFrame):
"""Replicate V7+VT36 signal generation step-by-step, logging every event."""
v7 = TrendRiderV7(target_vol=0.36, min_lev=0.75)
# --- Layer 1: V3 regime weights ---
w_v3 = v7.v3.generate_signals(data)
# --- Layer 2: Vol-target overlay ---
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
port_rets_v3 = (w_v3 * daily_ret).sum(axis=1)
realized_vol = (
port_rets_v3.rolling(v7.vol_window, min_periods=21).std() * np.sqrt(252)
)
scale_raw = (v7.target_vol / realized_vol).clip(lower=v7.min_lev, upper=v7.max_lev)
scale = scale_raw.shift(1).fillna(1.0)
w_vt = w_v3.mul(scale, axis=0)
# --- Layer 3: Profit-take (replicate the loop, logging events) ---
w = w_vt.copy()
held = w.idxmax(axis=1)
max_w = w.max(axis=1)
held[max_w < 1e-8] = ""
park_col = v7.pt_park if v7.pt_park in w.columns else ""
entry_price = None
current_sym = None
is_stopped = False
restore_level = v7.pt_threshold - v7.pt_band
pt_events = [] # (date, type, sym, entry_price, exit_price, gain%)
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
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
pt_events.append((data.index[i], "RESTORE", sym, entry_price, yesterday, gain))
else:
w.iloc[i] = 0.0
if park_col:
w.at[w.index[i], park_col] = scale.iloc[i]
else:
if gain >= v7.pt_threshold:
is_stopped = True
pt_events.append((data.index[i], "CLEAR", sym, entry_price, yesterday, gain))
w.iloc[i] = 0.0
if park_col:
w.at[w.index[i], park_col] = scale.iloc[i]
return w_v3, w_vt, w, scale, pt_events
def analyze_regime(w_v3: pd.DataFrame, data: pd.DataFrame):
"""Trace regime changes from V3 weights."""
held_v3 = w_v3.idxmax(axis=1)
max_w = w_v3.max(axis=1)
held_v3[max_w < 1e-8] = ""
risk_on_syms = {"TQQQ", "UPRO"}
risk_off_syms = {"GLD", "DBC"}
events = []
prev_regime = None
prev_sym = None
regime_start = None
for i in range(len(held_v3)):
sym = held_v3.iloc[i]
if not sym:
continue
regime = "risk_on" if sym in risk_on_syms else ("risk_off" if sym in risk_off_syms else "other")
if regime != prev_regime:
if prev_regime is not None:
events.append({
"date": data.index[i],
"type": "REGIME_CHANGE",
"from": prev_regime,
"to": regime,
"from_sym": prev_sym,
"to_sym": sym,
})
prev_regime = regime
regime_start = data.index[i]
prev_sym = sym
elif sym != prev_sym:
events.append({
"date": data.index[i],
"type": "INSTRUMENT_SWITCH",
"regime": regime,
"from_sym": prev_sym,
"to_sym": sym,
})
prev_sym = sym
return events, held_v3
def compute_contributions(data, w_v3, w_vt, w_final, scale, pt_events):
"""Return attribution of returns to risk-on, risk-off, PT-park, and vol-target."""
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
# Identify which days are in PT-park (cleared to cash by PT layer)
# NOTE: SHY is not in V3's output columns, so PT clears to 0% (cash), not SHY.
risk_on_syms = {"TQQQ", "UPRO"}
risk_off_syms = {"GLD", "DBC"}
vt_sum = w_vt.abs().sum(axis=1)
final_sum = w_final.abs().sum(axis=1)
held_final = w_final.idxmax(axis=1)
max_w_final = w_final.max(axis=1)
held_final[max_w_final < 1e-8] = ""
held_vt = w_vt.idxmax(axis=1)
max_w_vt = w_vt.max(axis=1)
held_vt[max_w_vt < 1e-8] = ""
# Classify each day
day_class = pd.Series("none", index=data.index)
for i in range(len(data)):
sym_vt = held_vt.iloc[i]
sym_final = held_final.iloc[i]
# PT-park: VT layer has allocation but final layer cleared to 0
if vt_sum.iloc[i] > 0.01 and final_sum.iloc[i] < 0.01:
day_class.iloc[i] = "pt_park"
elif not sym_final:
continue
elif sym_final in risk_on_syms:
day_class.iloc[i] = "risk_on"
elif sym_final in risk_off_syms:
day_class.iloc[i] = "risk_off"
elif sym_final == "SHY":
day_class.iloc[i] = "risk_off_shy"
else:
day_class.iloc[i] = "other"
# Daily portfolio return for each layer
port_ret_v3 = (w_v3 * daily_ret).sum(axis=1)
port_ret_vt = (w_vt * daily_ret).sum(axis=1)
port_ret_final = (w_final * daily_ret).sum(axis=1)
# Contribution by regime state
contrib = {}
for cls in ["risk_on", "risk_off", "risk_off_shy", "pt_park", "none"]:
mask = day_class == cls
contrib[cls] = {
"days": int(mask.sum()),
"cum_return": float((1 + port_ret_final[mask]).prod() - 1) if mask.any() else 0.0,
"avg_daily_ret": float(port_ret_final[mask].mean()) if mask.any() else 0.0,
}
# Vol-target impact: compare V3-only vs V3+VT
eq_v3 = (1 + port_ret_v3).cumprod() * CAPITAL
eq_vt = (1 + port_ret_vt).cumprod() * CAPITAL
eq_final = (1 + port_ret_final).cumprod() * CAPITAL
return day_class, contrib, eq_v3, eq_vt, eq_final
def main():
data = load_data()
print(f"Data: {data.index[0].date()} to {data.index[-1].date()}, {len(data)} days")
print(f"Columns: {list(data.columns)}")
w_v3, w_vt, w_final, scale, pt_events = trace_v7(data)
# =========================================================================
# 1. Regime changes and instrument switches
# =========================================================================
regime_events, held_v3 = analyze_regime(w_v3, data)
print(f"\n{'='*90}")
print(" REGIME CHANGES AND INSTRUMENT SWITCHES")
print(f"{'='*90}")
regime_changes = [e for e in regime_events if e["type"] == "REGIME_CHANGE"]
instrument_switches = [e for e in regime_events if e["type"] == "INSTRUMENT_SWITCH"]
print(f"\nTotal regime changes: {len(regime_changes)}")
print(f"Total instrument switches (within regime): {len(instrument_switches)}")
print(f"\n--- Regime Changes ---")
for e in regime_changes:
print(f" {e['date'].date()} {e['from']:>9} -> {e['to']:<9} ({e['from_sym']} -> {e['to_sym']})")
print(f"\n--- Instrument Switches ---")
for e in instrument_switches:
print(f" {e['date'].date()} [{e['regime']:>9}] {e['from_sym']} -> {e['to_sym']}")
# Regime holding periods
risk_on_syms = {"TQQQ", "UPRO"}
risk_off_syms = {"GLD", "DBC"}
regime_series = pd.Series("none", index=data.index)
for i in range(len(held_v3)):
sym = held_v3.iloc[i]
if sym in risk_on_syms:
regime_series.iloc[i] = "risk_on"
elif sym in risk_off_syms:
regime_series.iloc[i] = "risk_off"
active = regime_series[regime_series != "none"]
if len(active) > 0:
pct_risk_on = (active == "risk_on").mean()
pct_risk_off = (active == "risk_off").mean()
print(f"\n Time in risk_on: {pct_risk_on:.1%}")
print(f" Time in risk_off: {pct_risk_off:.1%}")
# Average holding period per regime stint
regime_shifts = active != active.shift(1)
stint_id = regime_shifts.cumsum()
stint_lengths = stint_id.groupby(stint_id).count()
avg_stint = stint_lengths.mean()
print(f" Avg regime stint: {avg_stint:.0f} trading days")
# =========================================================================
# 2. Profit-take events
# =========================================================================
print(f"\n{'='*90}")
print(" PROFIT-TAKE EVENTS")
print(f"{'='*90}")
print(f"\nTotal PT events: {len(pt_events)}")
clears = [e for e in pt_events if e[1] == "CLEAR"]
restores = [e for e in pt_events if e[1] == "RESTORE"]
print(f" CLEAR events: {len(clears)}")
print(f" RESTORE events: {len(restores)}")
if clears:
print(f"\n--- CLEAR Events ---")
gains = []
for date, typ, sym, entry, exit_p, gain in clears:
print(f" {date.date()} {sym:>5} entry=${entry:.2f} exit=${exit_p:.2f} gain={gain:+.1%}")
gains.append(gain)
print(f"\n Avg gain at CLEAR: {np.mean(gains):.1%}")
print(f" Min gain at CLEAR: {np.min(gains):.1%}")
print(f" Max gain at CLEAR: {np.max(gains):.1%}")
if restores:
print(f"\n--- RESTORE Events ---")
for date, typ, sym, entry, exit_p, gain in restores:
print(f" {date.date()} {sym:>5} entry=${entry:.2f} price=${exit_p:.2f} gain={gain:+.1%}")
# PT-park days
day_class, contrib, eq_v3, eq_vt, eq_final = compute_contributions(
data, w_v3, w_vt, w_final, scale, pt_events)
pt_park_days = (day_class == "pt_park").sum()
total_active_days = (day_class != "none").sum()
print(f"\n Days in PT-park (SHY): {pt_park_days} ({pt_park_days/total_active_days:.1%} of active days)")
# =========================================================================
# 3. Vol-target scale analysis
# =========================================================================
print(f"\n{'='*90}")
print(" VOL-TARGET SCALE ANALYSIS")
print(f"{'='*90}")
active_scale = scale[scale < 1.0 - 1e-6]
print(f"\n Scale stats (when < 1.0):")
print(f" Days at full scale (1.0): {(scale >= 1.0 - 1e-6).sum()}")
print(f" Days below 1.0: {len(active_scale)}")
print(f" Days below 0.90: {(scale < 0.90).sum()}")
print(f" Days below 0.80: {(scale < 0.80).sum()}")
print(f" Days at floor (0.75): {(scale <= 0.75 + 1e-6).sum()}")
print(f" Min scale: {scale.min():.3f}")
print(f" Mean scale: {scale.mean():.3f}")
# When did scale hit the floor?
at_floor = scale[scale <= 0.75 + 1e-6]
if len(at_floor) > 0:
print(f"\n Periods at floor (scale=0.75):")
floor_mask = scale <= 0.75 + 1e-6
shifts = floor_mask != floor_mask.shift(1)
stint_ids = shifts.cumsum()
floor_stints = stint_ids[floor_mask]
for stint_id_val in floor_stints.unique():
stint_dates = floor_stints[floor_stints == stint_id_val].index
if len(stint_dates) > 0:
print(f" {stint_dates[0].date()} to {stint_dates[-1].date()} ({len(stint_dates)} days)")
# =========================================================================
# 4. Contribution analysis
# =========================================================================
print(f"\n{'='*90}")
print(" RETURN CONTRIBUTION BY STATE")
print(f"{'='*90}")
for cls in ["risk_on", "risk_off", "risk_off_shy", "pt_park"]:
c = contrib[cls]
print(f"\n {cls:>14}: {c['days']:>5} days "
f"cum_return={c['cum_return']:>+8.1%} "
f"avg_daily={c['avg_daily_ret']*10000:>+6.1f}bps")
# =========================================================================
# 5. With vs Without vol-target
# =========================================================================
print(f"\n{'='*90}")
print(" VOL-TARGET IMPACT (V3 alone vs V3+VT)")
print(f"{'='*90}")
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
# V3-only with turnover cost
eq_v3_bt = backtest(
type("V3Only", (), {"generate_signals": lambda self, d: TrendRiderV7(target_vol=0.36, min_lev=0.75).v3.generate_signals(d)})(),
data, initial_capital=CAPITAL, transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
m_v3 = metrics.raw_summary(eq_v3_bt)
# V7+VT36 full
eq_full = backtest(
TrendRiderV7(target_vol=0.36, min_lev=0.75),
data, initial_capital=CAPITAL, transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
m_full = metrics.raw_summary(eq_full)
# V7 with VT but no PT
eq_no_pt = backtest(
TrendRiderV7(target_vol=0.36, min_lev=0.75, pt_threshold=0.0),
data, initial_capital=CAPITAL, transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
m_no_pt = metrics.raw_summary(eq_no_pt)
print(f"\n {'Variant':<30} {'Ann':>7} {'Sharpe':>7} {'MaxDD':>7} {'Sortino':>8} {'Calmar':>7}")
print(f" {'-'*67}")
for label, m in [("V3 only (no VT, no PT)", m_v3),
("V3 + VT (no PT)", m_no_pt),
("V7+VT36 full (VT+PT)", m_full)]:
print(f" {label:<30} {m['annualizedReturn']*100:>6.1f}% {m['sharpeRatio']:>7.2f} "
f"{m['maxDrawdown']*100:>6.1f}% {m['sortinoRatio']:>8.2f} {m['calmarRatio']:>7.2f}")
# =========================================================================
# 6. Drawdown analysis
# =========================================================================
print(f"\n{'='*90}")
print(" WORST DRAWDOWN PERIODS")
print(f"{'='*90}")
rolling_peak = eq_full.cummax()
dd = (eq_full - rolling_peak) / rolling_peak
# Find top 5 drawdown troughs
dd_sorted = dd.sort_values()
seen_windows = []
top_dds = []
for date, dd_val in dd_sorted.items():
too_close = False
for prev_date in seen_windows:
if abs((date - prev_date).days) < 60:
too_close = True
break
if not too_close:
top_dds.append((date, dd_val))
seen_windows.append(date)
if len(top_dds) >= 5:
break
for trough_date, trough_dd in top_dds:
# Find start of this drawdown (last peak before trough)
pre_trough = rolling_peak.loc[:trough_date]
peak_val = pre_trough.iloc[-1]
peak_dates = eq_full[eq_full >= peak_val * 0.999].loc[:trough_date]
if len(peak_dates) > 0:
peak_date = peak_dates.index[0]
else:
peak_date = trough_date
# What was the strategy holding?
held_during = day_class.loc[peak_date:trough_date]
regime_during = held_during.value_counts()
# Recovery date
post_trough = eq_full.loc[trough_date:]
recovered = post_trough[post_trough >= peak_val]
recovery_date = recovered.index[0] if len(recovered) > 0 else None
print(f"\n DD {trough_dd:.1%} | {peak_date.date()} -> {trough_date.date()}"
f" | Recovery: {recovery_date.date() if recovery_date else 'N/A'}")
print(f" State during DD: {dict(regime_during)}")
# What instrument was held at the trough?
held_final = w_final.idxmax(axis=1)
max_w_final = w_final.max(axis=1)
held_final[max_w_final < 1e-8] = ""
trough_sym = held_final.loc[trough_date] if trough_date in held_final.index else "?"
print(f" Held at trough: {trough_sym}")
# =========================================================================
# 7. Disproportionate trades
# =========================================================================
print(f"\n{'='*90}")
print(" TOP 10 BEST AND WORST SINGLE-DAY RETURNS")
print(f"{'='*90}")
port_ret_final = (w_final * daily_ret).sum(axis=1)
# Apply turnover cost
turnover = w_final.diff().abs().sum(axis=1).fillna(0.0)
port_ret_final_net = port_ret_final - turnover * TX_COST
held_final = w_final.idxmax(axis=1)
max_w_final = w_final.max(axis=1)
held_final[max_w_final < 1e-8] = ""
best = port_ret_final_net.nlargest(10)
worst = port_ret_final_net.nsmallest(10)
print("\n BEST DAYS:")
for date, ret in best.items():
sym = held_final.loc[date] if date in held_final.index else "?"
sc = scale.loc[date] if date in scale.index else 1.0
print(f" {date.date()} {ret:>+7.2%} holding={sym} scale={sc:.2f}")
print("\n WORST DAYS:")
for date, ret in worst.items():
sym = held_final.loc[date] if date in held_final.index else "?"
sc = scale.loc[date] if date in scale.index else 1.0
print(f" {date.date()} {ret:>+7.2%} holding={sym} scale={sc:.2f}")
# Compound impact of top/bottom 20 days
sorted_rets = port_ret_final_net.sort_values()
total_days = len(sorted_rets)
eq_without_top20 = (1 + sorted_rets.iloc[:-20]).prod()
eq_without_bottom20 = (1 + sorted_rets.iloc[20:]).prod()
eq_all = (1 + sorted_rets).prod()
print(f"\n Total compound growth factor: {eq_all:.2f}x")
print(f" Without best 20 days: {eq_without_top20:.2f}x")
print(f" Without worst 20 days: {eq_without_bottom20:.2f}x")
# =========================================================================
# 8. Annual returns breakdown
# =========================================================================
print(f"\n{'='*90}")
print(" ANNUAL RETURNS BREAKDOWN")
print(f"{'='*90}")
yearly = port_ret_final_net.groupby(port_ret_final_net.index.year)
print(f"\n {'Year':>6} {'Return':>8} {'Vol':>7} {'MaxDD':>7} {'Days_ON':>8} {'Days_OFF':>9} {'Days_PT':>8}")
print(f" {'-'*55}")
for year, rets in yearly:
ann_r = (1 + rets).prod() ** (252 / len(rets)) - 1 if len(rets) > 0 else 0
vol = rets.std() * np.sqrt(252) if len(rets) > 1 else 0
eq_yr = (1 + rets).cumprod()
mdd = ((eq_yr / eq_yr.cummax()) - 1).min()
yr_class = day_class.loc[rets.index]
n_on = (yr_class == "risk_on").sum()
n_off = ((yr_class == "risk_off") | (yr_class == "risk_off_shy")).sum()
n_pt = (yr_class == "pt_park").sum()
print(f" {year:>6} {ann_r:>+7.1%} {vol:>6.1%} {mdd:>+6.1%} {n_on:>8} {n_off:>9} {n_pt:>8}")
if __name__ == "__main__":
main()

View File

@@ -12,4 +12,4 @@ class BuyAndHoldStrategy(Strategy):
"""
tickers = data.columns
weights = pd.DataFrame(1 / len(tickers), index=data.index, columns=tickers)
return weights
return weights.shift(1).fillna(0.0)

View File

@@ -30,9 +30,9 @@ class DualMomentumStrategy(Strategy):
all_positive = (short_mom > 0) & (med_mom > 0) & (long_mom > 0)
# Composite score: average percentile rank across timeframes
short_rank = short_mom.rank(axis=1, pct=True, na_option="bottom")
med_rank = med_mom.rank(axis=1, pct=True, na_option="bottom")
long_rank = long_mom.rank(axis=1, pct=True, na_option="bottom")
short_rank = short_mom.rank(axis=1, pct=True, na_option="keep")
med_rank = med_mom.rank(axis=1, pct=True, na_option="keep")
long_rank = long_mom.rank(axis=1, pct=True, na_option="keep")
composite = (short_rank + med_rank + long_rank) / 3
# Only consider stocks passing absolute filter

View File

@@ -84,17 +84,6 @@ class EnsembleAlphaStrategy(Strategy):
row_sums = raw.sum(axis=1).replace(0, np.nan)
signals = raw.div(row_sums, axis=0).fillna(0.0)
# === Tail-risk protection ===
if self.tail_protection:
# Portfolio equity proxy: equal-weight market return
mkt_ret = ret.mean(axis=1)
mkt_eq = (1 + mkt_ret).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
in_tail = mkt_dd < self.tail_threshold
scale = pd.Series(1.0, index=data.index)
scale[in_tail] = self.tail_scale
signals = signals.mul(scale, axis=0)
# === Monthly rebalance ===
warmup = 252
rebal_mask = pd.Series(False, index=data.index)
@@ -105,6 +94,20 @@ class EnsembleAlphaStrategy(Strategy):
signals = signals.ffill().fillna(0.0)
signals.iloc[:warmup] = 0.0
# === Tail-risk protection (rebal-gated; NaN-safe market mean) ===
# Apply AFTER ffill so the scale changes only at rebal points,
# otherwise daily flips force half-out/half-in trades that burn
# the account through fixed per-trade fees.
if self.tail_protection:
mkt_ret = ret.mean(axis=1, skipna=True)
mkt_eq = (1 + mkt_ret.fillna(0.0)).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
in_tail = mkt_dd < self.tail_threshold
scale_raw = pd.Series(1.0, index=data.index)
scale_raw[in_tail] = self.tail_scale
scale = scale_raw.where(rebal_mask, np.nan).ffill().fillna(1.0)
signals = signals.mul(scale, axis=0)
return signals.shift(1).fillna(0.0)
@@ -162,16 +165,6 @@ class EnhancedFactorComboStrategy(Strategy):
row_sums = raw.sum(axis=1).replace(0, np.nan)
signals = raw.div(row_sums, axis=0).fillna(0.0)
# Tail protection
if self.tail_protection:
mkt_ret = ret.mean(axis=1)
mkt_eq = (1 + mkt_ret).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
in_tail = mkt_dd < -0.15
scale = pd.Series(1.0, index=data.index)
scale[in_tail] = 0.5
signals = signals.mul(scale, axis=0)
# Monthly rebalance
warmup = 252
rebal_mask = pd.Series(False, index=data.index)
@@ -182,6 +175,17 @@ class EnhancedFactorComboStrategy(Strategy):
signals = signals.ffill().fillna(0.0)
signals.iloc[:warmup] = 0.0
# Tail protection (rebal-gated to avoid daily-flip turnover)
if self.tail_protection:
mkt_ret = ret.mean(axis=1, skipna=True)
mkt_eq = (1 + mkt_ret.fillna(0.0)).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
in_tail = mkt_dd < -0.15
scale_raw = pd.Series(1.0, index=data.index)
scale_raw[in_tail] = 0.5
scale = scale_raw.where(rebal_mask, np.nan).ffill().fillna(1.0)
signals = signals.mul(scale, axis=0)
return signals.shift(1).fillna(0.0)
@@ -230,31 +234,41 @@ class RiskManagedEnsembleStrategy(Strategy):
# Step 1: Get raw signals from the ensemble (already shifted by 1)
raw = self.ensemble.generate_signals(data)
# Step 2: Compute MARKET returns (equal-weight of all stocks)
daily_rets = data.pct_change().fillna(0.0)
mkt_rets = daily_rets.mean(axis=1)
# Step 2: Compute MARKET returns over valid (non-masked) columns.
# `daily_rets` keeps NaN for PIT-masked tickers so they don't dilute
# the cross-sectional mean to ~0.
daily_rets = data.pct_change()
mkt_rets = daily_rets.mean(axis=1, skipna=True)
# Step 3: Market drawdown dampener
mkt_eq = (1 + mkt_rets).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1 # always ≤ 0
# Linear: at DD=0 → 1.0, at DD=-dd_denom → dd_floor
dd_scale = (1.0 + mkt_dd / self.dd_denom).clip(lower=self.dd_floor, upper=1.0)
dd_scale_lagged = dd_scale.shift(1).fillna(1.0) # PIT
mkt_eq = (1 + mkt_rets.fillna(0.0)).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
dd_scale_raw = (1.0 + mkt_dd / self.dd_denom).clip(
lower=self.dd_floor, upper=1.0,
)
# Step 4: Vol spike guard (uses portfolio's own vol for specificity)
# Step 4: Vol spike guard from portfolio returns (NaN-aware sum)
if self.vol_spike_guard:
port_rets = (raw * daily_rets).sum(axis=1)
port_rets = (raw * daily_rets).sum(axis=1, min_count=1).fillna(0.0)
short_vol = port_rets.rolling(self.vol_spike_window, min_periods=5).std() * np.sqrt(252)
vol_90th = short_vol.rolling(self.vol_spike_lookback, min_periods=126).quantile(0.90)
in_spike = short_vol > vol_90th
vol_scale = pd.Series(1.0, index=data.index)
vol_scale[in_spike] = self.vol_spike_floor
vol_scale_lagged = vol_scale.shift(1).fillna(1.0) # PIT
vol_scale_raw = pd.Series(1.0, index=data.index)
vol_scale_raw[in_spike] = self.vol_spike_floor
else:
vol_scale_lagged = 1.0
vol_scale_raw = pd.Series(1.0, index=data.index)
# Step 5: Combined scaling, sampled at the inner ensemble's rebal
# cadence so we don't trade in/out daily (which would incur huge
# fixed-fee costs).
combined = (dd_scale_raw * vol_scale_raw).shift(1).fillna(1.0)
rebal_freq = getattr(self.ensemble, "rebal_freq", 21)
warmup = 252
rebal_mask = pd.Series(False, index=data.index)
rebal_indices = list(range(warmup, len(data), rebal_freq))
rebal_mask.iloc[rebal_indices] = True
final_scale = combined.where(rebal_mask, np.nan).ffill().fillna(1.0)
# Step 5: Combined scaling
final_scale = dd_scale_lagged * vol_scale_lagged
return raw.mul(final_scale, axis=0)
@@ -342,26 +356,31 @@ class SharpeBoostedEnsembleStrategy(Strategy):
signals = signals.shift(1).fillna(0.0) # PIT: 1-day execution lag
# === Asymmetric vol scaling ===
# Only reduce exposure when vol is high AND returns are negative
# High vol + positive returns = riding a trend, don't cut
daily_rets = data.pct_change().fillna(0.0)
port_rets = (signals * daily_rets).sum(axis=1)
# NB: scales are RE-EVALUATED only on rebalance days. Daily flips of
# asym/dd scales would force half-in/half-out trades each session,
# burning the account through fixed per-trade fees ($2 US / $5 CN).
# Use cross-sectional mean of non-masked returns so PIT-masked
# NaN→0 fills don't dilute the market signal.
daily_rets = data.pct_change()
port_rets = (signals * daily_rets).sum(axis=1, min_count=1).fillna(0.0)
short_vol = port_rets.rolling(20, min_periods=10).std() * np.sqrt(252)
vol_median = short_vol.rolling(252, min_periods=126).median()
recent_ret = port_rets.rolling(20, min_periods=10).sum()
high_vol_neg = (short_vol > vol_median * 1.5) & (recent_ret < 0)
asym_scale = pd.Series(1.0, index=data.index)
asym_scale[high_vol_neg] = self.asym_vol_floor
signals = signals.mul(asym_scale.shift(1).fillna(1.0), axis=0) # PIT
asym_scale_raw = pd.Series(1.0, index=data.index)
asym_scale_raw[high_vol_neg] = self.asym_vol_floor
# === Light market-DD dampener ===
# Uses market (not strategy) drawdown to avoid negative feedback loop
mkt_rets = daily_rets.mean(axis=1)
mkt_eq = (1 + mkt_rets).cumprod()
# === Market-DD dampener (rebal-gated, NaN-aware market mean) ===
mkt_rets = daily_rets.mean(axis=1, skipna=True)
mkt_eq = (1 + mkt_rets.fillna(0.0)).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
dd_scale = (1.0 + mkt_dd / self.dd_denom).clip(
dd_scale_raw = (1.0 + mkt_dd / self.dd_denom).clip(
lower=self.dd_floor, upper=1.0
)
signals = signals.mul(dd_scale.shift(1).fillna(1.0), axis=0) # PIT
# Sample scales at rebal points only, then step-hold between rebals.
combined = (asym_scale_raw * dd_scale_raw).shift(1).fillna(1.0)
rebal_scale = combined.where(rebal_mask, np.nan).ffill().fillna(1.0)
signals = signals.mul(rebal_scale, axis=0)
return signals

View File

@@ -89,11 +89,13 @@ class TrendRiderV6(TrendRiderV5):
def _resolve_universe(self, prices: pd.DataFrame) -> list[str]:
if self.stock_universe is not None:
return [s for s in self.stock_universe if s in prices.columns]
# Heuristic: stocks are columns NOT in our known ETF/leveraged set
non_stock = (set(self.core_equity)
| set(self.leveraged_equity)
# Heuristic: stocks are columns NOT in our known ETF/leveraged set.
# We inherit V3's risk_on (e.g. TQQQ/UPRO) and risk_off (GLD/DBC),
# plus V6's risk_off_basket + moderate_anchor + signal + overlay sym.
non_stock = (set(self.risk_on)
| set(self.risk_off)
| {self.signal, *self.risk_off_basket, self.moderate_anchor})
| {self.signal, self.moderate_anchor,
self.leverage_overlay_symbol, *self.risk_off_basket})
return [c for c in prices.columns if c not in non_stock]
def _stock_top_n_weights(self, prices: pd.DataFrame, universe: list[str]) -> pd.DataFrame:

View File

@@ -0,0 +1,191 @@
"""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)
# Ensure pt_park column exists so PT can allocate to it
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
# --- 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,215 @@
"""TrendRider V8 — V7 + TMF risk-off upgrade.
Extends V7 (V3 regime + vol-target + profit-take) with a smarter risk-off
basket: when TLT is in a bull regime (above its MA200), TMF (3x long-duration
Treasuries) joins the risk-off momentum selection alongside GLD and DBC.
Why this works
--------------
V7 spends ~30-40% of time in risk-off, holding GLD or DBC which average
low single-digit returns. During equity bear markets the Fed typically cuts
rates, driving long bonds higher — TMF (3x TLT) captures this convexity.
2020 crash: TMF ~+60% while equities fell 34%.
The TLT MA200 gate prevents TMF allocation during bond bear markets
(e.g. 2022 rate-hiking cycle where TLT fell 31%).
PIT safety
----------
V3's generate_signals uses prices through day t-1 internally, then applies
a terminal shift(1). So V3's output weight at row i uses data through day
i-2. The TMF swap and TLT gate must match this information set: all
lookups use data through day i-2 (shift(2) for vectorized signals,
iloc[i-2] for point lookups).
Profit-take is applied ONLY to risk-on assets (TQQQ/UPRO). Risk-off
assets (GLD, DBC, TMF) are exempt because:
1. TMF can gain 30%+ during rate-cut cycles — PT would sell at the
worst possible time.
2. Risk-off is already regime-gated; PT on defensive assets is redundant.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.permanent import TrendRiderV3
class TrendRiderV8(Strategy):
"""V7 architecture + TMF risk-off with bond-regime gate.
Pipeline:
Layer 1 — V3 regime engine → risk-on / risk-off weights
Layer 1b — TMF swap (PIT-aligned to V3's info set: data through i-2)
Layer 2 — Vol-target overlay
Layer 3 — Profit-take (risk-on assets only; risk-off exempt)
"""
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"),
# TMF risk-off
tmf_symbol: str = "TMF",
tlt_symbol: str = "TLT",
tlt_ma_window: int = 200,
tmf_mom_lookback: int = 63,
# Vol-target overlay
target_vol: float = 0.36,
vol_window: int = 60,
min_lev: float = 0.75,
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.risk_on = risk_on
self.risk_off = risk_off
self.tmf_symbol = tmf_symbol
self.tlt_symbol = tlt_symbol
self.tlt_ma_window = tlt_ma_window
self.tmf_mom_lookback = tmf_mom_lookback
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[i] uses data through day i-2.
w = self.v3.generate_signals(data)
# --- Layer 1b: TMF risk-off swap ---
# PIT: must use data through day i-2 to match V3's info set.
# shift(2) on vectorized signals; iloc[i-2] for point lookups.
tmf = self.tmf_symbol
tlt = self.tlt_symbol
if tmf in data.columns and tlt in data.columns:
tlt_ma = data[tlt].rolling(self.tlt_ma_window).mean()
tlt_bull = (data[tlt] > tlt_ma).shift(2).fillna(False)
roff_cols = [c for c in self.risk_off if c in w.columns]
if tmf not in w.columns:
w[tmf] = 0.0
lb = self.tmf_mom_lookback
for i in range(lb + 3, len(w)):
roff_weight = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in roff_cols)
if roff_weight < 1e-8:
continue
if not tlt_bull.iloc[i]:
continue
best_sym, best_r = None, -np.inf
for sym in roff_cols + [tmf]:
if sym not in data.columns:
continue
p_now = data[sym].iloc[i - 2]
p_past = data[sym].iloc[i - 2 - lb]
if pd.notna(p_now) and pd.notna(p_past) and p_past > 0:
r = float(p_now / p_past - 1.0)
if r > best_r:
best_r, best_sym = r, sym
if best_sym is not None:
for c in roff_cols:
w.iat[i, w.columns.get_loc(c)] = 0.0
w.iat[i, w.columns.get_loc(tmf)] = 0.0
w.iat[i, w.columns.get_loc(best_sym)] = roff_weight
# --- Layer 2: Vol-target overlay ---
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
common = w.columns.intersection(daily_ret.columns)
port_rets = (w[common] * daily_ret[common]).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 (risk-on only) ---
# Risk-off assets (GLD, DBC, TMF) are exempt from PT.
if self.pt_threshold <= 0:
return w
risk_off_set = set(self.risk_off) | {self.tmf_symbol}
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
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
# Skip PT for risk-off assets
if sym in risk_off_set:
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
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__ = ["TrendRiderV8"]

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

187
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,62 @@ 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),
"trend_rider_v7_vt36": lambda **kw: TrendRiderV7(target_vol=0.36, min_lev=0.75),
# --- 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,12 +212,28 @@ 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)),
"trend_rider_v7_vt36": sorted(set(ETF_UNIVERSE)),
}
DEFAULT_MONITOR_STRATEGIES = [
name for name in STRATEGY_REGISTRY
if name not in ETF_STRATEGY_UNIVERSES
]
# 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 = list(STRATEGY_REGISTRY.keys())
def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
@@ -146,6 +241,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 +249,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 +409,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 +456,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 +714,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"])
@@ -1159,6 +1298,7 @@ def cmd_monitor(args):
print(f" Evening: {sched['eve_h']:02d}:{sched['eve_m']:02d} {sched['tz']}")
print(f" Fixed fee: {fee:.2f}/trade")
print(f" Strategies: {', '.join(strategies)}")
print(f" Auto-reload: ON (new strategies picked up each phase)")
print(f"{'='*60}")
# Use UTC as common reference for sleeping
@@ -1191,12 +1331,34 @@ def cmd_monitor(args):
all_candidates.extend(_next_events_for_market(mkt, now_utc))
return min(all_candidates, key=lambda x: x[0])
def _reload_strategies():
"""Re-read STRATEGY_REGISTRY to pick up new strategies without restart."""
import importlib
import trader as _self_mod
importlib.reload(_self_mod)
current = list(_self_mod.STRATEGY_REGISTRY.keys())
return current, _self_mod.STRATEGY_REGISTRY
def _run_phase(market, phase, now_utc):
"""Run all strategies for a market/phase."""
nonlocal strategies
sched = market_schedules[market]
tz = sched["tz"]
now_local = now_utc.astimezone(tz)
# Hot-reload strategy list from registry
try:
reloaded_names, reloaded_reg = _reload_strategies()
new_strats = set(reloaded_names) - set(strategies)
if new_strats:
print(f"[monitor] Hot-reload: +{len(new_strats)} new strategies: "
f"{', '.join(sorted(new_strats))}")
strategies = reloaded_names
# Update the global registry so cmd_morning/cmd_auto use new strategies
globals().update({"STRATEGY_REGISTRY": reloaded_reg})
except Exception as e:
print(f"[monitor] Hot-reload failed (using cached list): {e}")
print(f"\n[monitor] {'='*55}")
print(f"[monitor] {market.upper()} {phase.upper()} at "
f"{now_local.strftime('%Y-%m-%d %H:%M:%S %Z')}")
@@ -1233,7 +1395,7 @@ def cmd_monitor(args):
traceback.print_exc()
print(f"[monitor] {market.upper()} {phase} done — "
f"{len(strategies)} strategies")
f"{len(strategies)} strategies\n")
while True:
now_utc = datetime.now(utc)
@@ -1362,8 +1524,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"])