From 6dc623b4a1127c301d29f7503de1cd7be4b36245 Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Fri, 25 Sep 2026 02:16:48 +0800 Subject: [PATCH] fix(backtest): give mixed stock+ETF strategies their ETFs; derive markets from trader trend_rider_v6 backtests failed ('SPY' not in data) because only the stock universe was passed. Strategy markets in 'list' now follow the trader's eligibility rule, so v6 is no longer offered for CN. Co-Authored-By: Claude Opus 5.5 (1M context) --- bridge.py | 19 +++++++++++++------ trader.py | 5 +++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/bridge.py b/bridge.py index 7173af2..66c4484 100644 --- a/bridge.py +++ b/bridge.py @@ -24,6 +24,8 @@ import universe_history as uh from main import backtest from research.permanent_yearly import equity_with_cashflows from trader import ( + strategy_extra_tickers, + strategy_supports_market, ETF_STRATEGY_UNIVERSES, LIVE_EXCLUDED_STRATEGIES, MARKET_FEE_TIERED, @@ -698,7 +700,7 @@ def cmd_list() -> None: "label": meta["label"], "category": meta["category"], "params": meta["params"], - "markets": meta["markets"], + "markets": [m for m in meta["markets"] if strategy_supports_market(name, m)], }) json.dump(strategies, sys.stdout, indent=2) @@ -754,10 +756,12 @@ def cmd_backtest(args: argparse.Namespace) -> None: with _redirect_stdout_to_stderr(): pit_intervals = uh.load_sp500_history() historical_tickers = uh.all_tickers_ever(pit_intervals) - all_tickers = sorted(set(tickers + historical_tickers + [benchmark_ticker])) + all_tickers = sorted(set(tickers + historical_tickers + [benchmark_ticker]) + | {t for n in stock_strategies for t in strategy_extra_tickers(n)}) _log(f"PIT universe: {len(all_tickers)} tickers (current + historical members)") else: - all_tickers = sorted(set(tickers + [benchmark_ticker])) + all_tickers = sorted(set(tickers + [benchmark_ticker]) + | {t for n in stock_strategies for t in strategy_extra_tickers(n)}) _log(f"WARNING: {market.upper()} market lacks PIT membership history; " "backtest uses current index constituents (survivorship bias possible)") @@ -794,9 +798,12 @@ def cmd_backtest(args: argparse.Namespace) -> None: # Registry defaults are the single source of strategy parameters, # shared with the live trader. strategy = STRATEGY_REGISTRY[name]() + # Mixed stock+ETF strategies also read their fixed ETFs. + cols = tickers + [t for t in strategy_extra_tickers(name) + if t in data.columns and t not in tickers] equity = backtest( - strategy, data[tickers], + strategy, data[cols], initial_capital=capital, transaction_cost=tx_cost, fixed_fee=fixed_fee, @@ -805,8 +812,8 @@ def cmd_backtest(args: argparse.Namespace) -> None: ) # Get weight signals for trade extraction - weights = strategy.generate_signals(data[tickers]) - weights = weights.reindex(data[tickers].index).fillna(0.0) + weights = strategy.generate_signals(data[cols]) + weights = weights.reindex(data[cols].index).fillna(0.0) trade_events = compute_rebalance_trades(weights) result_entry = _build_strategy_result( diff --git a/trader.py b/trader.py index b251976..a4c6ee1 100644 --- a/trader.py +++ b/trader.py @@ -268,6 +268,11 @@ DEFAULT_MONITOR_STRATEGIES = [ ] +def strategy_extra_tickers(strategy_name: str) -> list[str]: + """Fixed ETF tickers a mixed stock+ETF strategy needs beside its stock universe.""" + return list(MIXED_STRATEGY_EXTRA_TICKERS.get(strategy_name.removeprefix("sim_"), [])) + + def strategy_supports_market(strategy_name: str, market: str) -> bool: """ETF-based strategies trade USD-listed ETFs, so they only run in the US book.""" base_name = strategy_name.removeprefix("sim_")