feat: robust-momentum strategies live; fix live universe, lots and UI backtests
Strategies - BarStrategy protocol (close/open/high/low/volume bars; row d uses bars through d-1) and RobustMomentumStrategy: overnight_recovery_top10 (US), lowrisk_momentum_top15 and lowbeta_sharpe_mom_top20 (CN), top-N with rank hysteresis, 10-day rebalance, weights drift between rebalances. - trader: strategy_fields/strategy_bars/bar_session_weights; one signal path (_signals_for_sessions) replaces generate_target_weights. Live fixes - CSI 300 constituents come from China Securities Index's file; the Wikipedia table was 122 names off the real index. - S&P 500 change log moved to "Historical components of the S&P 500". - data_manager: tickers missing from a partial Yahoo batch are retried alone before being marked unavailable (CEG, GEV, SNDK ... were dropped). update() takes fields=[...] and stores high/low/volume panels. - Whole-lot orders go to the nearest lot within cash (CN books held ~62% cash); held names are resized only beyond RESIZE_BAND (10%), so books no longer pay a ticket fee to trade daily drift. Web backtests - backtest_runner.py replaces the duplicated bridge/precompute code: PIT membership in both markets (data/csi300_history.json), stock universe limited to index members (ETFs and benchmarks in the shared panel were pickable), and the book kept like the live trader (simulate_live_book: signal timing, drift, order floor, resize band, fees on orders placed). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,32 +25,15 @@ import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import data_manager
|
||||
import metrics
|
||||
import universe_history as uh
|
||||
from backtest_runner import run_market
|
||||
from bridge import (
|
||||
MIN_TRADE_VALUE,
|
||||
STRATEGY_META,
|
||||
_build_strategy_result,
|
||||
_sanitize_for_json,
|
||||
benchmark_curve,
|
||||
compute_rebalance_trades,
|
||||
)
|
||||
from main import backtest
|
||||
from trader import (
|
||||
strategy_extra_tickers,
|
||||
strategy_supports_market,
|
||||
ETF_STRATEGY_UNIVERSES,
|
||||
MONITOR_CAPITAL,
|
||||
STRATEGY_REGISTRY,
|
||||
convert_quote_currency,
|
||||
filter_tradable_tickers,
|
||||
price_request,
|
||||
)
|
||||
from universe import UNIVERSES
|
||||
from trader import MONITOR_CAPITAL, STRATEGY_REGISTRY, strategy_supports_market
|
||||
|
||||
|
||||
# Initial capital per market, in local currency — the same amounts the live
|
||||
@@ -102,171 +85,6 @@ def _equity_series_from_curve(curve: list[dict]) -> pd.Series:
|
||||
return pd.Series(values, index=dates)
|
||||
|
||||
|
||||
def run_market_raw(
|
||||
market: str,
|
||||
strategy_names: list[str],
|
||||
years: int,
|
||||
capital: float,
|
||||
tx_cost: float,
|
||||
fixed_fee: float,
|
||||
fee_base: float = 0.0,
|
||||
fee_per_share: float = 0.0,
|
||||
) -> dict | None:
|
||||
"""Run all strategies once; return raw equity series + metadata per strategy."""
|
||||
stock_strategies = []
|
||||
etf_strategies = []
|
||||
for name in strategy_names:
|
||||
base_name = name.removeprefix("sim_")
|
||||
if base_name in ETF_STRATEGY_UNIVERSES:
|
||||
etf_strategies.append(name)
|
||||
else:
|
||||
stock_strategies.append(name)
|
||||
|
||||
raw_strategies: list[dict] = []
|
||||
benchmark_label = "SPY"
|
||||
bench_curve = []
|
||||
start_date = None
|
||||
end_date = None
|
||||
|
||||
# --- Stock strategies ---
|
||||
if stock_strategies:
|
||||
universe = UNIVERSES[market]
|
||||
tickers = universe["fetch"]()
|
||||
benchmark_ticker = universe["benchmark"]
|
||||
benchmark_label = universe["benchmark_label"]
|
||||
|
||||
pit_intervals = None
|
||||
if market == "us":
|
||||
pit_intervals = uh.load_sp500_history()
|
||||
historical_tickers = uh.all_tickers_ever(pit_intervals)
|
||||
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")
|
||||
else:
|
||||
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()} lacks PIT history; using current constituents")
|
||||
|
||||
log(f"Fetching price data for {len(all_tickers)} tickers...")
|
||||
data = data_manager.update(market, all_tickers, with_open=False)
|
||||
if isinstance(data, tuple):
|
||||
data = data[0]
|
||||
|
||||
if years:
|
||||
cutoff = data.index[-1] - pd.DateOffset(years=years)
|
||||
data = data[data.index >= cutoff]
|
||||
|
||||
if pit_intervals is not None:
|
||||
log("Applying PIT membership mask...")
|
||||
data = uh.mask_prices(data, pit_intervals)
|
||||
tickers = [
|
||||
t for t in data.columns
|
||||
if t != benchmark_ticker and data[t].notna().any()
|
||||
]
|
||||
else:
|
||||
tickers = filter_tradable_tickers(data, tickers)
|
||||
|
||||
log(f"Universe: {len(tickers)} stocks, {data.index[0].date()} to {data.index[-1].date()}")
|
||||
|
||||
for name in stock_strategies:
|
||||
log(f" Running: {name}")
|
||||
try:
|
||||
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[cols],
|
||||
initial_capital=capital,
|
||||
transaction_cost=tx_cost,
|
||||
fixed_fee=fixed_fee,
|
||||
fee_base=fee_base,
|
||||
fee_per_share=fee_per_share,
|
||||
min_trade_value=MIN_TRADE_VALUE,
|
||||
)
|
||||
|
||||
weights = strategy.generate_signals(data[cols])
|
||||
weights = weights.reindex(data[cols].index).fillna(0.0)
|
||||
trade_events = compute_rebalance_trades(weights)
|
||||
|
||||
raw_strategies.append({"name": name, "equity": equity, "trades": trade_events})
|
||||
log(f" OK: {name} (final: ${equity.iloc[-1]:,.0f})")
|
||||
except Exception as e:
|
||||
log(f" FAILED: {name}: {e}")
|
||||
|
||||
# Benchmark on exactly the strategies' dates
|
||||
bench_curve = benchmark_curve(data[benchmark_ticker], data.index, capital)
|
||||
start_date = data.index[0].strftime("%Y-%m-%d")
|
||||
end_date = data.index[-1].strftime("%Y-%m-%d")
|
||||
|
||||
# --- ETF strategies ---
|
||||
if etf_strategies:
|
||||
for name in etf_strategies:
|
||||
log(f" Running ETF strategy: {name}")
|
||||
try:
|
||||
base_name = name.removeprefix("sim_")
|
||||
etf_tickers = ETF_STRATEGY_UNIVERSES[base_name]
|
||||
all_etf = sorted(set(etf_tickers + ["SPY"]))
|
||||
|
||||
etf_data = data_manager.update("etfs", price_request(market, all_etf), with_open=False)
|
||||
if isinstance(etf_data, tuple):
|
||||
etf_data = etf_data[0]
|
||||
etf_data = convert_quote_currency(etf_data)
|
||||
if years:
|
||||
cutoff = etf_data.index[-1] - pd.DateOffset(years=years)
|
||||
etf_data = etf_data[etf_data.index >= cutoff]
|
||||
|
||||
strategy = STRATEGY_REGISTRY[name]()
|
||||
|
||||
tradable = filter_tradable_tickers(etf_data, etf_tickers)
|
||||
equity = backtest(
|
||||
strategy, etf_data[tradable],
|
||||
initial_capital=capital,
|
||||
transaction_cost=tx_cost,
|
||||
fixed_fee=fixed_fee,
|
||||
fee_base=fee_base,
|
||||
fee_per_share=fee_per_share,
|
||||
min_trade_value=MIN_TRADE_VALUE,
|
||||
)
|
||||
|
||||
weights = strategy.generate_signals(etf_data[tradable])
|
||||
weights = weights.reindex(etf_data[tradable].index).fillna(0.0)
|
||||
trade_events = compute_rebalance_trades(weights)
|
||||
|
||||
raw_strategies.append({"name": name, "equity": equity, "trades": trade_events})
|
||||
log(f" OK: {name} (final: ${equity.iloc[-1]:,.0f})")
|
||||
except Exception as e:
|
||||
log(f" FAILED: {name}: {e}")
|
||||
|
||||
if not stock_strategies:
|
||||
benchmark_label = "SPY (Benchmark)"
|
||||
spy_data = data_manager.update("etfs", ["SPY"], with_open=False)
|
||||
if isinstance(spy_data, tuple):
|
||||
spy_data = spy_data[0]
|
||||
if years:
|
||||
cutoff = spy_data.index[-1] - pd.DateOffset(years=years)
|
||||
spy_data = spy_data[spy_data.index >= cutoff]
|
||||
if "SPY" in spy_data.columns:
|
||||
bench_curve = benchmark_curve(spy_data["SPY"], spy_data.index, capital)
|
||||
start_date = spy_data.index[0].strftime("%Y-%m-%d")
|
||||
end_date = spy_data.index[-1].strftime("%Y-%m-%d")
|
||||
|
||||
if not raw_strategies:
|
||||
return None
|
||||
|
||||
return {
|
||||
"market": market,
|
||||
"strategy_runs": raw_strategies,
|
||||
"benchmark_label": benchmark_label,
|
||||
"benchmark_curve": bench_curve,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"tx_cost": tx_cost,
|
||||
"fixed_fee": fixed_fee,
|
||||
}
|
||||
|
||||
|
||||
def build_scenario_for_market(
|
||||
raw: dict,
|
||||
scenario: dict,
|
||||
@@ -358,7 +176,7 @@ def main():
|
||||
log("=" * 60)
|
||||
# US fee schedule (IBKR-style tiered):
|
||||
# fee = max(2.0, 1.88 + 0.009 * shares)
|
||||
us_raw = run_market_raw(
|
||||
us_raw = run_market(
|
||||
"us", us_strategies,
|
||||
years=args.years, capital=us_capital,
|
||||
tx_cost=0.001, fixed_fee=2.0,
|
||||
@@ -368,7 +186,7 @@ def main():
|
||||
log("=" * 60)
|
||||
log(f"CN MARKET: {len(cn_strategies)} strategies @ ¥{cn_capital:,.0f}")
|
||||
log("=" * 60)
|
||||
cn_raw = run_market_raw(
|
||||
cn_raw = run_market(
|
||||
"cn", cn_strategies,
|
||||
years=args.years, capital=cn_capital,
|
||||
tx_cost=0.001, fixed_fee=5.0,
|
||||
|
||||
Reference in New Issue
Block a user