fix: unify backtest execution and add forward validation candidates

Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band.

Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
This commit is contained in:
2026-10-01 02:24:01 -07:00
parent 761725d2c8
commit 722cade974
45 changed files with 1561 additions and 1021 deletions

253
main.py
View File

@@ -10,6 +10,8 @@ import metrics
import universe_history as uh
from strategies.adaptive_momentum import AdaptiveMomentumStrategy
from strategies.base import BarStrategy
from signals import session_targets
from execution import simulate_book
from strategies.buy_and_hold import BuyAndHoldStrategy
from strategies.dual_momentum import DualMomentumStrategy
from strategies.inverse_vol import InverseVolatilityStrategy
@@ -27,96 +29,40 @@ from universe import UNIVERSES
# ---------------------------------------------------------------------------
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)
bars: dict[str, pd.DataFrame] | None = None,
eligible: pd.DataFrame | None = None) -> pd.DataFrame:
"""Weights held over (close t-1, close t], after a feasible prior close fill."""
if eligible is None and bars is not None:
eligible = bars.get("eligible")
return session_targets(strategy, data, open_data, bars, eligible).shift(1).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))
min_trade_value: float = 0.0, resize_band: float = 0.0,
*, fresh_prices: pd.DataFrame | None = None,
integer_shares: bool = False, lot_size: int = 1):
targets = held.reindex_like(data).shift(-1).fillna(0.0)
# No decision after the last row is represented by `held`; preserve the book.
result = simulate_book(targets.iloc[:-1], data.iloc[:-1], initial_capital,
fresh_prices=fresh_prices.iloc[:-1] if fresh_prices is not None else None,
transaction_cost=transaction_cost, fixed_fee=fixed_fee,
fee_base=fee_base, fee_per_share=fee_per_share,
min_trade_value=min_trade_value, resize_band=resize_band,
integer_shares=integer_shares, lot_size=lot_size)
traded = pd.DataFrame(0.0, index=data.index, columns=data.columns)
for trade in result["trade_log"]:
i = data.index.get_loc(pd.Timestamp(trade["date"]))
value = result["pretrade_equity"].iloc[i]
traded.iloc[i + 1, data.columns.get_loc(trade["ticker"])] += (
trade["value"] / value * (1 if trade["action"] == "BUY" else -1)) if value else 0.0
marks = data.ffill().iloc[-1].dropna().to_dict()
from execution import portfolio_value
last = portfolio_value(result["holdings"], marks,
float(result["cash"].iloc[-1]) if len(result["cash"]) else initial_capital)
equity = pd.concat([result["equity"], pd.Series([last], index=data.index[-1:])])
return equity, traded
def backtest(
@@ -133,7 +79,7 @@ def backtest(
resize_band: float = 0.0,
) -> pd.Series:
"""
Vectorized backtest.
Cash-funded backtest with next-session execution and drifting holdings.
Parameters
----------
@@ -173,62 +119,8 @@ def backtest(
Daily equity curve.
"""
positions = strategy_positions(strategy, data, open_data=open_data, bars=bars)
if open_data is not None:
equity, _ = simulate_live_book(
positions, data, initial_capital, transaction_cost, fixed_fee, fee_base,
fee_per_share, min_trade_value, resize_band,
)
return equity
# 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)
portfolio_returns -= turnover * transaction_cost
# Per-trade fee. Supports both flat ($2/trade) and tiered (IBKR-style)
# schedules: fee = max(fixed_fee, fee_base + fee_per_share * shares).
if fixed_fee > 0 or fee_base > 0 or fee_per_share > 0:
weight_changes = positions.diff().fillna(0.0)
equity_running = (1 + portfolio_returns).cumprod() * initial_capital
eq_prev = equity_running.shift(1).fillna(initial_capital)
if min_trade_value > 0:
threshold = np.maximum(min_trade_value, 0.001 * eq_prev.abs())
charged = weight_changes.abs().mul(eq_prev.abs(), axis=0).ge(threshold, axis=0)
else:
charged = weight_changes.abs() > 1e-8
if fee_per_share > 0:
# Convert per-ticker weight change into share count traded.
# dollar_traded[i, t] = |w[i,t] - w[i,t-1]| * equity[t-1]
# shares_traded[i, t] = dollar_traded / price[i, t]
dollar_traded = weight_changes.abs().mul(eq_prev, axis=0)
shares_traded = dollar_traded.div(data).replace(
[np.inf, -np.inf], 0.0,
).fillna(0.0)
# The per-share schedule applies to US-listed shares only.
per_share = pd.Series(fee_per_share, index=data.columns)
per_share[[c for c in data.columns if str(c).endswith(".HK")]] = 0.0
per_trade_fee = (fee_base + shares_traded.mul(per_share, axis=1)).clip(
lower=fixed_fee,
)
per_trade_fee = per_trade_fee.where(charged, 0.0)
daily_fee = per_trade_fee.sum(axis=1)
else:
n_trades = charged.sum(axis=1)
daily_fee = n_trades * fixed_fee
fee_impact = daily_fee / eq_prev
portfolio_returns -= fee_impact
equity = (1 + portfolio_returns).cumprod() * initial_capital
# A portfolio that loses everything stays at zero; compounding negative
# equity would otherwise produce meaningless values and metrics.
ruined = (equity <= 0).cummax()
if ruined.any():
equity = equity.where(~ruined, 0.0)
equity, _ = simulate_live_book(positions, data, initial_capital, transaction_cost,
fixed_fee, fee_base, fee_per_share, min_trade_value, resize_band)
return equity
@@ -309,52 +201,34 @@ def main() -> None:
"--attribution-export", default=None,
help="Directory to export factor attribution CSVs",
)
parser.add_argument("--offline", action="store_true", help="Use local prices and membership without refreshing")
args = parser.parse_args()
initial_capital = args.capital if args.capital is not None else 10_000
use_open = args.execution == "open-close"
universe = UNIVERSES[args.market]
tickers = universe["fetch"]()
tickers = [] if args.offline else universe["fetch"]()
benchmark = universe["benchmark"]
benchmark_label = universe["benchmark_label"]
# PIT universe: include all historical index members for US market
pit_intervals = None
if args.market == "us":
pit_intervals = uh.load_sp500_history()
historical_tickers = uh.all_tickers_ever(pit_intervals)
all_tickers = sorted(set(tickers + historical_tickers + [benchmark]))
print(f"--- PIT universe: {len(all_tickers)} tickers (current + historical members) ---")
else:
all_tickers = sorted(set(tickers + [benchmark]))
result = data_manager.update(args.market, all_tickers, with_open=use_open)
if use_open:
data, open_data = result
else:
data = result
open_data = None
if args.years:
cutoff = data.index[-1] - pd.DateOffset(years=args.years)
data = data[data.index >= cutoff]
if open_data is not None:
open_data = open_data[open_data.index >= cutoff]
print(f"--- Sliced to last {args.years} years: {data.index[0].date()} to {data.index[-1].date()} ---")
# Apply PIT mask: NaN out prices for non-member dates
if pit_intervals is not None:
print("--- Applying PIT membership mask (survivorship-bias fix) ---")
data = uh.mask_prices(data, pit_intervals)
if open_data is not None:
open_data = uh.mask_prices(open_data, pit_intervals)
# Filter tickers to only those with any valid data
if pit_intervals is not None:
tickers = [t for t in data.columns if t != benchmark and data[t].notna().any()]
else:
tickers = [t for t in tickers if t in data.columns]
print(f"--- Universe: {len(tickers)} stocks + {benchmark} benchmark ---")
membership = uh.pit_membership(args.market, pd.DatetimeIndex([pd.Timestamp.today()]), refresh=not args.offline)
all_tickers = sorted(set(tickers) | set(membership.columns) | {benchmark})
fields = ["close", "open", "volume"]
panels = (data_manager.load_fields(args.market, fields) if args.offline else
data_manager.update(args.market, all_tickers, fields=fields))
data = data_manager.load_raw(args.market).reindex_like(panels["close"])
sessions = data.index[data[benchmark].notna()]
data = data.loc[sessions]
open_data = panels["open"].reindex_like(data) if use_open else None
member = uh.pit_membership(args.market, data.index).reindex(columns=data.columns, fill_value=False)
member[benchmark] = True
tickers = sorted(t for t in member.columns if t != benchmark and member[t].any())
if len(data) <= 315:
raise ValueError("Need 315 pre-window trading sessions")
requested = data.index[-1] - pd.DateOffset(years=args.years) if args.years else data.index[0]
start = max(requested, data.index[315])
days = data.index[data.index >= start]
print(f"--- Scoring {days[0].date()} to {days[-1].date()}; prior history is retained ---")
top_n = args.top_n if args.top_n else max(5, len(tickers) // 10)
print(f"--- Selective strategies will pick top {top_n} stocks ---")
@@ -369,7 +243,7 @@ def main() -> None:
strategies = {
# --- Original strategies ---
"Buy & Hold (EW)": (BuyAndHoldStrategy(), data[tickers], open_tickers),
"Buy & Hold": (BuyAndHoldStrategy(asset=benchmark), data[[benchmark]], open_data[[benchmark]] if open_data is not None else None),
"Momentum": (MomentumStrategy(lookback=252, skip=21, top_n=top_n), data[tickers], open_tickers),
"Inverse Volatility": (InverseVolatilityStrategy(vol_window=20), data[tickers], open_tickers),
"Multi-Factor": (MultiFactorStrategy(tickers=tickers, benchmark=benchmark, top_n=top_n), data, open_all),
@@ -386,12 +260,19 @@ def main() -> None:
results: dict[str, pd.Series] = {}
for name, (strategy, strat_data, strat_open) in strategies.items():
print(f"\nRunning: {name}")
results[name] = backtest(strategy, strat_data, initial_capital=initial_capital,
fixed_fee=args.fixed_fee, open_data=strat_open)
targets = session_targets(strategy, strat_data, strat_open,
eligible=member.reindex_like(strat_data))
run = simulate_book(targets.loc[days], strat_data.loc[days], initial_capital,
previous_prices=strat_data.ffill().shift(1).loc[days],
volume=panels["volume"].reindex(index=days, columns=strat_data.columns),
market=args.market, integer_shares=True,
lot_size=100 if args.market == "cn" else 1,
fixed_fee=args.fixed_fee, min_trade_value=50, resize_band=.10)
anchor = pd.Series([initial_capital], index=[days[0] - pd.Timedelta(days=1)])
results[name] = pd.concat([anchor, run["equity"]])
# Add benchmark (normalized to same initial capital)
bench = data[benchmark].dropna()
results[benchmark_label] = (bench / bench.iloc[0]) * initial_capital
bench = data[benchmark].reindex(days).ffill().dropna()
results[benchmark_label] = pd.concat([anchor, bench / bench.iloc[0] * initial_capital])
results_df = pd.DataFrame(results)