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:
130
main.py
130
main.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user