fix: unify backtest execution and add forward validation candidates

Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band.

Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
This commit is contained in:
2026-10-01 02:24:01 -07:00
parent 761725d2c8
commit 722cade974
45 changed files with 1561 additions and 1021 deletions

View File

@@ -1,197 +1,229 @@
"""
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.
"""
"""Shared, feasible replay for the API, precompute and strategy comparisons."""
from __future__ import annotations
import sys
import numpy as np
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 execution import ENGINE_VERSION, simulate_book
from trader import (ETF_STRATEGY_UNIVERSES, STRATEGY_REGISTRY, RESIZE_BAND, AGENT_REVIEW_STRATEGIES,
convert_quote_currency, price_request, strategy_extra_tickers,
strategy_fields, strategy_signal_tickers, make_strategy,
session_signals, strategy_supports_market, strategy_resize_band)
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:
def _log(msg):
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
rows = []
for day, row in diff.iterrows():
buy, sell = row[row > threshold], row[row < -threshold]
if len(buy) + len(sell):
rows.append({"date": str(day.date()), "buys": len(buy), "sells": len(sell),
"buyTickers": list(buy.nlargest(3).index),
"sellTickers": list(sell.nsmallest(3).index)})
return rows
def order_events(trades: list[dict]) -> list[dict]:
days = {}
for t in trades:
day = days.setdefault(t["date"], {"date": t["date"], "buys": 0, "sells": 0,
"buyTickers": [], "sellTickers": []})
side = "buy" if t["action"] == "BUY" else "sell"
day[side + "s"] += 1
day[side + "Tickers"].append(t["ticker"])
for day in days.values():
for key in ("buyTickers", "sellTickers"):
day[key] = day[key][:3]
return list(days.values())
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()]
return [{"date": str(d.date()), "value": round(float(v), 2)}
for d, v in (series / series.iloc[0] * capital).items()]
def _slice_years(panels: dict[str, pd.DataFrame], years: int | None) -> dict[str, pd.DataFrame]:
if not years:
return panels
def _panels(data_market, tickers, fields, refresh):
filled = (data_manager.update(data_market, tickers, fields=fields) if refresh
else data_manager.load_fields(data_market, fields))
raw = data_manager.load_raw(data_market)
if raw is None:
raise FileNotFoundError(f"No local prices for {data_market}")
# Only valuation forward-fills. Indicators and tradability see actual bars.
filled["close"] = raw.reindex_like(filled["close"])
return filled
def load_stock_panels(market: str, strategy_names: list[str], years: int | None,
*, refresh: bool = True) -> dict:
u = UNIVERSES[market]
# Refresh membership explicitly only in the data-loading stage.
if refresh:
if market == "us":
uh.load_sp500_history()
else:
uh.load_csi300_history()
sample = pd.DatetimeIndex([pd.Timestamp.today().normalize()])
ever = list(uh.pit_membership(market, sample).columns)
extras = {t for n in strategy_names for t in strategy_extra_tickers(n) + strategy_signal_tickers(n, market)}
fields = sorted({"close", "open", "volume"} | {f for n in strategy_names for f in strategy_fields(n)})
panels = _panels(market, sorted(set(ever) | extras | {u['benchmark']}), fields, refresh)
close = panels["close"]
cutoff = close.index[-1] - pd.DateOffset(years=years)
return {f: p[p.index >= cutoff] for f, p in panels.items()}
member = uh.pit_membership(market, close.index)
stocks = sorted(set(member.columns) & set(close.columns) - extras - {u['benchmark']})
observed = close.reindex(columns=member.columns).notna() & member
coverage = observed.sum(axis=1).div(member.sum(axis=1).replace(0, np.nan))
return {"panels": panels, "member": member, "stocks": stocks,
"benchmark": u["benchmark"], "benchmark_label": u["benchmark_label"],
"coverage": coverage}
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 prepare_strategy(name: str, market: str, panels: dict, cols: list[str], *,
member: pd.DataFrame | None = None, years: int | None = None,
start=None, end=None, strategy=None) -> dict:
close = panels["close"]
if end is not None:
close = close.loc[:end]
benchmark = "SPY" if name in ETF_STRATEGY_UNIVERSES else UNIVERSES[market]["benchmark"]
if benchmark in close:
close = close.loc[close[benchmark].notna()]
extra = strategy_extra_tickers(name) + strategy_signal_tickers(name, market)
cols = sorted(set(cols + extra) & set(close.columns))
bars = {f: panels[f].reindex(index=close.index, columns=cols)
for f in strategy_fields(name)}
targets = session_signals(market, name, cols, close.index, close,
panels["open"].reindex(index=close.index),
bars=bars, member=member, strategy=strategy)
cutoff = pd.Timestamp(start) if start is not None else (
close.index[-1] - pd.DateOffset(years=years) if years else close.index[0])
days = close.index[close.index >= cutoff]
if len(days) < 2:
raise ValueError("Need at least two replay sessions")
return {"name": name, "market": market, "targets": targets.loc[days],
"prices": close.reindex(columns=targets.columns).loc[days],
"warmup_rows": int((close.index < days[0]).sum()),
"resize_band": strategy_resize_band(name),
"previous_prices": close.reindex(columns=targets.columns).ffill().shift(1).loc[days],
"volume": panels["volume"].reindex(index=days, columns=targets.columns) if "volume" in panels else None}
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 replay_prepared(plan: dict, capital: float, tx_cost: float, fixed_fee: float,
fee_base: float = 0.0, fee_per_share: float = 0.0,
*, contributions=None, integer_shares: bool = True,
resize_band: float | None = None, cash_returns=None) -> dict:
result = simulate_book(plan["targets"], plan["prices"], capital, market=plan["market"],
previous_prices=plan.get("previous_prices"), volume=plan.get("volume"),
transaction_cost=tx_cost, fixed_fee=fixed_fee,
fee_base=fee_base, fee_per_share=fee_per_share,
integer_shares=integer_shares,
lot_size=100 if plan["market"] == "cn" else 1,
min_trade_value=MIN_TRADE_VALUE,
resize_band=plan.get("resize_band", RESIZE_BAND) if resize_band is None else resize_band,
contributions=contributions, cash_returns=cash_returns)
# Include the initial capital so day-one commissions count in all metrics.
anchor = plan["prices"].index[0] - pd.Timedelta(days=1)
for key in ("equity", "unit_equity"):
result[key] = pd.concat([pd.Series([capital], index=[anchor]), result[key]])
result.update(name=plan["name"], trades=order_events(result["trade_log"]),
warmup_rows=plan.get("warmup_rows", 0))
return result
def _run(strategy, panels, cols, capital, tx_cost, fixed_fee, fee_base, fee_per_share,
member=None, min_trade_value=MIN_TRADE_VALUE):
# Compatibility for callers that already constructed a strategy.
from signals import session_targets
from strategies.base import BarStrategy
close = panels["close"][cols]
bars = {f: panels[f][cols] for f in strategy.fields} if isinstance(strategy, BarStrategy) else None
targets = session_targets(strategy, close, panels["open"][cols], bars, member)
result = simulate_book(targets, close, 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, resize_band=RESIZE_BAND)
return result["equity"], order_events(result["trade_log"])
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]
fee_per_share: float = 0.0, *, refresh: bool = True,
start=None, end=None) -> dict | None:
if any(n in AGENT_REVIEW_STRATEGIES for n in strategy_names):
raise ValueError("Agent-reviewed strategies are forward-only; use their dated live record")
invalid = [n for n in strategy_names if not strategy_supports_market(n, market)]
if invalid:
raise ValueError(f"Unsupported strategies for {market}: {invalid}")
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}")
stocks = [n for n in strategy_names if n not in ETF_STRATEGY_UNIVERSES]
stock = load_stock_panels(market, stocks, years, refresh=refresh) if stocks else None
etf_names = [n for n in strategy_names if n in ETF_STRATEGY_UNIVERSES]
etfs = None
if etf_names:
request = sorted(set(t for n in etf_names for t in ETF_STRATEGY_UNIVERSES[n]) | {"SPY"})
etfs = _panels("etfs", price_request(market, request), ["close", "open"], refresh)
etfs = {f: convert_quote_currency(p) for f, p in etfs.items()}
histories = []
for panel, benchmark in ((stock["panels"], stock["benchmark"]) if stock else (None, None),
(etfs, "SPY")):
if panel is not None:
sessions = panel["close"].index[panel["close"][benchmark].notna()]
if end is not None:
sessions = sessions[sessions <= pd.Timestamp(end)]
if len(sessions) <= 315:
raise ValueError("Registered replay needs 315 pre-window sessions")
histories.append(sessions)
common_end = min(h[-1] for h in histories)
requested_start = (pd.Timestamp(start) if start is not None else
common_end - pd.DateOffset(years=years) if years else min(h[0] for h in histories))
common_start = max(requested_start, max(h[315] for h in histories))
runs, errors = [], []
for name in strategy_names:
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}")
panels = etfs if name in ETF_STRATEGY_UNIVERSES else stock["panels"]
cols = ETF_STRATEGY_UNIVERSES[name] if name in ETF_STRATEGY_UNIVERSES else stock["stocks"]
member = None if name in ETF_STRATEGY_UNIVERSES else stock["member"]
plan = prepare_strategy(name, market, panels, cols, member=member, start=common_start, end=common_end)
result = replay_prepared(plan, capital, **fees)
result["_plan"] = plan
result["_fees"] = fees
runs.append(result)
_log(f" {name}: {result['equity'].iloc[-1]:,.2f}")
except (ValueError, KeyError, FileNotFoundError) as exc:
errors.append({"strategy": name, "error": str(exc)})
_log(f" FAILED {name}: {exc}")
if not runs:
if errors:
raise ValueError(str(errors))
return None
out["strategy_runs"] = runs
return out
first, last = runs[0]["equity"].index[[0, -1]]
bench = stock["benchmark"] if stock else "SPY"
panels = stock["panels"] if stock else etfs
dates = runs[0]["equity"].index
curve = benchmark_curve(panels["close"][bench], dates[1:], capital)
curve.insert(0, {"date": str(first.date()), "value": capital})
coverage = stock["coverage"].loc[first:last].dropna() if stock else pd.Series(dtype=float)
warnings = ["使用复权价格:历史整股、整手及公司行动的模拟仍有近似。",
"缺少完整退市结算和市场冲击数据,收益为研究估计。"]
if market == "cn":
warnings.append("沪深300历史成分股按月记录;缺少完整历史ST状态及集合竞价数据,涨跌停成交采用保守限制。")
if common_start > requested_start:
warnings.append(f"为保留315个交易日的预热历史,统一统计窗口从 {common_start.date()} 开始;更早区间需补齐行情。")
if len(coverage) and coverage.min() < .99:
warnings.append(f"历史成分股报价覆盖率最低为 {coverage.min():.1%},仍存在样本缺失偏差。")
return {"market": market, "benchmark_label": stock["benchmark_label"] if stock else "SPY",
"benchmark_curve": curve, "start_date": str(first.date()), "end_date": str(last.date()),
**fees, "strategy_runs": runs, "engine_version": ENGINE_VERSION,
"actual_years": round((last - first).days / 365.25, 1),
"warnings": warnings, "errors": errors,
"coverage": {"minimum": float(coverage.min()), "median": float(coverage.median())}
if len(coverage) else None}