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:
229
bridge.py
229
bridge.py
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user