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:
228
trader.py
228
trader.py
@@ -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} "
|
||||
|
||||
Reference in New Issue
Block a user