Files
quant-trading/main.py
Gahow Wang cd0721eada fix(backtest): settle delisted US holdings (engine v3), PIT membership before 2011, registry causality test
- execution/trader: a held US stock with no print for 5 sessions that has left
  the index is sold at its last print (execution.SETTLE_AFTER_SESSIONS), in both
  the batch replay and the live session ledger. Acquired names used to stay at
  their last mark forever: lowrisk_top20 ended 2017-12..2026-10 with 11 of 26
  dead positions, and CAGR rises 1.7-5.5pp once they settle. ENGINE_VERSION
  cash-ledger-pit-v3; the API cache key follows.
- universe_history: current members whose pre-2011 addition the Wikipedia change
  log missed now start at the list page's "Date added" (80 of 94 current members
  added 2001-2010 were open-start, e.g. ISRG, MA, BKNG). Membership from 2016 on
  is unchanged; the research panel cache now keys on the membership intervals.
- research/lab/registered: pure-ETF strategies replay on the ETF panel (they ran
  as all-cash books or crashed); prepare_strategy refuses an ETF strategy that
  lacks its ETF universe.
- research/lab/factors: trend_hzz uses a month's regression betas only from the
  session its forward returns end (short months read 1-2 sessions ahead).
- tests: every registered strategy (US and CN) is truncation-invariant on the
  production signal path; batch and live ledgers settle a delisting identically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:21:49 -07:00

309 lines
14 KiB
Python

import argparse
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import data_manager
import factor_attribution
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
from strategies.mean_reversion import MeanReversionStrategy
from strategies.momentum import MomentumStrategy
from strategies.momentum_quality import MomentumQualityStrategy
from strategies.multi_factor import MultiFactorStrategy
from strategies.recovery_momentum import RecoveryMomentumStrategy
from strategies.trend_following import TrendFollowingStrategy
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,
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,
*, 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(
strategy,
data: pd.DataFrame,
initial_capital: float = 100_000,
transaction_cost: float = 0.001,
fixed_fee: float = 0.0,
fee_base: float = 0.0,
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:
"""
Cash-funded backtest with next-session execution and drifting holdings.
Parameters
----------
strategy : Strategy
Any class implementing generate_signals(data) → DataFrame of weights.
data : pd.DataFrame
Adjusted close prices, one column per asset.
initial_capital : float
Starting portfolio value.
transaction_cost : float
One-way cost per unit of turnover (e.g. 0.001 = 10 bps).
fixed_fee : float
Floor of the per-trade fee (e.g. 2.0 = $2 minimum per buy/sell).
With fee_per_share=0 (default), this is also the actual per-trade fee.
fee_base : float
Fixed component of a per-share tiered fee schedule. The actual
per-trade fee is ``max(fixed_fee, fee_base + fee_per_share * shares)``.
fee_per_share : float
Per-share variable component of the tiered fee (e.g. 0.009 = $0.009/share).
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, 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)
equity, _ = simulate_live_book(positions, data, initial_capital, transaction_cost,
fixed_fee, fee_base, fee_per_share, min_trade_value, resize_band)
return equity
# ---------------------------------------------------------------------------
# Visualization
# ---------------------------------------------------------------------------
def plot_results(results: pd.DataFrame) -> None:
"""Two-panel chart: equity curves (top) + drawdowns (bottom)."""
# Compute drawdowns
drawdowns = results.div(results.cummax()) - 1
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 9), sharex=True,
gridspec_kw={"height_ratios": [3, 1]})
for col in results.columns:
ax1.plot(results.index, results[col], label=col, linewidth=1.5)
ax1.set_title("Strategy Comparison — Equity Curves", fontsize=14)
ax1.set_ylabel("Portfolio Value ($)")
ax1.legend(loc="upper left")
ax1.grid(True, alpha=0.3)
for col in drawdowns.columns:
ax2.plot(drawdowns.index, drawdowns[col] * 100, label=col, linewidth=1.0)
ax2.set_title("Drawdowns")
ax2.set_ylabel("Drawdown (%)")
ax2.set_xlabel("Date")
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Run strategy backtest")
parser.add_argument(
"--market", choices=UNIVERSES.keys(), default="us",
help="Market universe to backtest (default: us)",
)
parser.add_argument(
"--capital", type=float, default=None,
help="Initial capital (default: 10000)",
)
parser.add_argument(
"--top-n", type=int, default=None,
help="Number of stocks for selective strategies (default: ~10%% of universe)",
)
parser.add_argument(
"--years", type=int, default=None,
help="Limit backtest to last N years of data",
)
parser.add_argument(
"--no-plot", action="store_true",
help="Skip plotting charts",
)
parser.add_argument(
"--fixed-fee", type=float, default=0.0,
help="Fixed dollar cost per trade, e.g. 2.0 means $2 per buy or sell",
)
parser.add_argument(
"--execution", choices=["close", "open-close"], default="close",
help="Execution mode: 'close' (default, signal & execute on close) or "
"'open-close' (signal on morning open, execute at close)",
)
parser.add_argument(
"--attribution", action="store_true",
help="Run factor attribution after performance metrics",
)
parser.add_argument(
"--attribution-model", choices=["capm", "ff5", "ff5plus", "all"], default="all",
help="Factor model selection for attribution output",
)
parser.add_argument(
"--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 = [] if args.offline else universe["fetch"]()
benchmark = universe["benchmark"]
benchmark_label = universe["benchmark_label"]
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 ---")
if args.fixed_fee > 0:
print(f"--- Fixed fee: ${args.fixed_fee:.2f} per trade ---")
if use_open:
print(f"--- Execution: open-close (signal on open, execute at close) ---")
# Build strategy tuples: (strategy, close_data, open_data_or_None)
open_tickers = open_data[tickers] if open_data is not None else None
open_all = open_data if open_data is not None else None
strategies = {
# --- Original strategies ---
"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),
# --- New strategies ---
"Mean Reversion": (MeanReversionStrategy(top_n=top_n), data[tickers], open_tickers),
"Trend Following": (TrendFollowingStrategy(ma_window=150, momentum_period=126, top_n=top_n), data[tickers], open_tickers),
"Dual Momentum": (DualMomentumStrategy(top_n=top_n), data[tickers], open_tickers),
"Momentum+Quality": (MomentumQualityStrategy(momentum_period=252, skip=21, top_n=top_n), data[tickers], open_tickers),
"Mom+InvVol": (AdaptiveMomentumStrategy(top_n=top_n), data[tickers], open_tickers),
"Recovery+Mom Top20": (RecoveryMomentumStrategy(top_n=min(20, top_n)), data[tickers], open_tickers),
"Recovery+Mom Top10": (RecoveryMomentumStrategy(top_n=10), data[tickers], open_tickers),
}
results: dict[str, pd.Series] = {}
for name, (strategy, strat_data, strat_open) in strategies.items():
print(f"\nRunning: {name}")
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),
member=member.reindex(index=days, columns=strat_data.columns, fill_value=True),
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"]])
bench = data[benchmark].reindex(days).ffill().dropna()
results[benchmark_label] = pd.concat([anchor, bench / bench.iloc[0] * initial_capital])
results_df = pd.DataFrame(results)
# --- Performance metrics ---
for name, equity in results_df.items():
eq = equity.dropna()
if len(eq) < 2:
print(f"\n {name}: insufficient data, skipping")
continue
metrics.summary(eq, name=name)
if args.attribution:
summary_df, loadings_df = factor_attribution.attribute_strategies(
results_df=results_df,
benchmark_label=benchmark_label,
benchmark=benchmark,
price_data=data,
market=args.market,
model_selection=args.attribution_model,
)
factor_attribution.print_attribution_summary(summary_df)
if args.attribution_export:
factor_attribution.export_attribution(summary_df, loadings_df, args.attribution_export)
print(f"Attribution CSVs written to {args.attribution_export}")
# --- Visualization ---
if not args.no_plot:
plot_results(results_df.dropna())
if __name__ == "__main__":
main()