You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

392 lines
14 KiB
Python

"""BacktestRunner: main simulation class and CLI entry point."""
from __future__ import annotations
import argparse
import datetime as dt
import subprocess
import sys
from pathlib import Path
from typing import Any
from libs.backtest.allocator import build_planned_order
from libs.backtest.artifacts import create_run_directory, write_all_artifacts
from libs.backtest.domain import (
BacktestConfig,
Candidate,
DailyPortfolioState,
ExperimentManifest,
ExperimentResult,
FilledTrade,
MetricsBundle,
OpenPosition,
PositionStatus,
)
from libs.backtest.execution import (
simulate_entry,
simulate_exit,
simulate_kill_switch_exit,
update_trailing_stop,
)
from libs.backtest.manifests import generate_run_id, load_manifest, resolve_config
from libs.backtest.metrics import build_metrics_bundle
from libs.backtest.selector import select_candidates
from libs.backtest.snapshot_store import SnapshotStore
from libs.common.logging import get_logger
from libs.common.time_utils import utc_now
logger = get_logger(__name__)
_KILL_SWITCH_DRAWDOWN_PCT = 25.0
def _get_git_commit_hash() -> str:
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True, text=True, timeout=5,
)
return result.stdout.strip() or "unknown"
except Exception:
return "unknown"
class BacktestRunner:
"""Event-driven backtester simulation engine."""
def __init__(
self,
manifest: ExperimentManifest,
config: BacktestConfig,
store: SnapshotStore,
initial_equity: float = 100_000.0,
) -> None:
self.manifest = manifest
self.config = config
self.store = store
self.initial_equity = initial_equity
# Simulation state
self._equity = initial_equity
self._cash = initial_equity
self._open_positions: list[OpenPosition] = []
self._closed_trades: list[FilledTrade] = []
self._equity_curve: list[DailyPortfolioState] = []
self._candidate_map: dict[str, Candidate] = {} # trade_id → candidate
# Stats
self._total_candidates_seen = 0
self._total_orders_rejected = 0
self._peak_equity = initial_equity
self._realized_pnl = 0.0
self._daily_new_risk_used = 0.0
self._consecutive_losses = 0
self._cooldown_remaining = 0
self._kill_switch_triggered = False
def run(self, output_root: str | Path | None = None) -> ExperimentResult:
"""Execute the full simulation. Returns ExperimentResult."""
started_at = utc_now()
run_id = generate_run_id(self.config)
logger.info("backtest_start", run_id=run_id, strategy=self.config.strategy_name)
all_dates = self.store.all_execution_dates()
if not all_dates:
logger.warning("backtest_no_dates", run_id=run_id)
for date in all_dates:
self._simulate_day(date)
# Force-close any remaining open positions at end of backtest
if self._open_positions:
last_date = all_dates[-1] if all_dates else dt.date.today()
self._force_close_all(last_date, reason="end_of_backtest")
finished_at = utc_now()
metrics = build_metrics_bundle(
self._closed_trades, self._equity_curve, self._candidate_map
)
# Create run directory and write artifacts
run_dir = None
artifact_paths: dict[str, str] = {}
if output_root is not None:
run_dir = create_run_directory(output_root, run_id)
git_hash = _get_git_commit_hash()
artifact_paths = write_all_artifacts(
run_dir=run_dir,
run_id=run_id,
manifest=self.manifest,
config=self.config,
metrics=metrics,
trades=self._closed_trades,
equity_curve=self._equity_curve,
open_positions=self._open_positions,
candidate_map=self._candidate_map,
started_at=started_at,
finished_at=finished_at,
git_hash=git_hash,
total_trading_days=len(self._equity_curve),
total_candidates_seen=self._total_candidates_seen,
total_orders_rejected=self._total_orders_rejected,
)
logger.info(
"backtest_complete",
run_id=run_id,
trades=len(self._closed_trades),
days=len(self._equity_curve),
)
return ExperimentResult(
run_id=run_id,
manifest=self.manifest,
resolved_config=self.config,
metrics=metrics,
artifact_paths=artifact_paths,
started_at=started_at,
finished_at=finished_at,
total_trading_days=len(self._equity_curve),
total_candidates_seen=self._total_candidates_seen,
total_orders_rejected=self._total_orders_rejected,
)
def _simulate_day(self, date: dt.date) -> None:
"""Simulate a single trading day."""
# Reset daily risk tracker
self._daily_new_risk_used = 0.0
# Decrement cooldown
if self._cooldown_remaining > 0:
self._cooldown_remaining -= 1
# Increment days_held for all open positions
for pos in self._open_positions:
pos.days_held += 1
# --- EXITS FIRST (using today's OHLCV) ---
newly_closed: list[FilledTrade] = []
still_open: list[OpenPosition] = []
for pos in self._open_positions:
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
# Kill switch: force close
if self._kill_switch_triggered:
trade = simulate_kill_switch_exit(pos, bar, date, self.config.execution)
newly_closed.append(trade)
continue
if bar is None:
# Missing bar — hold position (do not impute zero)
still_open.append(pos)
continue
# Update trailing stop if configured
if self.config.execution.trailing_model:
update_trailing_stop(pos, bar)
trade = simulate_exit(pos, bar, self.config.execution, date)
if trade is not None:
newly_closed.append(trade)
else:
still_open.append(pos)
# Process closed trades
for trade in newly_closed:
self._closed_trades.append(trade)
self._realized_pnl += trade.net_pnl
self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
# Track consecutive losses for cooldown
if trade.net_pnl < 0:
self._consecutive_losses += 1
else:
self._consecutive_losses = 0
if (
self.config.risk.cooldown_after_loss_streak > 0
and self._consecutive_losses >= self.config.risk.cooldown_after_loss_streak
):
self._cooldown_remaining = self.config.risk.cooldown_days
self._consecutive_losses = 0
self._open_positions = still_open
# --- Compute current equity for kill-switch check ---
unrealized = self._compute_unrealized_pnl(date)
self._equity = self._cash + unrealized
self._peak_equity = max(self._peak_equity, self._equity)
drawdown_pct = (
(self._peak_equity - self._equity) / self._peak_equity * 100.0
if self._peak_equity > 0
else 0.0
)
if drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT and not self._kill_switch_triggered:
logger.warning("kill_switch_triggered", date=str(date), drawdown_pct=drawdown_pct)
self._kill_switch_triggered = True
# --- ENTRIES (only if kill switch not triggered) ---
if not self._kill_switch_triggered:
raw_rows = self.store.get_candidates_for_date(date)
self._total_candidates_seen += len(raw_rows)
portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized)
candidates = select_candidates(
raw_rows, self.config.universe, self.config.signal
)
for candidate in candidates:
plan = build_planned_order(
candidate=candidate,
portfolio_state=portfolio_state,
open_positions=self._open_positions,
config=self.config,
cooldown_remaining=self._cooldown_remaining,
)
if plan.skip_reason is not None:
self._total_orders_rejected += 1
logger.debug(
"order_rejected",
symbol=candidate.symbol,
reason=plan.skip_reason,
date=str(date),
)
continue
bar = self.store.get_bar(candidate.symbol, date)
pos = simulate_entry(plan, bar, self.config.execution)
if pos is not None:
self._open_positions.append(pos)
self._cash -= pos.entry_price * pos.shares_total
self._daily_new_risk_used += plan.risk_dollars
# Update portfolio state for next candidate in same day
portfolio_state = self._build_portfolio_state(
date, drawdown_pct, self._compute_unrealized_pnl(date)
)
# --- Record daily equity curve snapshot ---
unrealized_final = self._compute_unrealized_pnl(date)
self._equity = self._cash + unrealized_final
self._peak_equity = max(self._peak_equity, self._equity)
final_drawdown = (
(self._peak_equity - self._equity) / self._peak_equity * 100.0
if self._peak_equity > 0
else 0.0
)
self._equity_curve.append(
DailyPortfolioState(
date=date,
equity=self._equity,
cash_available=self._cash,
gross_exposure=sum(
p.entry_price * p.shares_open for p in self._open_positions
),
net_exposure=sum(
p.entry_price * p.shares_open for p in self._open_positions
),
reserved_risk_budget=self._daily_new_risk_used,
unrealized_pnl=unrealized_final,
realized_pnl=self._realized_pnl,
open_positions=[p.position_id for p in self._open_positions],
daily_new_risk_used=self._daily_new_risk_used,
peak_equity=self._peak_equity,
current_drawdown_pct=final_drawdown,
)
)
def _compute_unrealized_pnl(self, date: dt.date) -> float:
total = 0.0
for pos in self._open_positions:
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
if bar and bar.get("close"):
total += (float(bar["close"]) - pos.entry_price) * pos.shares_open
return total
def _build_portfolio_state(
self,
date: dt.date,
drawdown_pct: float,
unrealized: float,
) -> DailyPortfolioState:
return DailyPortfolioState(
date=date,
equity=self._equity,
cash_available=self._cash,
gross_exposure=sum(
p.entry_price * p.shares_open for p in self._open_positions
),
net_exposure=sum(
p.entry_price * p.shares_open for p in self._open_positions
),
reserved_risk_budget=self._daily_new_risk_used,
unrealized_pnl=unrealized,
realized_pnl=self._realized_pnl,
open_positions=[p.position_id for p in self._open_positions],
daily_new_risk_used=self._daily_new_risk_used,
peak_equity=self._peak_equity,
current_drawdown_pct=drawdown_pct,
)
def _force_close_all(self, date: dt.date, reason: str = "force_close") -> None:
"""Close all open positions (end of backtest or kill switch)."""
for pos in list(self._open_positions):
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
trade = simulate_kill_switch_exit(pos, bar, date, self.config.execution)
self._closed_trades.append(trade)
self._realized_pnl += trade.net_pnl
self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
self._open_positions = []
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _build_store(
manifest: ExperimentManifest,
config: BacktestConfig,
split_name: str,
snapshot_dir_override: str | None = None,
) -> SnapshotStore:
from libs.common.config import get_settings
s = get_settings()
snapshot_dir = Path(snapshot_dir_override or s.parquet_dir) / config.dataset_snapshot_id
return SnapshotStore.load(
snapshot_dir=snapshot_dir,
split_name=split_name,
oracle_url=s.stock_oracle_url,
db_dsn=s.postgres_dsn,
)
def main() -> None:
parser = argparse.ArgumentParser(description="ACE-F Backtester")
parser.add_argument("--manifest", required=True, help="Path to experiment manifest JSON")
parser.add_argument("--snapshot-id", help="Override dataset_snapshot_id")
parser.add_argument("--split", default="train", help="Split name (train/valid/test)")
parser.add_argument("--output-root", default="./runs", help="Output root directory")
parser.add_argument("--initial-equity", type=float, default=100_000.0)
parser.add_argument("--config-root", default=".", help="Root dir for resolving config paths")
args = parser.parse_args()
manifest = load_manifest(args.manifest)
config = resolve_config(manifest, config_root=args.config_root, snapshot_id_override=args.snapshot_id)
store = _build_store(manifest, config, args.split)
runner = BacktestRunner(
manifest=manifest,
config=config,
store=store,
initial_equity=args.initial_equity,
)
result = runner.run(output_root=args.output_root)
print(f"Run complete: {result.run_id}")
print(f"Trades: {result.metrics.trade_count}")
if result.metrics.total_return_pct is not None:
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
if __name__ == "__main__":
main()