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

130
main.py
View File

@@ -9,6 +9,7 @@ import factor_attribution
import metrics
import universe_history as uh
from strategies.adaptive_momentum import AdaptiveMomentumStrategy
from strategies.base import BarStrategy
from strategies.buy_and_hold import BuyAndHoldStrategy
from strategies.dual_momentum import DualMomentumStrategy
from strategies.inverse_vol import InverseVolatilityStrategy
@@ -25,6 +26,99 @@ from universe import UNIVERSES
# Backtest engine
# ---------------------------------------------------------------------------
def strategy_positions(strategy, data: pd.DataFrame, open_data: pd.DataFrame | None = None,
bars: dict[str, pd.DataFrame] | None = None) -> pd.DataFrame:
"""Weights actually held over (close t-1, close t] for each row t.
Close mode (``open_data`` is None): row t's weights use closes through t-1
and trade at the close of t-1 — the same bar the signal saw.
Live mode (``open_data`` given), matching trader.py:
* close-price strategies read the session's open as their latest price
and trade at that session's close;
* a BarStrategy reads completed bars through t-1 and trades at the close
of t.
"""
if isinstance(strategy, BarStrategy):
weights = strategy.generate_signals(bars if bars is not None else {"close": data})
weights = weights.reindex(data.index).fillna(0.0)
return weights.shift(1).fillna(0.0) if open_data is not None else weights
if open_data is not None:
# The strategy's shift(1) on opens gives weights[t] = f(open_{t-1});
# undo it so the row-t decision uses open_t, then hold it from close t.
weights = strategy.generate_signals(open_data).shift(-1).fillna(0.0)
return weights.reindex(data.index).fillna(0.0).shift(1).fillna(0.0)
return strategy.generate_signals(data).reindex(data.index).fillna(0.0)
def simulate_live_book(held: pd.DataFrame, data: pd.DataFrame, initial_capital: float,
transaction_cost: float = 0.001, fixed_fee: float = 0.0,
fee_base: float = 0.0, fee_per_share: float = 0.0,
min_trade_value: float = 0.0,
resize_band: float = 0.0) -> tuple[pd.Series, pd.DataFrame]:
"""Day-by-day book kept the way trader.py keeps it.
``held`` row t holds the target weights for (close t-1, close t]. Holdings
drift with prices; at each close the book trades toward the next row's
targets: entries and exits always, a held name only once it is at least
``resize_band`` (relative to its target) away. Orders smaller than
``max(min_trade_value, 0.1% of equity)`` are skipped unless they close a
position. Costs are charged on the orders placed.
Returns (equity, traded weight per ticker on the row whose holdings changed).
"""
targets = held.reindex(index=data.index, columns=data.columns).fillna(0.0).to_numpy()
prices = data.ffill().to_numpy(dtype=float)
rets = np.nan_to_num(data.ffill().pct_change(fill_method=None).to_numpy(dtype=float), nan=0.0)
per_share = np.array([0.0 if str(c).endswith(".HK") else fee_per_share for c in data.columns])
n, m = targets.shape
w = np.zeros(m)
value = float(initial_capital)
equity = np.empty(n)
traded = np.zeros((n, m))
for t in range(n):
if value <= 0:
equity[t:] = 0.0
break
gross = w * (1.0 + rets[t])
growth = gross.sum() + (1.0 - w.sum()) # idle cash earns nothing
value *= growth
w = gross / growth if growth > 0 else np.zeros(m)
if t + 1 < n:
tgt = targets[t + 1]
new = w.copy()
entry_exit = (tgt <= 0) | (w <= 0)
resize = ~entry_exit & (np.abs(tgt - w) >= resize_band * tgt)
change = (entry_exit | resize) & (np.abs(tgt - w) > 0)
new[change] = tgt[change]
if new.sum() > 1.0: # kept winners leave less room
buys = change & (new > w)
room = max(1.0 - new[~buys].sum(), 0.0)
new[buys] *= room / new[buys].sum() if new[buys].sum() > 0 else 0.0
delta = new - w
floor = (max(min_trade_value, 0.001 * value) / value) if min_trade_value > 0 else 1e-12
skip = (np.abs(delta) < floor) & (new > 0)
delta[skip] = 0.0
new[skip] = w[skip]
orders = np.abs(delta) > 1e-12
if orders.any():
dollars = np.abs(delta) * value
cost = dollars.sum() * transaction_cost
if fixed_fee > 0 or fee_base > 0 or fee_per_share > 0:
with np.errstate(divide="ignore", invalid="ignore"):
shares = np.where(prices[t] > 0, dollars / prices[t], 0.0)
fee = np.maximum(fixed_fee, fee_base + per_share * shares)
cost += fee[orders].sum()
if cost >= value: # fees ate the book
equity[t:] = 0.0
break
w = new * value / (value - cost)
value -= cost
traded[t + 1] = delta
equity[t] = value
return (pd.Series(equity, index=data.index),
pd.DataFrame(traded, index=data.index, columns=data.columns))
def backtest(
strategy,
data: pd.DataFrame,
@@ -35,6 +129,8 @@ def backtest(
fee_per_share: float = 0.0,
open_data: pd.DataFrame | None = None,
min_trade_value: float = 0.0,
bars: dict[str, pd.DataFrame] | None = None,
resize_band: float = 0.0,
) -> pd.Series:
"""
Vectorized backtest.
@@ -60,39 +156,33 @@ def backtest(
With fee_base=1.88 + fee_per_share=0.009 + fixed_fee=2.0 you get an
IBKR-style schedule: max(2, 1.88 + 0.009 * shares).
open_data : pd.DataFrame, optional
Open prices. When provided, enables open-to-close execution mode.
Open prices. When provided, the backtest keeps the book the way the
live trader does: its signal timing (``strategy_positions``), drifting
holdings, whole orders and ``resize_band`` (``simulate_live_book``).
min_trade_value : float
Per-ticker rebalances smaller than ``max(min_trade_value, 0.1% of
equity)`` are not charged the per-trade fee, mirroring the live
trader, which never places such orders. 0 (default) charges every
weight change.
bars : dict, optional
``{field: DataFrame}`` panels for a BarStrategy (close/open/high/low/volume).
Returns
-------
pd.Series
Daily equity curve.
"""
positions = strategy_positions(strategy, data, open_data=open_data, bars=bars)
if open_data is not None:
# Open-to-close mode:
# Strategy's shift(1) on open prices gives: weights[t] = f(open_{t-1})
# But open_t is known at morning of day t, so undo shift to get f(open_t)
# Then execute at close_t, earning close_t → close_{t+1}
weights = strategy.generate_signals(open_data)
weights = weights.shift(-1).fillna(0.0)
weights = weights.reindex(data.index).fillna(0.0)
positions = weights
equity, _ = simulate_live_book(
positions, data, initial_capital, transaction_cost, fixed_fee, fee_base,
fee_per_share, min_trade_value, resize_band,
)
return equity
# Returns earned: close[t] → close[t+1], weighted by positions decided at open[t]
close_returns = data.pct_change().fillna(0.0)
portfolio_returns = (close_returns * positions.shift(1).fillna(0.0)).sum(axis=1)
else:
# Classic close-to-close mode
weights = strategy.generate_signals(data)
weights = weights.reindex(data.index).fillna(0.0)
positions = weights
daily_returns = data.pct_change().fillna(0.0)
portfolio_returns = (daily_returns * positions).sum(axis=1)
# A missing close carries the last price (zero return), as before.
daily_returns = data.ffill().pct_change(fill_method=None).fillna(0.0)
portfolio_returns = (daily_returns * positions).sum(axis=1)
# Turnover cost: sum of absolute weight changes each day
turnover = positions.diff().abs().sum(axis=1).fillna(0.0)