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

228
trader.py
View File

@@ -40,6 +40,7 @@ import pandas as pd
import yfinance as yf
import data_manager
from strategies.base import BarStrategy
from strategies.buy_and_hold import BuyAndHoldStrategy
from strategies.dual_momentum import DualMomentumStrategy
from strategies.factor_combo import FactorComboStrategy
@@ -55,6 +56,7 @@ from strategies.permanent import (
TrendRiderV4,
)
from strategies.recovery_momentum import RecoveryMomentumStrategy
from strategies.robust_momentum import RobustMomentumStrategy
from strategies.trend_following import TrendFollowingStrategy
from strategies.trend_rider_v5 import TrendRiderV5
from strategies.trend_rider_voltgt import (
@@ -95,6 +97,12 @@ MARKET_FEE_TIERED = {
# board lots of 100.
MARKET_LOT_SIZE = {"us": 1, "cn": 100}
# A position already held is resized only once it is this far from its target
# (relative to the target value); entries and exits always trade. Without the
# band the books paid a ticket fee almost daily to trade weight drift that
# the backtests never trade.
RESIZE_BAND = 0.10
# Default live capital per market, in the market's own currency.
MONITOR_CAPITAL = {"us": 10_000.0, "cn": 70_000.0}
@@ -226,6 +234,16 @@ STRATEGY_REGISTRY = {
"recovery_quality_blend_top10": lambda **kw: RecoveryQualityBlendStrategy(top_n=10),
"improved_mom_quality_top20": lambda **kw: ImprovedMomentumQualityStrategy(top_n=20),
"improved_mom_quality_top10": lambda **kw: ImprovedMomentumQualityStrategy(top_n=10),
# --- Robust momentum (2026-09 PIT research; bar strategies) ---
"overnight_recovery_top10": lambda **kw: RobustMomentumStrategy(
"overnight_recovery", top_n=10, rebal_freq=10, buffer=2.0,
),
"lowrisk_momentum_top15": lambda **kw: RobustMomentumStrategy(
"lowrisk_momentum", top_n=15, rebal_freq=10, buffer=2.0,
),
"lowbeta_sharpe_mom_top20": lambda **kw: RobustMomentumStrategy(
"lowbeta_sharpe_mom", top_n=20, rebal_freq=10, buffer=1.0,
),
# --- TrendRiderV6: stock-picking + V5 regime engine ---
"trend_rider_v6": lambda **kw: TrendRiderV6(),
"trend_rider_v6_top10": lambda **kw: TrendRiderV6(top_n=10),
@@ -273,6 +291,12 @@ def strategy_extra_tickers(strategy_name: str) -> list[str]:
return list(MIXED_STRATEGY_EXTRA_TICKERS.get(strategy_name.removeprefix("sim_"), []))
def strategy_fields(strategy_name: str) -> tuple[str, ...]:
"""Bar fields a strategy reads; close-price strategies read ("close",)."""
strategy = STRATEGY_REGISTRY[strategy_name.removeprefix("sim_")]()
return tuple(strategy.fields) if isinstance(strategy, BarStrategy) else ("close",)
def strategy_supports_market(strategy_name: str, market: str) -> bool:
"""ETF-based strategies trade USD-listed ETFs, so they only run in the US book."""
base_name = strategy_name.removeprefix("sim_")
@@ -351,14 +375,15 @@ def load_strategy_prices(market: str, strategy_name: str, tickers: list[str],
stale forward-filled price from a real one.
"""
data_market = strategy_data_market(market, strategy_name)
fields = ["close", "open"] if with_open else ["close"]
fields += [f for f in strategy_fields(strategy_name) if f not in fields]
if refresh:
request = price_request(market, list(tickers) + [benchmark])
result = data_manager.update(data_market, request, with_open=with_open)
close, open_data = result if with_open else (result, None)
panels = data_manager.update(data_market, request, fields=fields)
else:
close = data_manager.load(data_market, "close")
open_data = (data_manager._fill_open(close, data_manager.load_raw(data_market, "open"))
if with_open else None)
panels = data_manager.load_fields(data_market, fields)
close = panels["close"]
open_data = panels["open"] if with_open else None
close_raw = data_manager.load_raw(data_market, "close").reindex(
index=close.index, columns=close.columns,
)
@@ -438,85 +463,102 @@ def portfolio_value(holdings: dict, prices: dict, cash: float) -> float:
return cash + stock_value
def generate_target_weights(strategy, open_data: pd.DataFrame, target_date) -> dict:
"""
Run strategy on full open price history, extract target_date's weights.
def strategy_bars(market: str, strategy_name: str, tickers: list[str],
close: pd.DataFrame, open_data: pd.DataFrame | None) -> dict | None:
"""Stored bar panels a BarStrategy reads (refreshed by load_strategy_prices),
aligned to ``close``; None for close-price strategies."""
fields = strategy_fields(strategy_name)
if not isinstance(STRATEGY_REGISTRY[strategy_name.removeprefix("sim_")](), BarStrategy):
return None
stored = data_manager.load_fields(strategy_data_market(market, strategy_name),
[f for f in fields if f not in ("close", "open")])
bars = {"close": close[tickers]}
if "open" in fields:
bars["open"] = open_data[tickers]
for f in fields:
if f not in bars:
bars[f] = stored[f].reindex(index=close.index, columns=tickers)
return bars
Strategies have an internal shift(1) designed for close prices: weights[t]
uses data up to t-1. With open prices (observable same-day), we want the
signal that uses data up to today. To get this, we append a dummy future
row so the strategy computes one extra day of signals, then we read that
last row — which incorporates today's open prices.
"""
# Append a dummy row one day after the last date so the strategy's
# shift(1) produces a valid signal row that uses target_date's open
dummy_date = open_data.index[-1] + pd.Timedelta(days=1)
dummy_row = pd.DataFrame(
[open_data.iloc[-1].values],
index=[dummy_date],
columns=open_data.columns,
)
extended = pd.concat([open_data, dummy_row])
weights = strategy.generate_signals(extended)
def bar_session_weights(strategy: BarStrategy, bars: dict, days: pd.DatetimeIndex) -> pd.DataFrame:
"""Targets traded at the close of each day in ``days``: row d uses bars through d-1."""
hist = {f: b.loc[:days[-1]] for f, b in bars.items()}
close = hist["close"]
if days[-1] not in close.index:
# morning run before today's bar exists: add an empty row for today
hist = {f: pd.concat([b, pd.DataFrame(np.nan, index=[days[-1]], columns=b.columns)])
for f, b in hist.items()}
return strategy.generate_signals(hist).reindex(days).fillna(0.0)
# The dummy_date row in weights now uses data up to target_date (the real last day)
# Read the dummy row — that's today's actual signal
if dummy_date in weights.index:
row = weights.loc[dummy_date]
elif target_date in weights.index:
# Fallback: use target_date row (which uses data up to target_date - 1)
row = weights.loc[target_date]
else:
idx = weights.index.get_indexer([target_date], method="ffill")
if idx[0] >= 0:
row = weights.iloc[idx[0]]
def _allocate_lots(desired: dict, holdings: dict, prices: dict, cash: float,
lot_size: int, reserve: float) -> dict:
"""Whole-lot share targets: round each move toward zero, then spend the
remaining cash one lot at a time on the names furthest below target,
as long as the lot brings them nearer (nearest-lot rounding)."""
final, spend = {}, 0.0
for t, want in desired.items():
cur = holdings.get(t, 0.0)
if want <= 0:
final[t] = 0.0 # full exit, odd lot included
else:
return {}
# Return only non-zero weights
return {t: float(w) for t, w in row.items() if w > 1e-6}
final[t] = cur + int((want - cur) / lot_size) * lot_size
spend += (final[t] - cur) * prices[t]
budget = cash - spend - reserve
short = sorted(((desired[t] - final[t]) * prices[t], t) for t in desired if desired[t] > 0)
for gap, t in reversed(short):
lot_value = lot_size * prices[t]
if gap > 0.5 * lot_value and lot_value <= budget:
final[t] += lot_size
budget -= lot_value
return final
def compute_trades(holdings: dict, cash: float, target_weights: dict,
prices: dict, min_trade_value: float = 50.0,
integer_shares: bool = False, lot_size: int = 1,
frozen: set | None = None) -> list[dict]:
frozen: set | None = None, resize_band: float = 0.0) -> list[dict]:
"""
Compute trades needed to move from current holdings to target weights.
If integer_shares=True, share deltas are rounded toward zero to whole
multiples of ``lot_size`` (e.g. 100-share A-share board lots); a position
whose target is zero is always sold in full, odd lot included.
If integer_shares=True, positions move in whole multiples of ``lot_size``
(e.g. 100-share A-share board lots): each move is rounded toward zero and
leftover cash then buys one more lot for the names furthest below target
when that lot brings them nearer; a position whose target is zero is
always sold in full, odd lot included.
A held position with a nonzero target is not resized while it is within
``resize_band`` (fraction of its target value) of the target.
Tickers in ``frozen`` (no fresh price today) are neither bought nor sold.
Returns list of {ticker, shares_delta, direction, est_value}.
"""
total = portfolio_value(holdings, prices, cash)
raw = []
frozen = frozen or set()
all_tickers = set(list(target_weights.keys()) + list(holdings.keys()))
for ticker in sorted(all_tickers):
desired = {}
for ticker in sorted(set(target_weights) | set(holdings)):
if ticker in frozen:
continue
target_weight = target_weights.get(ticker, 0.0)
price = prices.get(ticker, 0.0)
if price <= 0:
continue
target_weight = target_weights.get(ticker, 0.0)
target_value = total * target_weight
target_shares = target_value / price
current_shares = holdings.get(ticker, 0.0)
if (current_shares > 0 and target_weight > 0
and abs(target_value - current_shares * price) < resize_band * target_value):
continue
desired[ticker] = target_value / price
targets = (_allocate_lots(desired, holdings, prices, cash, lot_size,
reserve=0.002 * total + 10.0 * len(desired))
if integer_shares else desired)
raw = []
for ticker, target_shares in targets.items():
price = prices[ticker]
current_shares = holdings.get(ticker, 0.0)
delta = target_shares - current_shares
if integer_shares:
if target_weight <= 0 and current_shares > 0:
delta = -current_shares # full exit may include an odd lot
else:
# Round toward zero: never overshoot cash or over-sell.
delta = int(delta / lot_size) * lot_size
trade_value = abs(delta * price)
if trade_value < min_trade_value:
continue
@@ -735,25 +777,32 @@ def _strategy_fees(args, market: str) -> dict:
def _signals_for_sessions(strategy_name: str, open_data: pd.DataFrame,
days: pd.DatetimeIndex) -> pd.DataFrame:
"""Target weights for many sessions in one pass: row ``d`` uses opens up to ``d``.
days: pd.DatetimeIndex, bars: dict | None = None) -> pd.DataFrame:
"""Target weights traded at the close of each day in ``days``, in one pass.
Equivalent to ``generate_target_weights`` per day for causal strategies
(weights at t never depend on data after t), which every registered
strategy must be.
A close-price strategy reads opens as its prices: the row for ``d`` uses
opens up to ``d`` (a dummy row after the last open makes the strategy's
internal shift(1) yield that row). A BarStrategy uses completed bars
through d-1. Every registered strategy must be causal (weights at t never
depend on data after t), so one pass equals running each day separately.
"""
strategy = STRATEGY_REGISTRY[strategy_name]()
if isinstance(strategy, BarStrategy):
return bar_session_weights(strategy, bars, days)
hist = open_data.loc[:days[-1]]
dummy = pd.DataFrame([hist.iloc[-1].values], index=[hist.index[-1] + pd.Timedelta(days=1)],
columns=hist.columns)
weights = STRATEGY_REGISTRY[strategy_name]().generate_signals(pd.concat([hist, dummy]))
weights = strategy.generate_signals(pd.concat([hist, dummy]))
return weights.shift(-1).reindex(days).fillna(0.0)
def _run_session(state: dict, strategy_name: str, market: str, day: pd.Timestamp,
tickers: list[str], benchmark: str, close_data: pd.DataFrame,
open_data: pd.DataFrame, close_raw: pd.DataFrame, fees: dict,
integer_shares: bool, signal_row: pd.Series | None = None) -> dict:
"""Signal on ``day``'s open, execute at ``day``'s close, record the day.
integer_shares: bool, signal_row: pd.Series | None = None,
bars: dict | None = None) -> dict:
"""Signal on ``day``'s open (a BarStrategy: bars through the previous
session), execute at ``day``'s close, record the day.
Tickers without a real closing print on ``day`` (forward-filled price) are
valued but not traded. ``signal_row`` supplies precomputed target weights.
@@ -761,10 +810,9 @@ def _run_session(state: dict, strategy_name: str, market: str, day: pd.Timestamp
day_str = str(day.date())
lot_size = MARKET_LOT_SIZE.get(market, 1) if integer_shares else 1
if signal_row is None:
strategy = STRATEGY_REGISTRY[strategy_name]()
target_weights = generate_target_weights(strategy, open_data.loc[:day, tickers], day)
else:
target_weights = {t: float(w) for t, w in signal_row.items() if w > 1e-6}
signal_row = _signals_for_sessions(strategy_name, open_data[tickers],
pd.DatetimeIndex([day]), bars).loc[day]
target_weights = {t: float(w) for t, w in signal_row.items() if w > 1e-6}
all_held = sorted(set(target_weights) | set(state["holdings"]))
close_prices = _with_last_known_prices(
@@ -778,6 +826,7 @@ def _run_session(state: dict, strategy_name: str, market: str, day: pd.Timestamp
state["holdings"], state["cash"], target_weights, close_prices,
min_trade_value=max(50, pre_value * 0.001),
integer_shares=integer_shares, lot_size=lot_size, frozen=frozen,
resize_band=RESIZE_BAND,
)
n_log = len(state["trade_log"])
execute_trades(state, trades, close_prices, trade_date=day_str,
@@ -840,9 +889,11 @@ def cmd_morning(args):
print(f" Strategy: {strategy_name} | Market: {market.upper()}")
print(f"{'='*60}")
# Run strategy on open prices
strategy = STRATEGY_REGISTRY[strategy_name]()
target_weights = generate_target_weights(strategy, open_data[tickers], today)
# Run strategy on open prices (a BarStrategy: on completed bars)
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
row = _signals_for_sessions(strategy_name, open_data[tickers],
pd.DatetimeIndex([today]), bars).loc[today]
target_weights = {t: float(w) for t, w in row.items() if w > 1e-6}
# Get today's open prices for trade computation
open_prices = get_prices_for_date(
@@ -854,7 +905,8 @@ def cmd_morning(args):
trades = compute_trades(state["holdings"], state["cash"], target_weights,
open_prices, min_trade_value=max(50, total * 0.001),
integer_shares=args.integer_shares,
lot_size=MARKET_LOT_SIZE.get(market, 1))
lot_size=MARKET_LOT_SIZE.get(market, 1),
resize_band=RESIZE_BAND)
# Store pending
state["pending_trades"] = {
@@ -1335,18 +1387,8 @@ def cmd_simulate(args):
# Pre-compute strategy weights for the full period (much faster than per-day)
# Append dummy row so shift(1) inside strategy produces valid last-day signal
strategy = STRATEGY_REGISTRY[strategy_name]()
open_tickers = open_data[tickers]
dummy_date = open_tickers.index[-1] + pd.Timedelta(days=1)
dummy_row = pd.DataFrame(
[open_tickers.iloc[-1].values], index=[dummy_date], columns=open_tickers.columns
)
extended = pd.concat([open_tickers, dummy_row])
raw_weights = strategy.generate_signals(extended)
# Strategy's shift(1): raw_weights[t] uses data up to t-1
# For date t, the signal using open[t] is in raw_weights[t+1]
# So shift(-1) to align: full_weights[t] = signal using open[t]
full_weights = raw_weights.shift(-1).fillna(0.0)
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
full_weights = _signals_for_sessions(strategy_name, open_data[tickers], trading_days, bars)
print(f"\n{'='*70}")
print(f" SIMULATION: {strategy_name} | {market.upper()}")
@@ -1385,6 +1427,7 @@ def cmd_simulate(args):
state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares, lot_size=lot_size,
resize_band=RESIZE_BAND,
)
execute_trades(state, trades, close_prices,
@@ -1710,13 +1753,16 @@ def cmd_monitor(args):
def refresh_panels(market: str, strategy_names: list[str], label: str) -> None:
"""Refresh every price panel the strategies read, once, for the union of their tickers."""
requests: dict[str, set[str]] = {}
fields: dict[str, set[str]] = {}
for name in strategy_names:
tickers, benchmark = strategy_universe(market, name)
requests.setdefault(strategy_data_market(market, name), set()).update(
data_market = strategy_data_market(market, name)
requests.setdefault(data_market, set()).update(
price_request(market, list(tickers) + [benchmark]))
fields.setdefault(data_market, {"close", "open"}).update(strategy_fields(name))
for data_market, request in requests.items():
print(f"[{label}] Refreshing {data_market} panel for {len(request)} tickers")
data_manager.update(data_market, sorted(request), with_open=True)
data_manager.update(data_market, sorted(request), fields=sorted(fields[data_market]))
def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = None,
@@ -1759,12 +1805,12 @@ def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = Non
f"{todo[0].date()} .. {todo[-1].date()}")
fees = _strategy_fees(args, market)
signals = (_signals_for_sessions(strategy_name, open_data[tickers], todo)
if len(todo) > 1 else None)
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
signals = _signals_for_sessions(strategy_name, open_data[tickers], todo, bars)
for day in todo:
r = _run_session(state, strategy_name, market, day, tickers, benchmark,
close_data, open_data, close_raw, fees, args.integer_shares,
signal_row=signals.loc[day] if signals is not None else None)
signal_row=signals.loc[day], bars=bars)
frozen = f" | no fresh price: {', '.join(r['frozen'])}" if r["frozen"] else ""
print(f"[{label}] {r['date']} {strategy_name:<34} value {r['value']:>12,.2f} "
f"({r['daily_return']:+.2%}) trades {r['trades']:>2} "