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.

692 lines
27 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""BacktestRunner: main simulation class and CLI entry point."""
from __future__ import annotations
import argparse
import datetime as dt
import json
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.backtest.splits import generate_walk_forward_windows
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,
split_name: str | None = None,
) -> None:
self.split_name = split_name
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
self._kill_switch_cooldown_remaining = 0
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)
exec_dates = self.store.all_execution_dates()
if not exec_dates:
logger.warning("backtest_no_dates", run_id=run_id)
# Iterate ALL trading days (not just candidate days) so stop/target/time
# exits are checked every day, not just on days with new candidates.
all_dates = self.store.all_trading_days()
# Record initial equity state (before any trades)
if all_dates:
self._equity_curve.append(
DailyPortfolioState(
date=all_dates[0],
equity=self.initial_equity,
cash_available=self.initial_equity,
gross_exposure=0.0,
net_exposure=0.0,
reserved_risk_budget=0.0,
unrealized_pnl=0.0,
realized_pnl=0.0,
open_positions=[],
daily_new_risk_used=0.0,
peak_equity=self.initial_equity,
current_drawdown_pct=0.0,
)
)
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,
split_name=self.split_name,
)
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 cooldowns
if self._cooldown_remaining > 0:
self._cooldown_remaining -= 1
if self._kill_switch_cooldown_remaining > 0:
self._kill_switch_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) ---
# Build position → candidate lookup for attribution mapping
pos_to_candidate = {pos.position_id: pos.plan.candidate for pos in self._open_positions}
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,
self.config.execution.trailing_model,
warmup_days=self.config.execution.trailing_warmup_days,
)
# Build effective execution config with per-event-type overrides
effective_exec = self.config.execution
evt_profile = self.config.get_event_profile(pos.plan.candidate.event_type)
if evt_profile and evt_profile.max_holding_days_override is not None:
effective_exec = self.config.execution.model_copy(
update={"max_holding_days": evt_profile.max_holding_days_override}
)
prev_status = pos.status
trade = simulate_exit(pos, bar, effective_exec, date)
if trade is not None:
newly_closed.append(trade)
# Partial exit: status just changed from ENTERED to PARTIALLY_EXITED
# Keep position open for remaining shares
if prev_status == PositionStatus.ENTERED and pos.status == PositionStatus.PARTIALLY_EXITED:
still_open.append(pos)
else:
still_open.append(pos)
# Process closed trades
for trade in newly_closed:
self._closed_trades.append(trade)
# Map trade to candidate for attribution
cand = pos_to_candidate.get(trade.position_id)
if cand:
self._candidate_map[trade.trade_id] = cand
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 ---
market_value = self._compute_positions_market_value(date)
unrealized = market_value - sum(
p.entry_price * p.shares_open for p in self._open_positions
)
self._equity = self._cash + market_value
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)
if self.config.risk.kill_switch_log_only:
logger.info("kill_switch_log_only_mode", date=str(date))
# Don't trigger — just observe
else:
self._kill_switch_triggered = True
if self.config.risk.backtest_mode == "research":
self._kill_switch_cooldown_remaining = self.config.risk.kill_switch_cooldown_days
# Research mode: reset kill switch after cooldown expires
# Reset peak_equity to current equity so drawdown restarts from 0
if (
self._kill_switch_triggered
and self.config.risk.backtest_mode == "research"
and self._kill_switch_cooldown_remaining <= 0
):
self._kill_switch_triggered = False
self._peak_equity = self._equity
drawdown_pct = 0.0
logger.info("kill_switch_reset", date=str(date))
# --- 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,
event_type_profiles=self.config.event_type_profiles or None,
)
macro_data = self.store.get_macro_for_date(date)
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,
macro_data=macro_data,
)
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 equity and portfolio state for next candidate
mv = self._compute_positions_market_value(date)
self._equity = self._cash + mv
ur = mv - sum(
p.entry_price * p.shares_open
for p in self._open_positions
)
portfolio_state = self._build_portfolio_state(
date, drawdown_pct, ur
)
# --- Record daily equity curve snapshot ---
market_value_final = self._compute_positions_market_value(date)
unrealized_final = market_value_final - sum(
p.entry_price * p.shares_open for p in self._open_positions
)
self._equity = self._cash + market_value_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_positions_market_value(self, date: dt.date) -> float:
"""Market value of all open positions using today's close.
For long: market_value = close * shares.
For short: market_value = (2 * entry - close) * shares.
This reflects that a short position gains when price falls:
the "value" of a short at entry is entry_price * shares,
and PnL = (entry - close) * shares, so effective value = entry + PnL = (2*entry - close).
Falls back to entry_price when bar is missing (assumes no change
rather than treating the position as worthless).
"""
total = 0.0
for pos in self._open_positions:
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
is_short = pos.plan.candidate.trade_direction == "short"
if bar and bar.get("close"):
close = float(bar["close"])
if is_short:
total += (2.0 * pos.entry_price - close) * pos.shares_open
else:
total += close * pos.shares_open
else:
total += pos.entry_price * pos.shares_open
return total
def _compute_unrealized_pnl(self, date: dt.date) -> float:
"""Unrealized PnL = market_value cost_basis."""
market_value = self._compute_positions_market_value(date)
cost_basis = sum(p.entry_price * p.shares_open for p in self._open_positions)
return market_value - cost_basis
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._candidate_map[trade.trade_id] = pos.plan.candidate
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
# Resolve scoring function from config
scoring_fn = None
if config.signal.scoring_model == "pead":
from libs.backtest.scoring import compute_pead_score
from functools import partial
scoring_fn = partial(
compute_pead_score,
reaction_threshold=config.signal.pead_reaction_threshold,
volume_threshold=config.signal.pead_volume_threshold,
)
return SnapshotStore.load(
snapshot_dir=snapshot_dir,
split_name=split_name,
oracle_url=s.stock_oracle_url,
db_dsn=s.postgres_dsn,
scoring_fn=scoring_fn,
)
def run_walk_forward(
manifest: ExperimentManifest,
config: BacktestConfig,
snapshot_dir_override: str | None,
initial_equity: float,
output_root: str,
train_days: int = 252,
test_days: int = 63,
step_days: int | None = None,
) -> list[dict[str, Any]]:
"""Run walk-forward cross-validation over all splits.
Loads all three splits (train/valid/test) into one SnapshotStore,
generates walk-forward windows, and runs a separate backtest on each
window's test period. Returns per-fold metrics.
"""
from libs.common.config import get_settings
s = get_settings()
snapshot_root = Path(snapshot_dir_override or s.parquet_dir) / config.dataset_snapshot_id
# Merge all splits into a single SnapshotStore
stores: list[SnapshotStore] = []
for split in ["train", "valid", "test"]:
parquet_path = snapshot_root / f"{split}.parquet"
if parquet_path.exists():
st = SnapshotStore.load(
snapshot_dir=snapshot_root,
split_name=split,
oracle_url=s.stock_oracle_url,
db_dsn=s.postgres_dsn,
)
stores.append(st)
if not stores:
print("No splits found to load.")
return []
# Merge candidates and bars from all stores
merged_candidates: dict[dt.date, list[dict[str, Any]]] = {}
merged_bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
merged_macro: dict[dt.date, dict[str, Any]] = {}
for st in stores:
for d in st.all_execution_dates():
merged_candidates.setdefault(d, []).extend(st.get_candidates_for_date(d))
merged_bars.update(st._bars)
merged_macro.update(st._macro)
merged_store = SnapshotStore(
candidates_by_exec_date=merged_candidates,
bars_by_symbol_date=merged_bars,
macro_by_date=merged_macro,
)
all_dates = merged_store.all_trading_days()
if not all_dates:
print("No trading days found in merged data.")
return []
windows = generate_walk_forward_windows(
all_dates,
train_days=train_days,
test_days=test_days,
step_days=step_days,
)
if not windows:
print(f"Not enough data for walk-forward windows (need {train_days + test_days} days, have {len(all_dates)}).")
# Fall back to a single window using all available data
print("Running single-window backtest on all data instead.")
windows_dates = [(all_dates[0], all_dates[-1])]
else:
windows_dates = [(w.test_start, w.test_end) for w in windows]
fold_results: list[dict[str, Any]] = []
for fold_idx, (test_start, test_end) in enumerate(windows_dates):
# Filter candidates to only those within the test window
filtered_candidates: dict[dt.date, list[dict[str, Any]]] = {}
for d, cands in merged_candidates.items():
if test_start <= d <= test_end:
filtered_candidates[d] = cands
fold_store = SnapshotStore(
candidates_by_exec_date=filtered_candidates,
bars_by_symbol_date=merged_bars,
macro_by_date=merged_macro,
)
runner = BacktestRunner(
manifest=manifest,
config=config,
store=fold_store,
initial_equity=initial_equity,
)
result = runner.run(output_root=output_root)
fold_info = {
"fold": fold_idx,
"test_start": str(test_start),
"test_end": str(test_end),
"trade_count": result.metrics.trade_count,
"total_return_pct": result.metrics.total_return_pct,
"win_rate": result.metrics.win_rate,
"profit_factor": result.metrics.profit_factor,
"max_drawdown_pct": result.metrics.max_drawdown_pct,
"sharpe_ratio": result.metrics.sharpe_ratio,
"run_id": result.run_id,
}
fold_results.append(fold_info)
print(
f"Fold {fold_idx}: {test_start}{test_end} | "
f"Trades={result.metrics.trade_count} "
f"Return={result.metrics.total_return_pct or 0:.2f}% "
f"WinRate={result.metrics.win_rate or 0:.1%}"
)
# Aggregate summary
total_trades = sum(f["trade_count"] or 0 for f in fold_results)
returns = [f["total_return_pct"] for f in fold_results if f["total_return_pct"] is not None]
win_rates = [f["win_rate"] for f in fold_results if f["win_rate"] is not None]
print(f"\n--- Walk-Forward Summary ({len(fold_results)} folds) ---")
print(f"Total trades: {total_trades}")
if returns:
import statistics
print(f"Mean return: {statistics.mean(returns):.2f}%")
if len(returns) > 1:
print(f"StdDev return: {statistics.stdev(returns):.2f}%")
if win_rates:
import statistics
print(f"Mean win rate: {statistics.mean(win_rates):.1%}")
return fold_results
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("--snapshot-dir", help="Override snapshot root directory (default: data/parquet/)")
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")
parser.add_argument(
"--walk-forward",
action="store_true",
help="Run walk-forward cross-validation instead of single backtest",
)
parser.add_argument("--wf-train-days", type=int, default=252, help="Walk-forward train window (trading days)")
parser.add_argument("--wf-test-days", type=int, default=63, help="Walk-forward test window (trading days)")
parser.add_argument("--mode", choices=["research", "live"], default=None,
help="Backtest mode: research (kill switch resets) or live (permanent)")
args = parser.parse_args()
manifest = load_manifest(args.manifest)
config = resolve_config(manifest, config_root=args.config_root, snapshot_id_override=args.snapshot_id)
if args.mode:
config.risk.backtest_mode = args.mode
if args.walk_forward:
fold_results = run_walk_forward(
manifest=manifest,
config=config,
snapshot_dir_override=args.snapshot_dir,
initial_equity=args.initial_equity,
output_root=args.output_root,
train_days=args.wf_train_days,
test_days=args.wf_test_days,
)
# Write summary JSON
summary_path = Path(args.output_root) / "walk_forward_summary.json"
summary_path.parent.mkdir(parents=True, exist_ok=True)
summary_path.write_text(json.dumps(fold_results, indent=2))
print(f"\nSummary written to: {summary_path}")
else:
store = _build_store(manifest, config, args.split, snapshot_dir_override=args.snapshot_dir)
runner = BacktestRunner(
manifest=manifest,
config=config,
store=store,
initial_equity=args.initial_equity,
split_name=args.split,
)
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}%")
# Print SQS score
from libs.backtest.tracker import compute_sqs
sqs_score, sqs_breakdown = compute_sqs(result.metrics)
print(f"SQS: {sqs_score} ({', '.join(f'{k}={v}' for k, v in sqs_breakdown.items())})")
if __name__ == "__main__":
main()