diff --git a/apps/paper_trader/backtest_sim.py b/apps/paper_trader/backtest_sim.py new file mode 100644 index 0000000..9c74536 --- /dev/null +++ b/apps/paper_trader/backtest_sim.py @@ -0,0 +1,236 @@ +"""Paper-trading backtest simulator. + +Refactored to use the SAME BacktestRunner + SnapshotStore as +apps/backtester/run.py. This guarantees identical scoring, engine +matching, and position sizing between `fithia2 paper backtest` and +the research backtester. + +Previous implementation used PaperTradingEngine + EventDetector + +MockBroker which had different scoring functions, feature computation, +and data sources — causing divergent results. +""" +from __future__ import annotations + +import datetime as dt +import math +import statistics +import tempfile +from pathlib import Path +from typing import Any + +from libs.common.logging import get_logger + +logger = get_logger(__name__) + + +def run_backtest_session_sync( + session_name: str, + config_path: str, + initial_equity: float, + start_date: dt.date, + end_date: dt.date, +) -> dict[str, Any]: + """Run a single strategy using BacktestRunner (same as research backtester). + + Uses the existing Parquet snapshot + BacktestRunner pipeline so results + match `python -m apps.backtester.run --manifest ` exactly. + """ + from apps.backtester.run import ( + BacktestRunner, + _build_store, + _build_merged_snapshot_store, + load_manifest, + resolve_config, + ) + + manifest = load_manifest(config_path) + config = resolve_config(manifest) + + # Use merged store (train+valid+test) to cover the full date range + store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override=None) + + # Slice to requested date range + store = store.slice_by_date_range(start_date, end_date) + + runner = BacktestRunner( + manifest=manifest, + config=config, + store=store, + initial_equity=initial_equity, + split_name="paper_backtest", + ) + + # Run with temporary output directory + tmp_dir = tempfile.mkdtemp(prefix="paper_bt_") + try: + result = runner.run(output_root=tmp_dir) + except Exception: + result = runner.run(output_root=None) + + # Convert to paper backtest format + return _convert_from_runner(session_name, config_path, initial_equity, result, tmp_dir) + + +def _convert_from_runner( + session_name: str, + config_path: str, + initial_equity: float, + result: Any, + tmp_dir: str, +) -> dict[str, Any]: + """Convert BacktestRunner result to paper backtest output format.""" + equity_curve: list[dict] = [] + trades: list[dict] = [] + + # Load from Parquet artifacts + try: + import pyarrow.parquet as pq + import glob + import os + + run_dirs = sorted(glob.glob(os.path.join(tmp_dir, "bt_*"))) + if run_dirs: + run_dir = Path(run_dirs[0]) + + # Equity curve + eq_path = run_dir / "artifacts" / "daily_equity_curve.parquet" + if eq_path.exists(): + eq_df = pq.read_table(str(eq_path)).to_pandas() + for _, row in eq_df.iterrows(): + d = row.get("date") + if isinstance(d, str): + d = dt.date.fromisoformat(d[:10]) + equity_curve.append({ + "date": d, + "equity": float(row.get("equity", initial_equity)), + }) + + # Trade blotter + bl_path = run_dir / "artifacts" / "trade_blotter.parquet" + if bl_path.exists(): + bl_df = pq.read_table(str(bl_path)).to_pandas() + for _, row in bl_df.iterrows(): + entry_px = row.get("entry_price") + exit_px = row.get("exit_price") + shares = int(row.get("shares", 0)) + pnl_pct = float(row.get("pnl_pct", 0.0)) + pnl_dollar = pnl_pct * float(entry_px or 0) * shares if entry_px else 0.0 + + trades.append({ + "symbol": str(row.get("symbol", "")), + "entry_date": str(row.get("entry_date", "-")), + "exit_date": str(row.get("exit_date", "-")), + "entry_price": float(entry_px) if entry_px is not None else None, + "exit_price": float(exit_px) if exit_px is not None else None, + "shares": shares, + "pnl": pnl_dollar, + "reason": str(row.get("exit_reason", "-")), + "event_type": str(row.get("event_type", "-")), + "score": float(row.get("score", 0.0)), + "engine_id": str(row.get("engine_id", "")), + }) + except Exception as exc: + logger.warning("backtest_sim_artifact_load_failed", error=str(exc)) + + # Compute summary stats + final_equity = equity_curve[-1]["equity"] if equity_curve else initial_equity + total_return_pct = (final_equity - initial_equity) / initial_equity * 100 + + pnls = [t["pnl"] for t in trades] + wins = [p for p in pnls if p > 0] + win_rate = len(wins) / len(pnls) * 100 if pnls else 0.0 + + equities = [r["equity"] for r in equity_curve] + daily_returns = [ + (equities[i] - equities[i - 1]) / equities[i - 1] + for i in range(1, len(equities)) + if equities[i - 1] > 0 + ] + if len(daily_returns) >= 2: + mean_r = statistics.mean(daily_returns) + std_r = statistics.stdev(daily_returns) + sharpe = (mean_r / std_r) * math.sqrt(252) if std_r > 0 else 0.0 + else: + sharpe = 0.0 + + peak = initial_equity + max_dd_pct = 0.0 + for eq in equities: + if eq > peak: + peak = eq + dd = (peak - eq) / peak * 100 if peak > 0 else 0.0 + if dd > max_dd_pct: + max_dd_pct = dd + + return { + "session_name": session_name, + "config_path": config_path, + "initial_equity": initial_equity, + "equity_curve": equity_curve, + "trades": trades, + "all_entries": [], + "all_exits": [], + "summary": { + "return_pct": total_return_pct, + "final_equity": final_equity, + "max_dd_pct": max_dd_pct, + "trade_count": len(trades), + "win_rate": win_rate, + "sharpe": sharpe, + }, + } + + +async def run_backtest( + configs: list[str], + capital: float, + start_date: dt.date, + end_date: dt.date, + db_dsn: str, + oracle_url: str, + console=None, +) -> list[dict[str, Any]]: + """Run multiple strategies sequentially using BacktestRunner.""" + from libs.common.time_utils import is_trading_day + from libs.common.logging import configure_logging + + all_days = [ + start_date + dt.timedelta(days=i) + for i in range((end_date - start_date).days + 1) + ] + trading_days = [d for d in all_days if is_trading_day(d)] + + if not trading_days: + raise ValueError(f"No trading days found between {start_date} and {end_date}") + + if console: + console.print(f"[bold]Trading days:[/] {trading_days[0]} → {trading_days[-1]} ({len(trading_days)} days)") + console.print("[bold]Engine:[/] BacktestRunner (identical to research backtester)") + + configure_logging("WARNING") + + results = [] + for config_path in configs: + session_name = Path(config_path).stem + if console: + console.print(f"\n[bold cyan]Running:[/] {session_name}") + + result = run_backtest_session_sync( + session_name=session_name, + config_path=config_path, + initial_equity=capital, + start_date=start_date, + end_date=end_date, + ) + results.append(result) + + if console and result["summary"]["trade_count"] > 0: + s = result["summary"] + console.print( + f" Trades: {s['trade_count']}, " + f"Return: {s['return_pct']:+.2f}%, " + f"MaxDD: {s['max_dd_pct']:.2f}%, " + f"WR: {s['win_rate']:.0f}%" + ) + + return results