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), 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()