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:
2026-09-25 19:12:02 +08:00
parent 0e378efa67
commit cc1366b2dc
16 changed files with 16027 additions and 606 deletions

229
bridge.py
View File

@@ -20,29 +20,19 @@ import pandas as pd
import data_manager
import metrics
import universe_history as uh
from main import backtest
from backtest_runner import benchmark_curve, run_market
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,
STRATEGY_REGISTRY,
convert_quote_currency,
price_request,
strategy_supports_market,
strategy_data_market,
filter_tradable_tickers,
strategy_supports_market,
)
MARKET_CURRENCY = {"us": "USD", "cn": "CNY"}
# 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
from universe import UNIVERSES
# ---------------------------------------------------------------------------
@@ -440,6 +430,25 @@ STRATEGY_META = {
"params": {},
"markets": ["us"],
},
# --- Robust momentum (2026-09 PIT research): US pick, CN picks ---
"overnight_recovery_top10": {
"label": "Robust: Overnight Mom + Recovery Top 10",
"category": "factor_combo",
"params": {},
"markets": ["us", "cn"],
},
"lowrisk_momentum_top15": {
"label": "Robust: Low-Risk Momentum Top 15",
"category": "factor_combo",
"params": {},
"markets": ["us", "cn"],
},
"lowbeta_sharpe_mom_top20": {
"label": "Robust: Low-Beta + Sharpe Mom Top 20",
"category": "factor_combo",
"params": {},
"markets": ["us", "cn"],
},
}
@@ -515,28 +524,6 @@ def compute_drawdown_periods(equity: pd.Series, threshold: float = -0.05) -> lis
return periods
def compute_rebalance_trades(weights: pd.DataFrame, threshold: float = 0.01) -> list[dict]:
"""Extract significant trade events from weight changes."""
diff = weights.diff().fillna(0.0)
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 compute_rebalance_win_rate(equity: pd.Series, trade_events: list[dict]) -> float | None:
"""Fraction of inter-rebalance periods with positive cumulative return.
@@ -600,15 +587,6 @@ def compute_periodic_returns(equity: pd.Series) -> tuple[dict, dict]:
return monthly, yearly
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 build_injection_schedule(equity: pd.Series, schedule: dict) -> tuple[pd.Series, list[dict]]:
"""Build a contributions Series from injection schedule config.
@@ -725,159 +703,18 @@ def cmd_backtest(args: argparse.Namespace) -> None:
_log(f"Running backtest: market={market}, years={years}, capital={capital}")
_log(f"Strategies: {strategy_names}")
# Determine data needs
# Group strategies by their data source
stock_strategies = []
etf_strategies = []
for name in strategy_names:
if name not in STRATEGY_REGISTRY:
_log(f"WARNING: Unknown strategy '{name}', skipping")
continue
base_name = name.removeprefix("sim_")
if base_name in ETF_STRATEGY_UNIVERSES:
etf_strategies.append(name)
else:
stock_strategies.append(name)
results = []
# Process stock strategies
if stock_strategies:
universe = UNIVERSES[market]
with _redirect_stdout_to_stderr():
tickers = universe["fetch"]()
benchmark_ticker = universe["benchmark"]
benchmark_label = universe["benchmark_label"]
# PIT universe: for US market, include all tickers that were EVER
# S&P 500 members so that removed stocks are present in price data.
pit_intervals = None
if market == "us":
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])
| {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])
| {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)")
_log(f"Fetching price data for {len(all_tickers)} tickers...")
with _redirect_stdout_to_stderr():
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]
# Apply PIT mask: set prices to NaN for dates when a stock was NOT
# an index member. Strategies naturally skip NaN entries.
if pit_intervals is not None:
_log("Applying PIT membership mask (survivorship-bias fix)...")
data = uh.mask_prices(data, pit_intervals)
tickers = filter_tradable_tickers(data, tickers)
# For PIT mode, expand tickers to all columns with valid data
# (historical members that have been masked are NaN when not members,
# but have valid data during their membership period).
if pit_intervals is not None:
all_data_tickers = [
t for t in data.columns
if t != benchmark_ticker and data[t].notna().any()
]
tickers = all_data_tickers
_log(f"Universe: {len(tickers)} tradable stocks, period: {data.index[0].date()} to {data.index[-1].date()}")
for name in stock_strategies:
_log(f" Running: {name}")
# 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[cols],
initial_capital=capital,
transaction_cost=tx_cost,
fixed_fee=fixed_fee,
min_trade_value=MIN_TRADE_VALUE,
**MARKET_FEE_TIERED.get(market, {}),
)
# Get weight signals for trade extraction
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(
name, equity, trade_events, injection_schedule, capital
)
results.append(result_entry)
# Benchmark over exactly the strategies' dates
bench_curve = benchmark_curve(data[benchmark_ticker], data.index, capital)
else:
benchmark_label = "SPY"
bench_curve = []
# Process ETF strategies
if etf_strategies:
for name in etf_strategies:
_log(f" Running ETF strategy: {name}")
base_name = name.removeprefix("sim_")
etf_tickers = ETF_STRATEGY_UNIVERSES[base_name]
all_etf = sorted(set(etf_tickers + ["SPY"]))
with _redirect_stdout_to_stderr():
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,
min_trade_value=MIN_TRADE_VALUE,
**MARKET_FEE_TIERED.get(market, {}),
)
weights = strategy.generate_signals(etf_data[tradable])
weights = weights.reindex(etf_data[tradable].index).fillna(0.0)
trade_events = compute_rebalance_trades(weights)
result_entry = _build_strategy_result(
name, equity, trade_events, injection_schedule, capital
)
results.append(result_entry)
# If no stock strategies were run, use SPY as benchmark from ETF data
if not stock_strategies and len(etf_strategies) > 0:
benchmark_label = "SPY (Benchmark)"
with _redirect_stdout_to_stderr():
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)
known = [n for n in strategy_names if n in STRATEGY_REGISTRY]
for name in set(strategy_names) - set(known):
_log(f"WARNING: Unknown strategy '{name}', skipping")
with _redirect_stdout_to_stderr():
raw = run_market(market, known, years, capital, tx_cost, fixed_fee,
**MARKET_FEE_TIERED.get(market, {}))
results = [
_build_strategy_result(run["name"], run["equity"], run["trades"], injection_schedule, capital)
for run in (raw["strategy_runs"] if raw else [])
]
benchmark_label = raw["benchmark_label"] if raw else "SPY"
bench_curve = raw["benchmark_curve"] if raw else []
output = {
"meta": {