Files
quant-trading/backtest_runner.py
Gahow Wang cc1366b2dc 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>
2026-09-25 19:12:02 +08:00

198 lines
9.4 KiB
Python

"""
Market-level backtests behind the web UI: ``bridge.py backtest`` (on demand)
and ``precompute_backtest.py`` (the strategy page) share this one data path.
* Universe: point-in-time index members in both markets (S&P 500 history from
Wikipedia, CSI 300 history in data/csi300_history.json); prices outside a
stock's membership are masked, so strategies cannot pick it then.
* Book: kept like the live trader keeps it — its signal timing
(``main.strategy_positions`` live mode), drifting holdings, the same order
size floor and resize band (``main.simulate_live_book``).
* Bars: panels a BarStrategy needs (open/high/low/volume) are loaded too.
"""
from __future__ import annotations
import sys
import pandas as pd
import data_manager
import universe_history as uh
from main import simulate_live_book, strategy_positions
from strategies.base import BarStrategy
from trader import (
ETF_STRATEGY_UNIVERSES,
LIVE_EXCLUDED_STRATEGIES,
RESIZE_BAND,
STRATEGY_REGISTRY,
convert_quote_currency,
filter_tradable_tickers,
price_request,
strategy_extra_tickers,
strategy_fields,
)
from universe import UNIVERSES
# Rebalances below this size are not charged the per-trade fee in backtests,
# matching the live trader's minimum order size.
MIN_TRADE_VALUE = 50.0
def _log(msg: str) -> None:
print(msg, file=sys.stderr)
def trade_events(diff: pd.DataFrame, threshold: float = 0.01) -> list[dict]:
"""Trade events from per-ticker traded weights (rows = days)."""
trades = []
for date in diff.index:
row = diff.loc[date]
significant = row[row.abs() > threshold]
if significant.empty:
continue
buys = significant[significant > 0]
sells = significant[significant < 0]
if len(buys) > 0 or len(sells) > 0:
trades.append({
"date": date.strftime("%Y-%m-%d"),
"buys": len(buys),
"sells": len(sells),
"buyTickers": list(buys.nlargest(3).index),
"sellTickers": list(sells.nsmallest(3).index),
})
return trades
def benchmark_curve(prices: pd.Series, dates: pd.DatetimeIndex, capital: float) -> list[dict]:
"""Benchmark value on ``dates``, rebased to ``capital`` on the first date it has a price."""
series = prices.reindex(dates).ffill().dropna()
if series.empty:
return []
values = series / series.iloc[0] * capital
return [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)} for d, v in values.items()]
def _slice_years(panels: dict[str, pd.DataFrame], years: int | None) -> dict[str, pd.DataFrame]:
if not years:
return panels
close = panels["close"]
cutoff = close.index[-1] - pd.DateOffset(years=years)
return {f: p[p.index >= cutoff] for f, p in panels.items()}
def load_stock_panels(market: str, strategy_names: list[str], years: int | None) -> dict:
"""PIT-masked panels for the market's stock strategies."""
universe = UNIVERSES[market]
tickers = universe["fetch"]()
benchmark = universe["benchmark"]
since = None
if years:
since = (pd.Timestamp.today() - pd.DateOffset(years=years + 1)).strftime("%Y-%m-%d")
ever = uh.pit_tickers_ever(market, since=since) or []
extras = {t for n in strategy_names for t in strategy_extra_tickers(n)}
request = sorted(set(tickers) | set(ever) | extras | {benchmark})
fields = sorted({"close", "open"} | {f for n in strategy_names for f in strategy_fields(n)})
_log(f"PIT universe: {len(request)} tickers ({len(ever)} ever index members); fields {fields}")
panels = data_manager.update(market, request, fields=fields)
panels = _slice_years(panels, years)
member = None
if ever:
member = uh.mask_prices_pit(panels["close"], market).notna() | panels["close"].isna()
# Only tickers that were index members: the shared panel also holds
# benchmarks and the ETFs of mixed strategies (TQQQ, SOXL, ...), which
# the membership mask passes through unmasked.
members_ever = set(ever)
stocks = [t for t in panels["close"].columns
if t in members_ever and t not in extras
and panels["close"][t].where(member[t]).notna().any()]
else:
stocks = filter_tradable_tickers(panels["close"], tickers)
return {"panels": panels, "member": member, "stocks": stocks, "benchmark": benchmark,
"benchmark_label": universe["benchmark_label"]}
def _run(strategy, panels: dict[str, pd.DataFrame], cols: list[str], capital: float,
tx_cost: float, fixed_fee: float, fee_base: float, fee_per_share: float,
member: pd.DataFrame | None = None, min_trade_value: float = MIN_TRADE_VALUE):
"""Close-price strategies see prices masked to index membership (they
cannot pick a non-member); a BarStrategy sees full histories plus the
membership as ``bars["eligible"]``. The book is valued on unmasked closes."""
close, opens = panels["close"][cols], panels["open"][cols]
if isinstance(strategy, BarStrategy):
bars = {f: panels[f][cols] for f in strategy.fields}
if member is not None:
bars["eligible"] = member.reindex(columns=cols).fillna(True)
held = strategy_positions(strategy, close, open_data=opens, bars=bars)
else:
m = member.reindex(columns=cols).fillna(True) if member is not None else None
held = strategy_positions(strategy, close.where(m) if m is not None else close,
open_data=opens.where(m) if m is not None else opens)
equity, traded = simulate_live_book(held, close, capital, tx_cost, fixed_fee, fee_base,
fee_per_share, min_trade_value, RESIZE_BAND)
return equity, trade_events(traded)
def run_market(market: str, strategy_names: list[str], years: int | None, capital: float,
tx_cost: float, fixed_fee: float, fee_base: float = 0.0,
fee_per_share: float = 0.0) -> dict | None:
"""Backtest every strategy once; returns raw equity series and metadata."""
stock = [n for n in strategy_names if n.removeprefix("sim_") not in ETF_STRATEGY_UNIVERSES]
etf = [n for n in strategy_names if n.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES]
fees = dict(tx_cost=tx_cost, fixed_fee=fixed_fee, fee_base=fee_base, fee_per_share=fee_per_share)
runs: list[dict] = []
out = {"market": market, "benchmark_label": "SPY", "benchmark_curve": [],
"start_date": None, "end_date": None, "tx_cost": tx_cost, "fixed_fee": fixed_fee}
if stock:
data = load_stock_panels(market, stock, years)
panels, stocks = data["panels"], data["stocks"]
close = panels["close"]
_log(f"Universe: {len(stocks)} stocks, {close.index[0].date()} to {close.index[-1].date()}")
for name in stock:
_log(f" Running: {name}")
try:
strategy = STRATEGY_REGISTRY[name]()
cols = stocks + [t for t in strategy_extra_tickers(name)
if t in close.columns and t not in stocks]
# Strategies the live book cannot hold (e.g. equal weight over
# ~500 names) are shown without the live order-size floor.
floor = 0.0 if name in LIVE_EXCLUDED_STRATEGIES else MIN_TRADE_VALUE
equity, trades = _run(strategy, panels, cols, capital, **fees,
member=data["member"], min_trade_value=floor)
runs.append({"name": name, "equity": equity, "trades": trades})
_log(f" OK: {name} (final: {equity.iloc[-1]:,.0f})")
except Exception as e: # noqa: BLE001 — one broken strategy must not sink the page
_log(f" FAILED: {name}: {e}")
out.update(benchmark_label=data["benchmark_label"],
benchmark_curve=benchmark_curve(close[data["benchmark"]], close.index, capital),
start_date=close.index[0].strftime("%Y-%m-%d"),
end_date=close.index[-1].strftime("%Y-%m-%d"))
for name in etf:
_log(f" Running ETF strategy: {name}")
try:
etf_tickers = ETF_STRATEGY_UNIVERSES[name.removeprefix("sim_")]
close, opens = data_manager.update(
"etfs", price_request(market, sorted(set(etf_tickers + ["SPY"]))), with_open=True)
panels = _slice_years({"close": convert_quote_currency(close),
"open": convert_quote_currency(opens)}, years)
strategy = STRATEGY_REGISTRY[name]()
equity, trades = _run(strategy, panels, filter_tradable_tickers(panels["close"], etf_tickers),
capital, **fees)
runs.append({"name": name, "equity": equity, "trades": trades})
_log(f" OK: {name} (final: {equity.iloc[-1]:,.0f})")
if not stock and not out["benchmark_curve"] and "SPY" in panels["close"].columns:
spy = panels["close"]["SPY"]
out.update(benchmark_label="SPY (Benchmark)",
benchmark_curve=benchmark_curve(spy, spy.index, capital),
start_date=spy.index[0].strftime("%Y-%m-%d"),
end_date=spy.index[-1].strftime("%Y-%m-%d"))
except Exception as e: # noqa: BLE001
_log(f" FAILED: {name}: {e}")
if not runs:
return None
out["strategy_runs"] = runs
return out