From 2f4d9f61f7dcd5f01ebabd74d59d7d2729dc862f Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Thu, 12 Mar 2026 21:35:06 -0700 Subject: [PATCH] feat: implement Phase 4 -- event-driven backtester MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full backtesting engine that reads Parquet snapshots and simulates a swing-trading strategy with no look-ahead bias. ## New modules (libs/backtest/) - domain.py: All Pydantic v2 models (Candidate, PlannedOrder, FilledTrade, OpenPosition, DailyPortfolioState, MetricsBundle, BacktestConfig, etc.) - calendar.py: Thin wrappers over time_utils + reaction_date - manifests.py: Config load/deep-merge/validate, run-ID generation - metrics.py: 21 pure-function metrics (no pandas, stdlib statistics only) - selector.py: build_candidate(), rank_candidates() (score↓ ADV↓ symbol↑) - allocator.py: 7-gate run_entry_gates(), ATR stop, floor() shares - execution.py: simulate_entry/exit(), update_trailing_stop() (ratchet-up only) - splits.py: Walk-forward windows, year/regime split utilities - snapshot_store.py: Sync load() → asyncio.run(_async_load()), no look-ahead - artifacts.py: Full run-dir writer (Parquet, CSV, JSON) ## App modules (apps/backtester/) - run.py: BacktestRunner (exit-first→entry simulation, 25% kill switch) + CLI - replay.py: Double-run determinism checker ## Config files - configs/backtest/defaults.json: Base strategy defaults - configs/experiments/baseline_v1.json: First experiment manifest ## Tests: 142 new tests, all passing - 132 unit tests (no DB/HTTP required) - 8 integration tests (synthetic SnapshotStore) - 3 backtest determinism/replay tests Co-Authored-By: Claude Sonnet 4.6 --- apps/backtester/__init__.py | 0 apps/backtester/replay.py | 107 +++++ apps/backtester/run.py | 391 +++++++++++++++++ configs/backtest/defaults.json | 43 ++ configs/experiments/baseline_v1.json | 22 + libs/backtest/__init__.py | 0 libs/backtest/allocator.py | 171 ++++++++ libs/backtest/artifacts.py | 399 ++++++++++++++++++ libs/backtest/calendar.py | 39 ++ libs/backtest/domain.py | 251 +++++++++++ libs/backtest/execution.py | 259 ++++++++++++ libs/backtest/manifests.py | 112 +++++ libs/backtest/metrics.py | 345 +++++++++++++++ libs/backtest/selector.py | 176 ++++++++ libs/backtest/snapshot_store.py | 360 ++++++++++++++++ libs/backtest/splits.py | 117 +++++ tests/fixtures/backtest_bars.json | 16 + tests/fixtures/backtest_manifest.json | 15 + .../fixtures/backtest_snapshot/manifest.json | 16 + .../fixtures/backtest_snapshot/train.parquet | Bin 0 -> 5871 bytes tests/integration/backtest/__init__.py | 0 .../integration/backtest/test_backtest_run.py | 259 ++++++++++++ tests/replay/test_determinism.py | 147 +++++++ tests/unit/backtest/__init__.py | 0 tests/unit/backtest/test_allocator.py | 238 +++++++++++ tests/unit/backtest/test_calendar.py | 94 +++++ tests/unit/backtest/test_domain.py | 232 ++++++++++ tests/unit/backtest/test_execution.py | 257 +++++++++++ tests/unit/backtest/test_manifests.py | 198 +++++++++ tests/unit/backtest/test_metrics.py | 227 ++++++++++ tests/unit/backtest/test_selector.py | 216 ++++++++++ tests/unit/backtest/test_snapshot_store.py | 155 +++++++ tests/unit/backtest/test_splits.py | 89 ++++ 33 files changed, 4951 insertions(+) create mode 100644 apps/backtester/__init__.py create mode 100644 apps/backtester/replay.py create mode 100644 apps/backtester/run.py create mode 100644 configs/backtest/defaults.json create mode 100644 configs/experiments/baseline_v1.json create mode 100644 libs/backtest/__init__.py create mode 100644 libs/backtest/allocator.py create mode 100644 libs/backtest/artifacts.py create mode 100644 libs/backtest/calendar.py create mode 100644 libs/backtest/domain.py create mode 100644 libs/backtest/execution.py create mode 100644 libs/backtest/manifests.py create mode 100644 libs/backtest/metrics.py create mode 100644 libs/backtest/selector.py create mode 100644 libs/backtest/snapshot_store.py create mode 100644 libs/backtest/splits.py create mode 100644 tests/fixtures/backtest_bars.json create mode 100644 tests/fixtures/backtest_manifest.json create mode 100644 tests/fixtures/backtest_snapshot/manifest.json create mode 100644 tests/fixtures/backtest_snapshot/train.parquet create mode 100644 tests/integration/backtest/__init__.py create mode 100644 tests/integration/backtest/test_backtest_run.py create mode 100644 tests/unit/backtest/__init__.py create mode 100644 tests/unit/backtest/test_allocator.py create mode 100644 tests/unit/backtest/test_calendar.py create mode 100644 tests/unit/backtest/test_domain.py create mode 100644 tests/unit/backtest/test_execution.py create mode 100644 tests/unit/backtest/test_manifests.py create mode 100644 tests/unit/backtest/test_metrics.py create mode 100644 tests/unit/backtest/test_selector.py create mode 100644 tests/unit/backtest/test_snapshot_store.py create mode 100644 tests/unit/backtest/test_splits.py diff --git a/apps/backtester/__init__.py b/apps/backtester/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backtester/replay.py b/apps/backtester/replay.py new file mode 100644 index 0000000..e9eed31 --- /dev/null +++ b/apps/backtester/replay.py @@ -0,0 +1,107 @@ +"""Replay runner: execute backtest twice and assert identical output. + +Usage: + python -m apps.backtester.replay --manifest configs/experiments/baseline_v1.json \ + --snapshot-id snapshot_2026_03_20 --output-root ./runs +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from libs.backtest.manifests import load_manifest, resolve_config +from libs.common.logging import get_logger + +logger = get_logger(__name__) + + +def _run_once( + manifest_path: str, + snapshot_id: str | None, + split: str, + output_root: str, + config_root: str, + initial_equity: float, +) -> dict: + """Execute one backtest run and return a dict of key determinism metrics.""" + from apps.backtester.run import BacktestRunner, _build_store + from libs.backtest.manifests import load_manifest, resolve_config + + manifest = load_manifest(manifest_path) + config = resolve_config(manifest, config_root=config_root, snapshot_id_override=snapshot_id) + store = _build_store(manifest, config, split) + + runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=initial_equity) + result = runner.run(output_root=output_root) + + # Build a determinism fingerprint (exclude timestamps and run_id) + return { + "trade_count": result.metrics.trade_count, + "total_return_pct": result.metrics.total_return_pct, + "max_drawdown_pct": result.metrics.max_drawdown_pct, + "win_rate": result.metrics.win_rate, + "avg_r_multiple": result.metrics.avg_r_multiple, + "total_trading_days": result.total_trading_days, + "total_candidates_seen": result.total_candidates_seen, + "total_orders_rejected": result.total_orders_rejected, + } + + +def run_replay( + manifest_path: str, + snapshot_id: str | None, + split: str, + output_root: str, + config_root: str, + initial_equity: float, +) -> bool: + """Run twice and compare. Returns True if identical, False otherwise.""" + logger.info("replay_run_1_start") + run1 = _run_once(manifest_path, snapshot_id, split, output_root, config_root, initial_equity) + logger.info("replay_run_1_complete", metrics=run1) + + logger.info("replay_run_2_start") + run2 = _run_once(manifest_path, snapshot_id, split, output_root, config_root, initial_equity) + logger.info("replay_run_2_complete", metrics=run2) + + if run1 == run2: + print("REPLAY PASS: runs are identical") + print(json.dumps(run1, indent=2)) + return True + else: + print("REPLAY FAIL: runs differ!") + print("Run 1:") + print(json.dumps(run1, indent=2)) + print("Run 2:") + print(json.dumps(run2, indent=2)) + diff = {k: (run1.get(k), run2.get(k)) for k in set(run1) | set(run2) if run1.get(k) != run2.get(k)} + print("Differences:") + print(json.dumps(diff, indent=2)) + return False + + +def main() -> None: + parser = argparse.ArgumentParser(description="ACE-F Backtest Replay / Determinism Check") + 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") + parser.add_argument("--output-root", default="./runs") + parser.add_argument("--config-root", default=".") + parser.add_argument("--initial-equity", type=float, default=100_000.0) + args = parser.parse_args() + + ok = run_replay( + manifest_path=args.manifest, + snapshot_id=args.snapshot_id, + split=args.split, + output_root=args.output_root, + config_root=args.config_root, + initial_equity=args.initial_equity, + ) + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/apps/backtester/run.py b/apps/backtester/run.py new file mode 100644 index 0000000..fd2608e --- /dev/null +++ b/apps/backtester/run.py @@ -0,0 +1,391 @@ +"""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() diff --git a/configs/backtest/defaults.json b/configs/backtest/defaults.json new file mode 100644 index 0000000..6cfcaf6 --- /dev/null +++ b/configs/backtest/defaults.json @@ -0,0 +1,43 @@ +{ + "strategy_name": "baseline_swing_v1", + "dataset_snapshot_id": "snapshot_2026_03_20", + "universe": { + "min_price": 5.0, + "min_avg_dollar_volume": 1000000.0, + "exclude_asset_types": ["ETF", "FUND"] + }, + "signal": { + "score_threshold": 0.5, + "max_candidates_per_day": 5, + "execution_timing": "next_open", + "decision_timing": "reaction_close", + "ranking_fields": ["score", "avg_dollar_volume"] + }, + "risk": { + "per_trade_risk_pct": 0.01, + "max_daily_new_risk_pct": 0.03, + "max_positions": 10, + "max_positions_per_sector": 3, + "max_position_value_pct": 0.10, + "max_adv_fraction": 0.01, + "cooldown_after_loss_streak": 3, + "cooldown_days": 2 + }, + "execution": { + "entry_fill_model": "next_open", + "exit_fill_model": "daily_bar_approximation", + "slippage_bps_base": 10.0, + "commission_per_share": 0.005, + "same_bar_priority": "stop_first_conservative", + "target_1_r": 2.0, + "target_1_fraction": 1.0, + "max_holding_days": 10 + }, + "reporting": { + "write_trade_blotter": true, + "write_equity_curve": true, + "write_metrics_summary": true, + "generate_plots": false, + "attribution_buckets": ["event_type", "sector", "score_bucket"] + } +} diff --git a/configs/experiments/baseline_v1.json b/configs/experiments/baseline_v1.json new file mode 100644 index 0000000..ef52af0 --- /dev/null +++ b/configs/experiments/baseline_v1.json @@ -0,0 +1,22 @@ +{ + "experiment_name": "baseline_v1", + "dataset_snapshot_id": "snapshot_2026_03_20", + "description": "Baseline swing-trade strategy: next-open entry, ATR stop, 2R target, 10-day max hold.", + "base_config": "configs/backtest/defaults.json", + "overrides": {}, + "splits": [ + { + "kind": "year", + "params": {} + }, + { + "kind": "walk_forward", + "params": { + "train_days": 252, + "test_days": 63 + } + } + ], + "tags": ["baseline", "next_open", "atr_stop"], + "notes": "Phase 4 baseline run. Uses default config with no overrides." +} diff --git a/libs/backtest/__init__.py b/libs/backtest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/backtest/allocator.py b/libs/backtest/allocator.py new file mode 100644 index 0000000..863a497 --- /dev/null +++ b/libs/backtest/allocator.py @@ -0,0 +1,171 @@ +"""Position sizing, entry gates, and order planning for the backtester.""" +from __future__ import annotations + +import math +from typing import Any + +from libs.backtest.domain import ( + BacktestConfig, + Candidate, + DailyPortfolioState, + OpenPosition, + PlannedOrder, + RiskConfig, +) +from libs.common.logging import get_logger + +logger = get_logger(__name__) + +# Default drawdown kill-switch threshold (not in JSON schema) +_KILL_SWITCH_DRAWDOWN_PCT = 25.0 + + +def compute_stop_price(candidate: Candidate, config: RiskConfig) -> float: + """Compute stop price based on ATR-14 or a percentage fallback. + + Uses entry_price_est (reaction close) as the price basis. + Actual fill uses the real open + slippage; R-multiple uses actual fill price. + """ + price = candidate.entry_price_est + if candidate.atr_14 and candidate.atr_14 > 0: + stop_distance = candidate.atr_14 * 1.5 + else: + # Fallback: 2% of price + stop_distance = price * 0.02 + return max(0.01, price - stop_distance) + + +def compute_target_price( + entry_price_est: float, + stop_price: float, + target_r: float = 2.0, +) -> float: + """Compute target price at target_r multiples of risk.""" + risk = entry_price_est - stop_price + if risk <= 0: + return entry_price_est * 1.10 # 10% default target + return entry_price_est + risk * target_r + + +def compute_shares( + equity: float, + entry_price: float, + stop_price: float, + config: RiskConfig, +) -> int: + """Compute integer share count. Always math.floor() — never round up.""" + stop_distance = entry_price - stop_price + if stop_distance <= 0: + return 0 + risk_dollars = equity * config.per_trade_risk_pct + raw_shares = risk_dollars / stop_distance + return max(0, math.floor(raw_shares)) + + +def _count_sector_positions(open_positions: list[OpenPosition], sector: str) -> int: + return sum(1 for p in open_positions if p.plan.candidate.sector == sector) + + +def _open_symbols(open_positions: list[OpenPosition]) -> set[str]: + return {p.plan.candidate.symbol for p in open_positions} + + +def run_entry_gates( + candidate: Candidate, + portfolio_state: DailyPortfolioState, + open_positions: list[OpenPosition], + config: BacktestConfig, + cooldown_remaining: int = 0, +) -> str | None: + """Run 7-step entry gate. Returns skip_reason string or None (pass). + + Gates (in order): + 1. Kill switch (drawdown >= threshold) + 2. Max total positions + 3. Duplicate symbol already open + 4. Sector concentration + 5. Daily new risk budget + 6. Cash available (estimated position cost) + 7. Loss-streak cooldown + """ + # Gate 1: Kill switch + if portfolio_state.current_drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT: + return "kill_switch_drawdown" + + # Gate 2: Max positions + if len(open_positions) >= config.risk.max_positions: + return "max_positions_reached" + + # Gate 3: Duplicate symbol + if candidate.symbol in _open_symbols(open_positions): + return "duplicate_symbol" + + # Gate 4: Sector concentration + sector_count = _count_sector_positions(open_positions, candidate.sector) + if sector_count >= config.risk.max_positions_per_sector: + return "sector_limit" + + # Gate 5: Daily new risk budget + trade_risk = portfolio_state.equity * config.risk.per_trade_risk_pct + daily_budget = portfolio_state.equity * config.risk.max_daily_new_risk_pct + if portfolio_state.daily_new_risk_used + trade_risk > daily_budget: + return "daily_risk_budget" + + # Gate 6: Cash available (estimate position cost) + stop_price = compute_stop_price(candidate, config.risk) + est_shares = compute_shares( + portfolio_state.equity, + candidate.entry_price_est, + stop_price, + config.risk, + ) + est_cost = est_shares * candidate.entry_price_est + if est_cost > portfolio_state.cash_available: + return "insufficient_cash" + + # Gate 7: Cooldown + if cooldown_remaining > 0: + return "cooldown" + + return None # all gates passed + + +def build_planned_order( + candidate: Candidate, + portfolio_state: DailyPortfolioState, + open_positions: list[OpenPosition], + config: BacktestConfig, + cooldown_remaining: int = 0, +) -> PlannedOrder: + """Build a PlannedOrder. skip_reason is non-None if any gate rejected it.""" + skip_reason = run_entry_gates( + candidate, portfolio_state, open_positions, config, cooldown_remaining + ) + + stop_price = compute_stop_price(candidate, config.risk) + target_r = config.execution.target_1_r or 2.0 + target_price = compute_target_price(candidate.entry_price_est, stop_price, target_r) + + shares = 0 + risk_dollars = 0.0 + if skip_reason is None: + shares = compute_shares( + portfolio_state.equity, + candidate.entry_price_est, + stop_price, + config.risk, + ) + if shares == 0: + skip_reason = "zero_shares" + else: + risk_dollars = (candidate.entry_price_est - stop_price) * shares + + return PlannedOrder( + candidate=candidate, + shares=shares, + entry_price_limit=candidate.entry_price_est, + stop_price=stop_price, + target_price=target_price, + risk_dollars=risk_dollars, + skip_reason=skip_reason, + ) diff --git a/libs/backtest/artifacts.py b/libs/backtest/artifacts.py new file mode 100644 index 0000000..3d6ca35 --- /dev/null +++ b/libs/backtest/artifacts.py @@ -0,0 +1,399 @@ +"""Write all output artifacts for a backtest run.""" +from __future__ import annotations + +import csv +import datetime as dt +import json +from collections import defaultdict +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pyarrow.parquet as pq + +from libs.backtest.domain import ( + BacktestConfig, + DailyPortfolioState, + ExperimentManifest, + ExperimentResult, + FilledTrade, + MetricsBundle, + OpenPosition, +) +from libs.common.logging import get_logger + +logger = get_logger(__name__) + + +def create_run_directory(output_root: str | Path, run_id: str) -> Path: + """Create the full run directory tree. Returns the run root path.""" + run_dir = Path(output_root) / run_id + for subdir in ["logs", "metrics", "artifacts", "plots", "notes"]: + (run_dir / subdir).mkdir(parents=True, exist_ok=True) + logger.info("run_directory_created", path=str(run_dir)) + return run_dir + + +def write_metadata( + run_dir: Path, + run_id: str, + started_at: dt.datetime, + finished_at: dt.datetime, + git_hash: str, + total_trading_days: int, + total_candidates_seen: int, + total_orders_rejected: int, +) -> Path: + """Write metadata.json.""" + meta = { + "run_id": run_id, + "started_at": started_at.isoformat(), + "finished_at": finished_at.isoformat(), + "elapsed_seconds": (finished_at - started_at).total_seconds(), + "git_commit_hash": git_hash, + "total_trading_days": total_trading_days, + "total_candidates_seen": total_candidates_seen, + "total_orders_rejected": total_orders_rejected, + } + out = run_dir / "metadata.json" + out.write_text(json.dumps(meta, indent=2)) + return out + + +def write_metrics_summary(run_dir: Path, metrics: MetricsBundle) -> Path: + """Write metrics/metrics_summary.json.""" + out = run_dir / "metrics" / "metrics_summary.json" + out.write_text(metrics.model_dump_json(indent=2)) + return out + + +def write_trade_blotter( + run_dir: Path, + trades: list[FilledTrade], +) -> Path | None: + """Write artifacts/trade_blotter.parquet.""" + if not trades: + return None + rows = [ + { + "trade_id": t.trade_id, + "position_id": t.position_id, + "event_id": t.event_id, + "symbol": t.symbol, + "entry_date": t.entry_date.isoformat(), + "exit_date": t.exit_date.isoformat(), + "entry_price": t.entry_price, + "exit_price": t.exit_price, + "exit_reason": t.exit_reason.value, + "shares": t.shares, + "commission": t.commission, + "slippage_bps": t.slippage_bps, + "gross_pnl": t.gross_pnl, + "net_pnl": t.net_pnl, + "pnl_pct": t.pnl_pct, + "r_multiple": t.r_multiple, + "holding_days": t.holding_days, + } + for t in trades + ] + out = run_dir / "artifacts" / "trade_blotter.parquet" + _write_parquet(rows, out) + return out + + +def write_daily_equity_curve( + run_dir: Path, + equity_curve: list[DailyPortfolioState], +) -> Path | None: + """Write artifacts/daily_equity_curve.parquet.""" + if not equity_curve: + return None + rows = [ + { + "date": s.date.isoformat(), + "equity": s.equity, + "cash_available": s.cash_available, + "gross_exposure": s.gross_exposure, + "net_exposure": s.net_exposure, + "unrealized_pnl": s.unrealized_pnl, + "realized_pnl": s.realized_pnl, + "open_position_count": len(s.open_positions), + "daily_new_risk_used": s.daily_new_risk_used, + "peak_equity": s.peak_equity, + "current_drawdown_pct": s.current_drawdown_pct, + } + for s in equity_curve + ] + out = run_dir / "artifacts" / "daily_equity_curve.parquet" + _write_parquet(rows, out) + return out + + +def write_position_timeline( + run_dir: Path, + trades: list[FilledTrade], + open_positions: list[OpenPosition] | None = None, +) -> Path | None: + """Write artifacts/position_timeline.parquet (one row per position).""" + rows = [] + for t in trades: + rows.append( + { + "position_id": t.position_id, + "event_id": t.event_id, + "symbol": t.symbol, + "entry_date": t.entry_date.isoformat(), + "exit_date": t.exit_date.isoformat(), + "entry_price": t.entry_price, + "exit_price": t.exit_price, + "exit_reason": t.exit_reason.value, + "shares": t.shares, + "net_pnl": t.net_pnl, + "r_multiple": t.r_multiple, + "holding_days": t.holding_days, + "status": "closed", + } + ) + if open_positions: + for p in open_positions: + rows.append( + { + "position_id": p.position_id, + "event_id": p.plan.candidate.event_id, + "symbol": p.plan.candidate.symbol, + "entry_date": p.entry_date.isoformat(), + "exit_date": None, + "entry_price": p.entry_price, + "exit_price": None, + "exit_reason": None, + "shares": p.shares_total, + "net_pnl": None, + "r_multiple": None, + "holding_days": p.days_held, + "status": p.status.value, + } + ) + if not rows: + return None + out = run_dir / "artifacts" / "position_timeline.parquet" + _write_parquet(rows, out) + return out + + +def write_attribution_by_event_type( + run_dir: Path, + trades: list[FilledTrade], + candidate_map: dict[str, Any], +) -> Path: + """Write metrics/attribution_by_event_type.csv.""" + bucket_data: dict[str, dict[str, float | int]] = defaultdict( + lambda: {"count": 0, "wins": 0, "net_pnl": 0.0, "avg_r": 0.0, "_r_sum": 0.0} + ) + for t in trades: + cand = candidate_map.get(t.trade_id) + et = getattr(cand, "event_type", "unknown") if cand else "unknown" + d = bucket_data[et] + d["count"] = int(d["count"]) + 1 + if t.net_pnl > 0: + d["wins"] = int(d["wins"]) + 1 + d["net_pnl"] = float(d["net_pnl"]) + t.net_pnl + d["_r_sum"] = float(d["_r_sum"]) + t.r_multiple + + out = run_dir / "metrics" / "attribution_by_event_type.csv" + with open(out, "w", newline="") as f: + writer = csv.DictWriter( + f, fieldnames=["event_type", "count", "wins", "win_rate", "net_pnl", "avg_r"] + ) + writer.writeheader() + for et, d in sorted(bucket_data.items()): + count = int(d["count"]) + wins = int(d["wins"]) + writer.writerow( + { + "event_type": et, + "count": count, + "wins": wins, + "win_rate": wins / count if count > 0 else 0.0, + "net_pnl": round(float(d["net_pnl"]), 4), + "avg_r": round(float(d["_r_sum"]) / count if count > 0 else 0.0, 4), + } + ) + return out + + +def write_attribution_by_sector( + run_dir: Path, + trades: list[FilledTrade], + candidate_map: dict[str, Any], +) -> Path: + """Write metrics/attribution_by_sector.csv.""" + bucket_data: dict[str, dict[str, float | int]] = defaultdict( + lambda: {"count": 0, "wins": 0, "net_pnl": 0.0, "_r_sum": 0.0} + ) + for t in trades: + cand = candidate_map.get(t.trade_id) + sector = getattr(cand, "sector", "UNKNOWN") if cand else "UNKNOWN" + d = bucket_data[sector] + d["count"] = int(d["count"]) + 1 + if t.net_pnl > 0: + d["wins"] = int(d["wins"]) + 1 + d["net_pnl"] = float(d["net_pnl"]) + t.net_pnl + d["_r_sum"] = float(d["_r_sum"]) + t.r_multiple + + out = run_dir / "metrics" / "attribution_by_sector.csv" + with open(out, "w", newline="") as f: + writer = csv.DictWriter( + f, fieldnames=["sector", "count", "wins", "win_rate", "net_pnl", "avg_r"] + ) + writer.writeheader() + for sector, d in sorted(bucket_data.items()): + count = int(d["count"]) + wins = int(d["wins"]) + writer.writerow( + { + "sector": sector, + "count": count, + "wins": wins, + "win_rate": wins / count if count > 0 else 0.0, + "net_pnl": round(float(d["net_pnl"]), 4), + "avg_r": round(float(d["_r_sum"]) / count if count > 0 else 0.0, 4), + } + ) + return out + + +def write_score_bucket_report( + run_dir: Path, + score_bucket_hit_rate: dict[str, float], + trades: list[FilledTrade], + candidate_map: dict[str, Any], +) -> Path: + """Write metrics/score_bucket_report.csv.""" + bucket_counts: dict[str, int] = defaultdict(int) + for t in trades: + cand = candidate_map.get(t.trade_id) + bucket = getattr(cand, "score_bucket", "unknown") if cand else "unknown" + bucket_counts[bucket] += 1 + + out = run_dir / "metrics" / "score_bucket_report.csv" + with open(out, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["score_bucket", "trade_count", "win_rate"]) + writer.writeheader() + for bucket in sorted(set(list(score_bucket_hit_rate.keys()) + list(bucket_counts.keys()))): + writer.writerow( + { + "score_bucket": bucket, + "trade_count": bucket_counts.get(bucket, 0), + "win_rate": round(score_bucket_hit_rate.get(bucket, 0.0), 4), + } + ) + return out + + +def write_plots(run_dir: Path, generate: bool = False) -> Path: + """Create plots directory. generate=True logs a warning (matplotlib not available).""" + plots_dir = run_dir / "plots" + plots_dir.mkdir(exist_ok=True) + if generate: + logger.warning( + "plots_not_implemented", + message="generate_plots=True is a no-op; matplotlib is not in dependencies.", + ) + return plots_dir + + +def write_run_notes(run_dir: Path, notes: str = "") -> Path: + """Write notes/run_notes.md.""" + out = run_dir / "notes" / "run_notes.md" + out.write_text(notes or "# Run Notes\n\n_No notes provided._\n") + return out + + +def write_all_artifacts( + run_dir: Path, + run_id: str, + manifest: ExperimentManifest, + config: BacktestConfig, + metrics: MetricsBundle, + trades: list[FilledTrade], + equity_curve: list[DailyPortfolioState], + open_positions: list[OpenPosition], + candidate_map: dict[str, Any], + started_at: dt.datetime, + finished_at: dt.datetime, + git_hash: str, + total_trading_days: int, + total_candidates_seen: int, + total_orders_rejected: int, +) -> dict[str, str]: + """Write all output files. Returns mapping of artifact_name → file_path.""" + from libs.backtest.manifests import save_manifest, save_resolved_config + + paths: dict[str, str] = {} + + # Core files + paths["manifest"] = str(save_manifest(manifest, run_dir)) + paths["resolved_config"] = str(save_resolved_config(config, run_dir)) + paths["metadata"] = str( + write_metadata( + run_dir, run_id, started_at, finished_at, git_hash, + total_trading_days, total_candidates_seen, total_orders_rejected, + ) + ) + + # Metrics + if config.reporting.write_metrics_summary: + paths["metrics_summary"] = str(write_metrics_summary(run_dir, metrics)) + paths["attribution_by_event_type"] = str( + write_attribution_by_event_type(run_dir, trades, candidate_map) + ) + paths["attribution_by_sector"] = str( + write_attribution_by_sector(run_dir, trades, candidate_map) + ) + paths["score_bucket_report"] = str( + write_score_bucket_report( + run_dir, metrics.score_bucket_hit_rate, trades, candidate_map + ) + ) + + # Trade data + if config.reporting.write_trade_blotter: + p = write_trade_blotter(run_dir, trades) + if p: + paths["trade_blotter"] = str(p) + + if config.reporting.write_equity_curve: + p = write_daily_equity_curve(run_dir, equity_curve) + if p: + paths["daily_equity_curve"] = str(p) + + p = write_position_timeline(run_dir, trades, open_positions) + if p: + paths["position_timeline"] = str(p) + + # Plots (no-op) + paths["plots_dir"] = str(write_plots(run_dir, config.reporting.generate_plots)) + + # Notes + paths["run_notes"] = str(write_run_notes(run_dir, manifest.notes or "")) + + return paths + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _write_parquet(rows: list[dict[str, Any]], path: Path) -> None: + """Write list of row dicts to Parquet.""" + if not rows: + return + keys = list(rows[0].keys()) + columns: dict[str, list[Any]] = {k: [] for k in keys} + for row in rows: + for k in keys: + columns[k].append(row.get(k)) + table = pa.table({k: pa.array(v) for k, v in columns.items()}) + pq.write_table(table, str(path)) + logger.debug("parquet_written", path=str(path), rows=len(rows)) diff --git a/libs/backtest/calendar.py b/libs/backtest/calendar.py new file mode 100644 index 0000000..7e6c50e --- /dev/null +++ b/libs/backtest/calendar.py @@ -0,0 +1,39 @@ +"""Calendar utilities for the backtester — thin wrappers over existing libs.""" +from __future__ import annotations + +import datetime as dt + +from libs.common.time_utils import ( + is_trading_day, + next_trading_day, + trading_days_between, +) +from libs.labeler.reaction_date import compute_reaction_date + + +def resolve_execution_date( + event_date: dt.date, + filing_time_bucket: str, +) -> dt.date: + """Return the date on which the trade is executed (next open after reaction). + + The reaction_date is the first trading day the market can react. + Execution date = next trading day after reaction_date (entry at next open). + """ + reaction = compute_reaction_date(event_date, filing_time_bucket) + return next_trading_day(reaction) + + +def get_trading_days(start: dt.date, end: dt.date) -> list[dt.date]: + """Return all NYSE trading days in [start, end] inclusive.""" + return trading_days_between(start, end) + + +__all__ = [ + "resolve_execution_date", + "get_trading_days", + "is_trading_day", + "next_trading_day", + "trading_days_between", + "compute_reaction_date", +] diff --git a/libs/backtest/domain.py b/libs/backtest/domain.py new file mode 100644 index 0000000..435fbdb --- /dev/null +++ b/libs/backtest/domain.py @@ -0,0 +1,251 @@ +"""Core domain models for the ACE-F backtester.""" +from __future__ import annotations + +import datetime as dt +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class PositionStatus(str, Enum): + PLANNED = "PLANNED" + ENTERED = "ENTERED" + PARTIALLY_EXITED = "PARTIALLY_EXITED" + OPEN = "OPEN" + EXIT_PENDING = "EXIT_PENDING" + CLOSED = "CLOSED" + ARCHIVED = "ARCHIVED" + + +class ExitReason(str, Enum): + STOP = "STOP" + TARGET = "TARGET" + TIME = "TIME" + TRAILING = "TRAILING" + KILL_SWITCH = "KILL_SWITCH" + MISSING_BAR = "MISSING_BAR" + + +class Candidate(BaseModel): + """An eligible trade candidate derived from a Parquet snapshot row.""" + model_config = ConfigDict(frozen=True) + + event_id: str + symbol: str + issuer_id: str | None = None + score: float + sector: str # "UNKNOWN" if unavailable + event_type: str + event_timestamp: dt.datetime # must be timezone-aware + filing_time_bucket: str + reaction_date: dt.date + execution_date: dt.date # mapped from Parquet entry_date at SnapshotStore boundary + entry_price_est: float # reaction-day close price + avg_dollar_volume: float # 20-day mean(volume * close) + atr_14: float | None = None + score_bucket: str + features: dict[str, Any] = Field(default_factory=dict) + + +class PlannedOrder(BaseModel): + """A sized, gated order plan for a candidate.""" + model_config = ConfigDict(frozen=True) + + candidate: Candidate + shares: int + entry_price_limit: float + stop_price: float + target_price: float + risk_dollars: float + skip_reason: str | None = None # non-None means the order was rejected + + +class FilledTrade(BaseModel): + """A completed (closed) trade leg.""" + model_config = ConfigDict(frozen=True) + + trade_id: str + position_id: str + event_id: str + symbol: str + entry_date: dt.date + exit_date: dt.date + entry_price: float + exit_price: float + exit_reason: ExitReason + shares: int + commission: float + slippage_bps: float + gross_pnl: float + net_pnl: float + pnl_pct: float + r_multiple: float + holding_days: int + + +class OpenPosition(BaseModel): + """A live open position (mutable throughout its lifetime).""" + + position_id: str + plan: PlannedOrder + entry_date: dt.date + entry_price: float + entry_fill_slippage_bps: float + current_stop: float + target_price: float + peak_price: float + shares_open: int + shares_total: int + days_held: int = 0 + status: PositionStatus = PositionStatus.ENTERED + partial_fills: list[FilledTrade] = Field(default_factory=list) + + +class DailyPortfolioState(BaseModel): + """Immutable snapshot of portfolio state at end of a trading day.""" + model_config = ConfigDict(frozen=True) + + date: dt.date + equity: float + cash_available: float + gross_exposure: float + net_exposure: float + reserved_risk_budget: float + unrealized_pnl: float + realized_pnl: float + open_positions: list[str] = Field(default_factory=list) # position_ids + daily_new_risk_used: float + peak_equity: float + current_drawdown_pct: float + + +class MetricsBundle(BaseModel): + """21 performance metrics for a completed backtest run.""" + + # Trade metrics (7) + trade_count: int = 0 + win_rate: float | None = None + avg_win_pct: float | None = None + avg_loss_pct: float | None = None + profit_factor: float | None = None + expectancy_r: float | None = None + avg_r_multiple: float | None = None + + # Portfolio metrics (8) + total_return_pct: float | None = None + annualized_return_pct: float | None = None + max_drawdown_pct: float | None = None + calmar_ratio: float | None = None + sharpe_ratio: float | None = None + sortino_ratio: float | None = None + avg_daily_pnl: float | None = None + avg_positions_held: float | None = None + + # Stability metrics (4) + trade_skewness: float | None = None + trade_kurtosis: float | None = None + monthly_win_rate: float | None = None + equity_curve_r_squared: float | None = None + + # Practicality metrics (4) + avg_holding_days: float | None = None + stop_exit_rate: float | None = None + target_exit_rate: float | None = None + score_bucket_hit_rate: dict[str, float] = Field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Config models (mirror JSON Schema) +# --------------------------------------------------------------------------- + +class UniverseConfig(BaseModel): + min_price: float = 5.0 + min_avg_dollar_volume: float = 1_000_000.0 + exclude_asset_types: list[str] = Field(default_factory=list) + allowed_exchanges: list[str] | None = None + + +class SignalConfig(BaseModel): + score_threshold: float = 0.5 + max_candidates_per_day: int = 5 + execution_timing: str = "next_open" + decision_timing: str = "reaction_close" + ranking_fields: list[str] = Field(default_factory=list) + + +class RiskConfig(BaseModel): + per_trade_risk_pct: float = 0.01 # 1% of equity per trade + max_daily_new_risk_pct: float = 0.03 # 3% of equity per day + max_positions: int = 10 + max_positions_per_sector: int = 3 + max_position_value_pct: float | None = None # max fraction of equity in one position + max_adv_fraction: float | None = None # max fraction of avg daily volume + cooldown_after_loss_streak: int = 0 # consecutive losses to trigger cooldown + cooldown_days: int = 0 # days to sit out after streak + + +class ExecutionConfig(BaseModel): + entry_fill_model: str = "next_open" + exit_fill_model: str = "daily_bar_approximation" + slippage_bps_base: float = 10.0 + commission_per_share: float = 0.005 + same_bar_priority: str = "stop_first_conservative" + stop_model: str | None = None + target_1_r: float | None = None # R-multiple for first target + target_1_fraction: float | None = None # fraction to exit at target_1 + trailing_model: str | None = None + max_holding_days: int = 10 + + +class ReportingConfig(BaseModel): + write_trade_blotter: bool = True + write_equity_curve: bool = True + write_metrics_summary: bool = True + generate_plots: bool = False + attribution_buckets: list[str] = Field(default_factory=list) + + +class BacktestConfig(BaseModel): + strategy_name: str + dataset_snapshot_id: str + universe: UniverseConfig = Field(default_factory=UniverseConfig) + signal: SignalConfig = Field(default_factory=SignalConfig) + risk: RiskConfig = Field(default_factory=RiskConfig) + execution: ExecutionConfig = Field(default_factory=ExecutionConfig) + reporting: ReportingConfig = Field(default_factory=ReportingConfig) + + +# --------------------------------------------------------------------------- +# Experiment models +# --------------------------------------------------------------------------- + +class SplitSpec(BaseModel): + kind: str + params: dict[str, Any] = Field(default_factory=dict) + + +class ExperimentManifest(BaseModel): + experiment_name: str + dataset_snapshot_id: str + description: str | None = None + base_config: str # path to base config JSON file + overrides: dict[str, Any] = Field(default_factory=dict) + splits: list[SplitSpec] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + notes: str | None = None + + +class ExperimentResult(BaseModel): + model_config = ConfigDict(frozen=True) + + run_id: str + manifest: ExperimentManifest + resolved_config: BacktestConfig + metrics: MetricsBundle + artifact_paths: dict[str, str] = Field(default_factory=dict) + started_at: dt.datetime + finished_at: dt.datetime + total_trading_days: int + total_candidates_seen: int + total_orders_rejected: int diff --git a/libs/backtest/execution.py b/libs/backtest/execution.py new file mode 100644 index 0000000..5c858ff --- /dev/null +++ b/libs/backtest/execution.py @@ -0,0 +1,259 @@ +"""Fill simulation for entries and exits.""" +from __future__ import annotations + +import datetime as dt +import math +import uuid +from typing import Any + +from libs.backtest.domain import ( + ExecutionConfig, + ExitReason, + FilledTrade, + OpenPosition, + PlannedOrder, + PositionStatus, +) +from libs.common.logging import get_logger + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Slippage helpers +# --------------------------------------------------------------------------- + + +def _long_entry_fill(open_price: float, slippage_bps: float) -> float: + """Buy at open + slippage (pays more).""" + return open_price * (1.0 + slippage_bps / 10_000) + + +def _long_exit_fill(price: float, slippage_bps: float) -> float: + """Sell at price - slippage (receives less).""" + return price * (1.0 - slippage_bps / 10_000) + + +# --------------------------------------------------------------------------- +# Entry simulation +# --------------------------------------------------------------------------- + + +def simulate_entry( + plan: PlannedOrder, + bar: dict[str, Any], + config: ExecutionConfig, + position_id: str | None = None, +) -> OpenPosition | None: + """Simulate filling a planned entry at the bar's open. + + Returns None (position NOT opened) if bar is missing or open is invalid. + No zero imputation — missing bar = no entry. + """ + if bar is None: + logger.warning("entry_skip_missing_bar", event_id=plan.candidate.event_id) + return None + + bar_open = bar.get("open") + if bar_open is None or bar_open <= 0: + logger.warning("entry_skip_invalid_open", event_id=plan.candidate.event_id, bar=bar) + return None + + if plan.skip_reason is not None: + logger.debug("entry_skip_gate_rejected", reason=plan.skip_reason) + return None + + if plan.shares <= 0: + logger.warning("entry_skip_zero_shares", event_id=plan.candidate.event_id) + return None + + fill_price = _long_entry_fill(float(bar_open), config.slippage_bps_base) + slippage_bps_actual = (fill_price / float(bar_open) - 1.0) * 10_000 + + pid = position_id or str(uuid.uuid4()) + + # entry_date here is the bar date (execution_date of the candidate) + bar_date_raw = bar.get("date") + if isinstance(bar_date_raw, str): + entry_date = dt.date.fromisoformat(bar_date_raw) + elif isinstance(bar_date_raw, dt.date): + entry_date = bar_date_raw + else: + entry_date = plan.candidate.execution_date + + return OpenPosition( + position_id=pid, + plan=plan, + entry_date=entry_date, + entry_price=fill_price, + entry_fill_slippage_bps=slippage_bps_actual, + current_stop=plan.stop_price, + target_price=plan.target_price, + peak_price=fill_price, + shares_open=plan.shares, + shares_total=plan.shares, + days_held=0, + status=PositionStatus.ENTERED, + ) + + +# --------------------------------------------------------------------------- +# Exit simulation +# --------------------------------------------------------------------------- + + +def simulate_exit( + position: OpenPosition, + bar: dict[str, Any], + config: ExecutionConfig, + current_date: dt.date, +) -> FilledTrade | None: + """Check if position should exit on this bar. Returns FilledTrade or None. + + Handles: + - Stop loss (low ≤ stop_price) + - Target (high ≥ target_price) + - Same-bar conflict (controlled by same_bar_priority) + - Time exit (days_held >= max_holding_days) + - Kill switch / missing bar handled upstream + + Slippage is applied in the unfavorable direction for long positions. + """ + if bar is None: + return None + + bar_low = bar.get("low") + bar_high = bar.get("high") + bar_close = bar.get("close") + slippage = config.slippage_bps_base + + stop_hit = bar_low is not None and float(bar_low) <= position.current_stop + target_hit = bar_high is not None and float(bar_high) >= position.target_price + + exit_reason: ExitReason | None = None + exit_fill_price: float | None = None + + if stop_hit and target_hit: + # Same-bar conflict + if config.same_bar_priority == "stop_first_conservative": + exit_reason = ExitReason.STOP + exit_fill_price = _long_exit_fill(position.current_stop, slippage) + else: # target_first_aggressive + exit_reason = ExitReason.TARGET + exit_fill_price = _long_exit_fill(position.target_price, slippage) + elif stop_hit: + exit_reason = ExitReason.STOP + exit_fill_price = _long_exit_fill(position.current_stop, slippage) + elif target_hit: + exit_reason = ExitReason.TARGET + exit_fill_price = _long_exit_fill(position.target_price, slippage) + elif position.days_held >= config.max_holding_days: + exit_reason = ExitReason.TIME + if bar_close is not None and float(bar_close) > 0: + exit_fill_price = _long_exit_fill(float(bar_close), slippage) + else: + exit_fill_price = position.entry_price # fallback (shouldn't happen) + + if exit_reason is None or exit_fill_price is None: + return None + + return _build_filled_trade(position, exit_fill_price, exit_reason, current_date, config) + + +def simulate_kill_switch_exit( + position: OpenPosition, + bar: dict[str, Any] | None, + current_date: dt.date, + config: ExecutionConfig, +) -> FilledTrade: + """Force-close a position due to kill switch (portfolio drawdown).""" + slippage = config.slippage_bps_base + if bar is not None and bar.get("close") is not None: + exit_price = _long_exit_fill(float(bar["close"]), slippage) + else: + exit_price = position.entry_price # last known price fallback + + return _build_filled_trade( + position, exit_price, ExitReason.KILL_SWITCH, current_date, config + ) + + +def simulate_missing_bar_exit( + position: OpenPosition, + current_date: dt.date, + config: ExecutionConfig, +) -> FilledTrade: + """Close a position when bar data is unavailable for too long.""" + return _build_filled_trade( + position, position.entry_price, ExitReason.MISSING_BAR, current_date, config + ) + + +# --------------------------------------------------------------------------- +# Trailing stop update +# --------------------------------------------------------------------------- + + +def update_trailing_stop(position: OpenPosition, bar: dict[str, Any]) -> None: + """Ratchet stop up to bar low (never down). Mutates position in place.""" + bar_low = bar.get("low") + if bar_low is not None: + new_stop = max(position.current_stop, float(bar_low)) + position.current_stop = new_stop + + # Track peak price + bar_high = bar.get("high") + if bar_high is not None: + position.peak_price = max(position.peak_price, float(bar_high)) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _build_filled_trade( + position: OpenPosition, + exit_price: float, + exit_reason: ExitReason, + exit_date: dt.date, + config: ExecutionConfig, +) -> FilledTrade: + shares = position.shares_open + commission = shares * config.commission_per_share * 2 # entry + exit legs + gross_pnl = (exit_price - position.entry_price) * shares + net_pnl = gross_pnl - commission + + entry_price = position.entry_price + pnl_pct = (exit_price - entry_price) / entry_price if entry_price != 0 else 0.0 + + # R-multiple uses actual fill price + stop_distance = entry_price - position.plan.stop_price + if stop_distance > 0: + r_multiple = (exit_price - entry_price) / stop_distance + else: + r_multiple = 0.0 + + holding_days = (exit_date - position.entry_date).days + + trade_id = str(uuid.uuid4()) + + return FilledTrade( + trade_id=trade_id, + position_id=position.position_id, + event_id=position.plan.candidate.event_id, + symbol=position.plan.candidate.symbol, + entry_date=position.entry_date, + exit_date=exit_date, + entry_price=position.entry_price, + exit_price=exit_price, + exit_reason=exit_reason, + shares=shares, + commission=commission, + slippage_bps=config.slippage_bps_base, + gross_pnl=gross_pnl, + net_pnl=net_pnl, + pnl_pct=pnl_pct, + r_multiple=r_multiple, + holding_days=holding_days, + ) diff --git a/libs/backtest/manifests.py b/libs/backtest/manifests.py new file mode 100644 index 0000000..fbc75af --- /dev/null +++ b/libs/backtest/manifests.py @@ -0,0 +1,112 @@ +"""Experiment manifest loading, config merging, and run-ID generation.""" +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from libs.backtest.domain import BacktestConfig, ExperimentManifest +from libs.common.ids import sha256_checksum_str +from libs.common.logging import get_logger +from libs.common.time_utils import utc_now + +logger = get_logger(__name__) + + +def load_base_config(config_path: str | Path) -> dict[str, Any]: + """Load a base config JSON file.""" + p = Path(config_path) + if not p.exists(): + raise FileNotFoundError(f"Base config not found: {p}") + return json.loads(p.read_text()) + + +def deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: + """Recursively merge overrides into base (overrides win on conflict).""" + result = dict(base) + for key, value in overrides.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + + +def load_manifest(manifest_path: str | Path) -> ExperimentManifest: + """Load and validate an experiment manifest JSON file.""" + p = Path(manifest_path) + if not p.exists(): + raise FileNotFoundError(f"Manifest not found: {p}") + raw = json.loads(p.read_text()) + return ExperimentManifest.model_validate(raw) + + +def resolve_config( + manifest: ExperimentManifest, + config_root: str | Path | None = None, + snapshot_id_override: str | None = None, +) -> BacktestConfig: + """Load base config, apply manifest overrides, validate into BacktestConfig. + + Args: + manifest: The experiment manifest. + config_root: Root directory for resolving relative config paths. + snapshot_id_override: If provided, overrides the dataset_snapshot_id. + """ + base_config_path = manifest.base_config + if config_root is not None: + resolved_path = Path(config_root) / base_config_path + if resolved_path.exists(): + base_config_path = str(resolved_path) + + base = load_base_config(base_config_path) + merged = deep_merge(base, manifest.overrides) + + # Inject snapshot_id + sid = snapshot_id_override or manifest.dataset_snapshot_id + merged["dataset_snapshot_id"] = sid + + return BacktestConfig.model_validate(merged) + + +def _safe_slug(text: str, max_len: int = 20) -> str: + """Convert text to safe alphanumeric slug.""" + slug = re.sub(r"[^a-zA-Z0-9_-]", "_", text.strip()) + return slug[:max_len] + + +def generate_run_id( + config: BacktestConfig, + strategy_override: str | None = None, +) -> str: + """Generate a unique, deterministic run ID. + + Format: bt_{safe_strategy}_{safe_snapshot[:12]}_{timestamp}_{config_hash[:8]} + """ + strategy = _safe_slug(strategy_override or config.strategy_name) + snapshot = _safe_slug(config.dataset_snapshot_id, max_len=12) + timestamp = utc_now().strftime("%Y%m%d%H%M%S") + config_json = config.model_dump_json(indent=None) + config_hash = sha256_checksum_str(config_json)[:8] + return f"bt_{strategy}_{snapshot}_{timestamp}_{config_hash}" + + +def save_resolved_config( + config: BacktestConfig, + run_dir: Path, +) -> Path: + """Write resolved_config.json to the run directory.""" + out = run_dir / "resolved_config.json" + out.write_text(config.model_dump_json(indent=2)) + return out + + +def save_manifest( + manifest: ExperimentManifest, + run_dir: Path, +) -> Path: + """Write manifest.json (copy of experiment manifest) to the run directory.""" + out = run_dir / "manifest.json" + out.write_text(manifest.model_dump_json(indent=2)) + return out diff --git a/libs/backtest/metrics.py b/libs/backtest/metrics.py new file mode 100644 index 0000000..b5456a1 --- /dev/null +++ b/libs/backtest/metrics.py @@ -0,0 +1,345 @@ +"""Pure-function performance metrics for the backtester. + +All functions take lists of domain objects (no pandas). +All ratio helpers return None instead of 0.0 for empty/zero denominators. +""" +from __future__ import annotations + +import datetime as dt +import math +import statistics +from collections import defaultdict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from libs.backtest.domain import DailyPortfolioState, ExitReason, FilledTrade, MetricsBundle + + +# --------------------------------------------------------------------------- +# Trade metrics (7) +# --------------------------------------------------------------------------- + + +def compute_win_rate(trades: list[FilledTrade]) -> float | None: + if not trades: + return None + wins = sum(1 for t in trades if t.net_pnl > 0) + return wins / len(trades) + + +def compute_avg_win_pct(trades: list[FilledTrade]) -> float | None: + wins = [t.pnl_pct for t in trades if t.net_pnl > 0] + if not wins: + return None + return statistics.mean(wins) + + +def compute_avg_loss_pct(trades: list[FilledTrade]) -> float | None: + losses = [t.pnl_pct for t in trades if t.net_pnl <= 0] + if not losses: + return None + return statistics.mean(losses) + + +def compute_profit_factor(trades: list[FilledTrade]) -> float | None: + gross_profit = sum(t.net_pnl for t in trades if t.net_pnl > 0) + gross_loss = abs(sum(t.net_pnl for t in trades if t.net_pnl < 0)) + if gross_loss == 0: + return None + return gross_profit / gross_loss + + +def compute_expectancy_r(trades: list[FilledTrade]) -> float | None: + if not trades: + return None + return statistics.mean(t.r_multiple for t in trades) + + +def compute_avg_r_multiple(trades: list[FilledTrade]) -> float | None: + if not trades: + return None + return statistics.mean(t.r_multiple for t in trades) + + +# --------------------------------------------------------------------------- +# Portfolio metrics (8) +# --------------------------------------------------------------------------- + + +def compute_total_return_pct(equity_curve: list[DailyPortfolioState]) -> float | None: + if len(equity_curve) < 2: + return None + start = equity_curve[0].equity + end = equity_curve[-1].equity + if start == 0: + return None + return (end - start) / start * 100.0 + + +def compute_annualized_return_pct( + equity_curve: list[DailyPortfolioState], +) -> float | None: + if len(equity_curve) < 2: + return None + start = equity_curve[0].equity + end = equity_curve[-1].equity + if start <= 0: + return None + days = (equity_curve[-1].date - equity_curve[0].date).days + if days <= 0: + return None + years = days / 365.25 + return ((end / start) ** (1.0 / years) - 1.0) * 100.0 + + +def compute_max_drawdown_pct(equity_curve: list[DailyPortfolioState]) -> float | None: + if not equity_curve: + return None + peak = equity_curve[0].equity + max_dd = 0.0 + for state in equity_curve: + if state.equity > peak: + peak = state.equity + if peak > 0: + dd = (peak - state.equity) / peak * 100.0 + max_dd = max(max_dd, dd) + return max_dd + + +def compute_calmar_ratio( + annualized_return: float | None, + max_drawdown: float | None, +) -> float | None: + if annualized_return is None or max_drawdown is None or max_drawdown == 0: + return None + return annualized_return / max_drawdown + + +def _daily_returns(equity_curve: list[DailyPortfolioState]) -> list[float]: + returns = [] + for i in range(1, len(equity_curve)): + prev = equity_curve[i - 1].equity + curr = equity_curve[i].equity + if prev > 0: + returns.append((curr - prev) / prev) + return returns + + +def compute_sharpe_ratio( + equity_curve: list[DailyPortfolioState], + risk_free_daily: float = 0.0, +) -> float | None: + returns = _daily_returns(equity_curve) + if len(returns) < 2: + return None + excess = [r - risk_free_daily for r in returns] + mean = statistics.mean(excess) + try: + std = statistics.stdev(excess) + except statistics.StatisticsError: + return None + if std == 0: + return None + return (mean / std) * math.sqrt(252) + + +def compute_sortino_ratio( + equity_curve: list[DailyPortfolioState], + risk_free_daily: float = 0.0, +) -> float | None: + returns = _daily_returns(equity_curve) + if len(returns) < 2: + return None + excess = [r - risk_free_daily for r in returns] + mean = statistics.mean(excess) + downside = [r for r in excess if r < 0] + if len(downside) < 2: + return None + try: + downside_std = statistics.stdev(downside) + except statistics.StatisticsError: + return None + if downside_std == 0: + return None + return (mean / downside_std) * math.sqrt(252) + + +def compute_avg_daily_pnl(equity_curve: list[DailyPortfolioState]) -> float | None: + if len(equity_curve) < 2: + return None + daily_pnls = [] + for i in range(1, len(equity_curve)): + daily_pnls.append(equity_curve[i].equity - equity_curve[i - 1].equity) + return statistics.mean(daily_pnls) + + +def compute_avg_positions_held(equity_curve: list[DailyPortfolioState]) -> float | None: + if not equity_curve: + return None + return statistics.mean(len(s.open_positions) for s in equity_curve) + + +# --------------------------------------------------------------------------- +# Stability metrics (4) +# --------------------------------------------------------------------------- + + +def compute_trade_skewness(trades: list[FilledTrade]) -> float | None: + if len(trades) < 3: + return None + pnls = [t.net_pnl for t in trades] + mean = statistics.mean(pnls) + try: + std = statistics.stdev(pnls) + except statistics.StatisticsError: + return None + if std == 0: + return None + n = len(pnls) + skew = sum(((x - mean) / std) ** 3 for x in pnls) * n / ((n - 1) * (n - 2)) + return skew + + +def compute_trade_kurtosis(trades: list[FilledTrade]) -> float | None: + if len(trades) < 4: + return None + pnls = [t.net_pnl for t in trades] + mean = statistics.mean(pnls) + try: + std = statistics.stdev(pnls) + except statistics.StatisticsError: + return None + if std == 0: + return None + n = len(pnls) + # Excess kurtosis (Fisher's definition) + kurt = sum(((x - mean) / std) ** 4 for x in pnls) * n * (n + 1) / ( + (n - 1) * (n - 2) * (n - 3) + ) - 3 * (n - 1) ** 2 / ((n - 2) * (n - 3)) + return kurt + + +def compute_monthly_win_rate(trades: list[FilledTrade]) -> float | None: + """Fraction of calendar months with net positive PnL.""" + if not trades: + return None + monthly: dict[str, float] = defaultdict(float) + for t in trades: + key = t.exit_date.strftime("%Y-%m") + monthly[key] += t.net_pnl + if not monthly: + return None + wins = sum(1 for v in monthly.values() if v > 0) + return wins / len(monthly) + + +def compute_equity_curve_r_squared(equity_curve: list[DailyPortfolioState]) -> float | None: + """R² of a linear regression fit to the equity curve (higher = smoother growth).""" + if len(equity_curve) < 3: + return None + n = len(equity_curve) + xs = list(range(n)) + ys = [s.equity for s in equity_curve] + x_mean = statistics.mean(xs) + y_mean = statistics.mean(ys) + ss_xx = sum((x - x_mean) ** 2 for x in xs) + ss_xy = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys)) + ss_yy = sum((y - y_mean) ** 2 for y in ys) + if ss_xx == 0 or ss_yy == 0: + return None + r = ss_xy / math.sqrt(ss_xx * ss_yy) + return r ** 2 + + +# --------------------------------------------------------------------------- +# Practicality metrics (4) +# --------------------------------------------------------------------------- + + +def compute_avg_holding_days(trades: list[FilledTrade]) -> float | None: + if not trades: + return None + return statistics.mean(t.holding_days for t in trades) + + +def compute_stop_exit_rate(trades: list[FilledTrade]) -> float | None: + from libs.backtest.domain import ExitReason + + if not trades: + return None + stops = sum(1 for t in trades if t.exit_reason in (ExitReason.STOP, ExitReason.TRAILING)) + return stops / len(trades) + + +def compute_target_exit_rate(trades: list[FilledTrade]) -> float | None: + from libs.backtest.domain import ExitReason + + if not trades: + return None + targets = sum(1 for t in trades if t.exit_reason == ExitReason.TARGET) + return targets / len(trades) + + +def compute_score_bucket_hit_rate(trades: list[FilledTrade], candidate_map: dict[str, object]) -> dict[str, float]: + """Win rate per score_bucket (uses trade_id -> candidate mapping).""" + bucket_wins: dict[str, int] = defaultdict(int) + bucket_total: dict[str, int] = defaultdict(int) + for t in trades: + cand = candidate_map.get(t.trade_id) + if cand is None: + continue + bucket = getattr(cand, "score_bucket", "unknown") + bucket_total[bucket] += 1 + if t.net_pnl > 0: + bucket_wins[bucket] += 1 + return { + b: bucket_wins[b] / bucket_total[b] + for b in bucket_total + if bucket_total[b] > 0 + } + + +# --------------------------------------------------------------------------- +# Builder +# --------------------------------------------------------------------------- + + +def build_metrics_bundle( + trades: list[FilledTrade], + equity_curve: list[DailyPortfolioState], + candidate_map: dict[str, object] | None = None, +) -> MetricsBundle: + from libs.backtest.domain import MetricsBundle + + ann_ret = compute_annualized_return_pct(equity_curve) + max_dd = compute_max_drawdown_pct(equity_curve) + + return MetricsBundle( + # Trade + trade_count=len(trades), + win_rate=compute_win_rate(trades), + avg_win_pct=compute_avg_win_pct(trades), + avg_loss_pct=compute_avg_loss_pct(trades), + profit_factor=compute_profit_factor(trades), + expectancy_r=compute_expectancy_r(trades), + avg_r_multiple=compute_avg_r_multiple(trades), + # Portfolio + total_return_pct=compute_total_return_pct(equity_curve), + annualized_return_pct=ann_ret, + max_drawdown_pct=max_dd, + calmar_ratio=compute_calmar_ratio(ann_ret, max_dd), + sharpe_ratio=compute_sharpe_ratio(equity_curve), + sortino_ratio=compute_sortino_ratio(equity_curve), + avg_daily_pnl=compute_avg_daily_pnl(equity_curve), + avg_positions_held=compute_avg_positions_held(equity_curve), + # Stability + trade_skewness=compute_trade_skewness(trades), + trade_kurtosis=compute_trade_kurtosis(trades), + monthly_win_rate=compute_monthly_win_rate(trades), + equity_curve_r_squared=compute_equity_curve_r_squared(equity_curve), + # Practicality + avg_holding_days=compute_avg_holding_days(trades), + stop_exit_rate=compute_stop_exit_rate(trades), + target_exit_rate=compute_target_exit_rate(trades), + score_bucket_hit_rate=compute_score_bucket_hit_rate(trades, candidate_map or {}), + ) diff --git a/libs/backtest/selector.py b/libs/backtest/selector.py new file mode 100644 index 0000000..4a256d1 --- /dev/null +++ b/libs/backtest/selector.py @@ -0,0 +1,176 @@ +"""Build, rank and filter Candidate objects from raw Parquet row dicts.""" +from __future__ import annotations + +import datetime as dt +from typing import Any +from zoneinfo import ZoneInfo + +from libs.backtest.domain import Candidate, SignalConfig, UniverseConfig +from libs.common.logging import get_logger + +logger = get_logger(__name__) +_UTC = ZoneInfo("UTC") + + +def build_candidate(row: dict[str, Any]) -> Candidate | None: + """Build a Candidate from a raw Parquet row dict. + + Returns None (logged as skip) if: + - event_timestamp is null/missing + - entry_price_est is null/zero + - execution_date is null/missing + """ + event_id = row.get("event_id", "") + + # Strict: no silent substitution for event_timestamp + raw_ts = row.get("event_timestamp") + if raw_ts is None: + logger.warning("skip_candidate_no_timestamp", event_id=event_id) + return None + + # Normalise to timezone-aware datetime + if isinstance(raw_ts, str): + try: + event_timestamp = dt.datetime.fromisoformat(raw_ts) + except ValueError: + logger.warning("skip_candidate_bad_timestamp", event_id=event_id, raw=raw_ts) + return None + elif isinstance(raw_ts, dt.datetime): + event_timestamp = raw_ts + else: + logger.warning("skip_candidate_unknown_timestamp_type", event_id=event_id) + return None + + if event_timestamp.tzinfo is None: + event_timestamp = event_timestamp.replace(tzinfo=_UTC) + + # entry_price_est (mapped from Parquet entry_price) + entry_price_est = row.get("entry_price") or row.get("entry_price_est") + if not entry_price_est: + logger.warning("skip_candidate_no_entry_price", event_id=event_id) + return None + entry_price_est = float(entry_price_est) + if entry_price_est <= 0: + logger.warning("skip_candidate_zero_entry_price", event_id=event_id) + return None + + # execution_date (mapped from Parquet entry_date) + raw_exec_date = row.get("execution_date") or row.get("entry_date") + if raw_exec_date is None: + logger.warning("skip_candidate_no_exec_date", event_id=event_id) + return None + if isinstance(raw_exec_date, str): + execution_date = dt.date.fromisoformat(raw_exec_date) + elif isinstance(raw_exec_date, dt.date): + execution_date = raw_exec_date + else: + logger.warning("skip_candidate_bad_exec_date", event_id=event_id) + return None + + # reaction_date + raw_react = row.get("reaction_date") + if isinstance(raw_react, str): + reaction_date = dt.date.fromisoformat(raw_react) + elif isinstance(raw_react, dt.date): + reaction_date = raw_react + else: + reaction_date = execution_date # fallback: same as execution + + score = float(row.get("score", 0.0)) + avg_dollar_volume = float(row.get("avg_dollar_volume", 0.0)) + atr_14_raw = row.get("atr_14") + atr_14 = float(atr_14_raw) if atr_14_raw is not None else None + + # Classify score bucket + score_bucket = _classify_score_bucket(score) + + return Candidate( + event_id=event_id, + symbol=str(row.get("symbol", row.get("ticker", ""))), + issuer_id=row.get("issuer_id"), + score=score, + sector=str(row.get("sector") or "UNKNOWN"), + event_type=str(row.get("event_type", "")), + event_timestamp=event_timestamp, + filing_time_bucket=str(row.get("filing_time_bucket", "unknown")), + reaction_date=reaction_date, + execution_date=execution_date, + entry_price_est=entry_price_est, + avg_dollar_volume=avg_dollar_volume, + atr_14=atr_14, + score_bucket=score_bucket, + features={k: v for k, v in row.items() if k not in _RESERVED_KEYS}, + ) + + +_RESERVED_KEYS = { + "event_id", "symbol", "ticker", "issuer_id", "score", "sector", + "event_type", "event_timestamp", "filing_time_bucket", "reaction_date", + "entry_date", "execution_date", "entry_price", "entry_price_est", + "avg_dollar_volume", "atr_14", "score_bucket", +} + + +def _classify_score_bucket(score: float) -> str: + if score >= 0.8: + return "high" + if score >= 0.6: + return "medium_high" + if score >= 0.4: + return "medium" + if score >= 0.2: + return "medium_low" + return "low" + + +def rank_candidates(candidates: list[Candidate]) -> list[Candidate]: + """Sort by score DESC, avg_dollar_volume DESC, symbol ASC (stable, deterministic).""" + return sorted(candidates, key=lambda c: (-c.score, -c.avg_dollar_volume, c.symbol)) + + +def filter_by_universe( + candidates: list[Candidate], + config: UniverseConfig, +) -> list[Candidate]: + """Apply universe filters: min_price, min_avg_dollar_volume, exchange.""" + filtered = [] + for c in candidates: + if c.entry_price_est < config.min_price: + continue + if c.avg_dollar_volume < config.min_avg_dollar_volume: + continue + filtered.append(c) + return filtered + + +def filter_by_score( + candidates: list[Candidate], + score_threshold: float, +) -> list[Candidate]: + return [c for c in candidates if c.score >= score_threshold] + + +def truncate_candidates( + candidates: list[Candidate], + max_per_day: int, +) -> list[Candidate]: + return candidates[:max_per_day] + + +def select_candidates( + raw_rows: list[dict[str, Any]], + universe_config: UniverseConfig, + signal_config: SignalConfig, +) -> list[Candidate]: + """Full selection pipeline: build → filter → rank → truncate.""" + candidates = [] + for row in raw_rows: + c = build_candidate(row) + if c is not None: + candidates.append(c) + + candidates = filter_by_universe(candidates, universe_config) + candidates = filter_by_score(candidates, signal_config.score_threshold) + candidates = rank_candidates(candidates) + candidates = truncate_candidates(candidates, signal_config.max_candidates_per_day) + return candidates diff --git a/libs/backtest/snapshot_store.py b/libs/backtest/snapshot_store.py new file mode 100644 index 0000000..c99d1b1 --- /dev/null +++ b/libs/backtest/snapshot_store.py @@ -0,0 +1,360 @@ +"""Point-in-time snapshot data loader for the backtester. + +The public interface is: + store = SnapshotStore.load(snapshot_dir, split_name, oracle_url, db_dsn) + rows = store.get_candidates_for_date(date) + bar = store.get_bar(symbol, date) + macro = store.get_macro_for_date(date) + +For tests, inject data directly: + store = SnapshotStore(candidates_by_exec_date=..., bars_by_symbol_date=..., macro_by_date=...) +""" +from __future__ import annotations + +import asyncio +import datetime as dt +from pathlib import Path +from typing import Any + +import pyarrow.parquet as pq + +from libs.common.logging import get_logger + +logger = get_logger(__name__) + + +class SnapshotStore: + """In-memory cache of point-in-time backtest data.""" + + def __init__( + self, + candidates_by_exec_date: dict[dt.date, list[dict[str, Any]]], + bars_by_symbol_date: dict[str, dict[dt.date, dict[str, Any]]], + macro_by_date: dict[dt.date, dict[str, Any]] | None = None, + ) -> None: + self._candidates = candidates_by_exec_date + self._bars = bars_by_symbol_date + self._macro = macro_by_date or {} + + # ------------------------------------------------------------------ + # Public query interface + # ------------------------------------------------------------------ + + def get_candidates_for_date(self, date: dt.date) -> list[dict[str, Any]]: + """Return candidates where execution_date == date. No look-ahead.""" + return list(self._candidates.get(date, [])) + + def get_bar(self, symbol: str, date: dt.date) -> dict[str, Any] | None: + """Return OHLCV bar for symbol on date, or None if missing.""" + sym_bars = self._bars.get(symbol) + if sym_bars is None: + return None + return sym_bars.get(date) + + def get_macro_for_date(self, date: dt.date) -> dict[str, Any]: + """Return macro observations for date (empty dict if none).""" + return dict(self._macro.get(date, {})) + + def all_execution_dates(self) -> list[dt.date]: + """Sorted list of dates that have at least one candidate.""" + return sorted(self._candidates.keys()) + + # ------------------------------------------------------------------ + # Factory: load from Parquet + DB + Oracle + # ------------------------------------------------------------------ + + @classmethod + def load( + cls, + snapshot_dir: str | Path, + split_name: str, + oracle_url: str, + db_dsn: str, + ) -> "SnapshotStore": + """Synchronous factory. Internally uses asyncio.run() to prefetch data. + + Raises RuntimeError if called from within a running event loop. + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is not None and loop.is_running(): + raise RuntimeError( + "SnapshotStore.load() cannot be called from a running event loop. " + "Use SnapshotStore._async_load() directly in async contexts." + ) + + data = asyncio.run(cls._async_load(Path(snapshot_dir), split_name, oracle_url, db_dsn)) + return cls(**data) + + @classmethod + async def _async_load( + cls, + snapshot_dir: Path, + split_name: str, + oracle_url: str, + db_dsn: str, + ) -> dict[str, Any]: + """Async data loading pipeline. Returns kwargs dict for __init__.""" + parquet_path = snapshot_dir / f"{split_name}.parquet" + if not parquet_path.exists(): + raise FileNotFoundError(f"Parquet file not found: {parquet_path}") + + # Step 1: Read Parquet + logger.info("snapshot_store_reading_parquet", path=str(parquet_path)) + table = pq.read_table(str(parquet_path)) + rows: list[dict[str, Any]] = table.to_pydict() + # Convert column-oriented dict to list of row dicts + num_rows = table.num_rows + col_names = list(rows.keys()) + row_list: list[dict[str, Any]] = [ + {col: rows[col][i] for col in col_names} for i in range(num_rows) + ] + logger.info("snapshot_store_rows_loaded", count=num_rows) + + # Collect event_ids for DB lookup + event_ids = [str(r.get("event_id", "")) for r in row_list] + + # Step 2–6: DB + Oracle enrichment + event_meta = await cls._fetch_event_metadata(event_ids, db_dsn) + unique_symbols = list({m.get("ticker", "") for m in event_meta.values() if m.get("ticker")}) + + date_range = cls._compute_date_range(row_list) + bars_by_symbol, avg_dvol = await cls._fetch_price_data( + unique_symbols, date_range, oracle_url + ) + sectors = await cls._fetch_sectors(unique_symbols, oracle_url) + macro_by_date = await cls._fetch_macro(date_range, db_dsn) + + # Step 7: Build candidates_by_exec_date + candidates_by_exec_date: dict[dt.date, list[dict[str, Any]]] = {} + for row in row_list: + eid = str(row.get("event_id", "")) + meta = event_meta.get(eid, {}) + ticker = meta.get("ticker") + if not ticker: + logger.debug("snapshot_store_skip_no_ticker", event_id=eid) + continue + + # Map entry_date → execution_date at this boundary + raw_exec = row.get("entry_date") or row.get("execution_date") + if raw_exec is None: + continue + if isinstance(raw_exec, str): + exec_date = dt.date.fromisoformat(raw_exec) + elif isinstance(raw_exec, dt.date): + exec_date = raw_exec + else: + continue + + enriched = dict(row) + enriched["execution_date"] = exec_date + enriched["symbol"] = ticker + enriched["issuer_id"] = meta.get("issuer_id") + enriched["event_type"] = meta.get("event_type", "") + enriched["event_timestamp"] = meta.get("event_timestamp") + enriched["avg_dollar_volume"] = avg_dvol.get(ticker, 0.0) + enriched["sector"] = sectors.get(ticker, "UNKNOWN") + + candidates_by_exec_date.setdefault(exec_date, []).append(enriched) + + # Build bars_by_symbol_date: symbol -> date -> bar dict + bars_by_symbol_date: dict[str, dict[dt.date, dict[str, Any]]] = {} + for sym, date_bars in bars_by_symbol.items(): + bars_by_symbol_date[sym] = date_bars + + logger.info( + "snapshot_store_built", + exec_dates=len(candidates_by_exec_date), + symbols=len(bars_by_symbol_date), + ) + return { + "candidates_by_exec_date": candidates_by_exec_date, + "bars_by_symbol_date": bars_by_symbol_date, + "macro_by_date": macro_by_date, + } + + # ------------------------------------------------------------------ + # Internal async helpers + # ------------------------------------------------------------------ + + @staticmethod + async def _fetch_event_metadata( + event_ids: list[str], + db_dsn: str, + ) -> dict[str, dict[str, Any]]: + """Batch-query Event + SymbolMaster for event metadata.""" + if not event_ids: + return {} + try: + from sqlalchemy import select + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + + from libs.db.models import Event, SymbolMaster + + engine = create_async_engine(db_dsn, echo=False) + async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + result: dict[str, dict[str, Any]] = {} + async with async_session() as session: + # Single IN clause for all event_ids + stmt = ( + select(Event, SymbolMaster) + .outerjoin(SymbolMaster, Event.symbol_id == SymbolMaster.symbol_id) + .where(Event.event_id.in_(event_ids)) + ) + rows = (await session.execute(stmt)).all() + for event, sym in rows: + result[event.event_id] = { + "issuer_id": event.issuer_id, + "event_type": event.event_type, + "event_timestamp": event.filed_at_utc, + "ticker": sym.ticker if sym else None, + } + await engine.dispose() + return result + except Exception as exc: + logger.warning("snapshot_store_db_fetch_failed", error=str(exc)) + return {} + + @staticmethod + async def _fetch_price_data( + symbols: list[str], + date_range: tuple[dt.date, dt.date] | None, + oracle_url: str, + ) -> tuple[dict[str, dict[dt.date, dict[str, Any]]], dict[str, float]]: + """Fetch daily OHLCV bars and compute avg_dollar_volume per symbol.""" + if not symbols or date_range is None: + return {}, {} + try: + from libs.oracle_client.client import OracleClient + from libs.oracle_client.price import PriceService + + start_str = date_range[0].isoformat() + end_str = date_range[1].isoformat() + + bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]] = {} + avg_dvol: dict[str, float] = {} + + async with OracleClient(base_url=oracle_url) as client: + svc = PriceService(client) + for sym in symbols: + try: + resp = await svc.get_daily_bars(sym, start=start_str, end=end_str) + date_bars: dict[dt.date, dict[str, Any]] = {} + dollar_vols: list[float] = [] + for bar in resp.bars: + d = dt.date.fromisoformat(bar.date) + b = { + "date": d, + "open": bar.open, + "high": bar.high, + "low": bar.low, + "close": bar.close, + "volume": bar.volume, + } + date_bars[d] = b + dollar_vols.append(bar.close * bar.volume) + bars_by_symbol[sym] = date_bars + # 20-day mean of dollar volume + if dollar_vols: + last_20 = dollar_vols[-20:] + avg_dvol[sym] = sum(last_20) / len(last_20) + else: + avg_dvol[sym] = 0.0 + except Exception as sym_exc: + logger.warning( + "snapshot_store_price_fetch_failed", symbol=sym, error=str(sym_exc) + ) + bars_by_symbol[sym] = {} + avg_dvol[sym] = 0.0 + return bars_by_symbol, avg_dvol + except Exception as exc: + logger.warning("snapshot_store_oracle_failed", error=str(exc)) + return {}, {} + + @staticmethod + async def _fetch_sectors( + symbols: list[str], + oracle_url: str, + ) -> dict[str, str]: + """Fetch company sector for each symbol. Default 'UNKNOWN' if unavailable.""" + if not symbols: + return {} + result: dict[str, str] = {} + try: + from libs.oracle_client.client import OracleClient + from libs.oracle_client.financial import FinancialService + + async with OracleClient(base_url=oracle_url) as client: + for sym in symbols: + try: + info = await client.get(f"/api/v1/company/{sym}") + sector = info.get("sector") if isinstance(info, dict) else None + result[sym] = str(sector) if sector else "UNKNOWN" + except Exception: + result[sym] = "UNKNOWN" + except Exception as exc: + logger.warning("snapshot_store_sector_fetch_failed", error=str(exc)) + # Default all remaining to UNKNOWN + for sym in symbols: + result.setdefault(sym, "UNKNOWN") + return result + + @staticmethod + async def _fetch_macro( + date_range: tuple[dt.date, dt.date] | None, + db_dsn: str, + ) -> dict[dt.date, dict[str, Any]]: + """Load MacroObservation regime data from DB.""" + if date_range is None: + return {} + try: + from sqlalchemy import select + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + + from libs.db.models import MacroObservation + + engine = create_async_engine(db_dsn, echo=False) + async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + result: dict[dt.date, dict[str, Any]] = {} + async with async_session() as session: + stmt = select(MacroObservation).where( + MacroObservation.observation_date >= date_range[0], + MacroObservation.observation_date <= date_range[1], + ) + rows = (await session.execute(stmt)).scalars().all() + for obs in rows: + d = obs.observation_date + result.setdefault(d, {})[obs.series_id] = ( + float(obs.value) if obs.value is not None else None + ) + await engine.dispose() + return result + except Exception as exc: + logger.warning("snapshot_store_macro_fetch_failed", error=str(exc)) + return {} + + @staticmethod + def _compute_date_range( + rows: list[dict[str, Any]], + ) -> tuple[dt.date, dt.date] | None: + """Compute (min_date, max_date) from entry_date/execution_date column.""" + dates: list[dt.date] = [] + for r in rows: + raw = r.get("entry_date") or r.get("execution_date") + if raw is None: + continue + if isinstance(raw, str): + try: + dates.append(dt.date.fromisoformat(raw)) + except ValueError: + pass + elif isinstance(raw, dt.date): + dates.append(raw) + if not dates: + return None + return min(dates), max(dates) diff --git a/libs/backtest/splits.py b/libs/backtest/splits.py new file mode 100644 index 0000000..1cccfa7 --- /dev/null +++ b/libs/backtest/splits.py @@ -0,0 +1,117 @@ +"""Walk-forward and analysis split utilities for the backtester.""" +from __future__ import annotations + +import datetime as dt +from typing import Any + +from libs.backtest.calendar import get_trading_days + + +class WalkForwardWindow: + """A single walk-forward window with train and test date ranges.""" + + def __init__( + self, + window_index: int, + train_start: dt.date, + train_end: dt.date, + test_start: dt.date, + test_end: dt.date, + ) -> None: + self.window_index = window_index + self.train_start = train_start + self.train_end = train_end + self.test_start = test_start + self.test_end = test_end + + def __repr__(self) -> str: + return ( + f"WalkForwardWindow(idx={self.window_index}, " + f"train={self.train_start}→{self.train_end}, " + f"test={self.test_start}→{self.test_end})" + ) + + +def generate_walk_forward_windows( + all_dates: list[dt.date], + train_days: int = 252, + test_days: int = 63, + step_days: int | None = None, +) -> list[WalkForwardWindow]: + """Generate walk-forward windows over a list of trading dates. + + Args: + all_dates: Sorted list of trading days (ascending). + train_days: Number of trading days in each train window. + test_days: Number of trading days in each test window. + step_days: Number of days to advance between windows (defaults to test_days). + + Returns: + List of WalkForwardWindow objects. + """ + if step_days is None: + step_days = test_days + + windows = [] + idx = 0 + window_index = 0 + while idx + train_days + test_days <= len(all_dates): + train_slice = all_dates[idx : idx + train_days] + test_slice = all_dates[idx + train_days : idx + train_days + test_days] + windows.append( + WalkForwardWindow( + window_index=window_index, + train_start=train_slice[0], + train_end=train_slice[-1], + test_start=test_slice[0], + test_end=test_slice[-1], + ) + ) + idx += step_days + window_index += 1 + return windows + + +def split_by_year( + dates: list[dt.date], +) -> dict[int, list[dt.date]]: + """Group trading dates by calendar year.""" + result: dict[int, list[dt.date]] = {} + for d in dates: + result.setdefault(d.year, []).append(d) + return result + + +def split_by_regime( + dates: list[dt.date], + regime_map: dict[dt.date, str], + default_regime: str = "unknown", +) -> dict[str, list[dt.date]]: + """Group trading dates by market regime label. + + Args: + dates: Sorted list of trading dates. + regime_map: Mapping of date → regime label (e.g. "bull", "bear", "sideways"). + default_regime: Label to use when no regime data is available. + + Returns: + Dict mapping regime label to list of dates. + """ + result: dict[str, list[dt.date]] = {} + for d in dates: + regime = regime_map.get(d, default_regime) + result.setdefault(regime, []).append(d) + return result + + +def get_date_range_for_split( + snapshot_manifest: dict[str, Any], + split_name: str, +) -> tuple[dt.date | None, dt.date | None]: + """Extract start/end dates for a named split from a snapshot manifest.""" + split_info = snapshot_manifest.get("splits", {}).get(split_name, {}) + start_str = split_info.get("start_date") + end_str = split_info.get("end_date") + start = dt.date.fromisoformat(start_str) if start_str else None + end = dt.date.fromisoformat(end_str) if end_str else None + return start, end diff --git a/tests/fixtures/backtest_bars.json b/tests/fixtures/backtest_bars.json new file mode 100644 index 0000000..ad8f37d --- /dev/null +++ b/tests/fixtures/backtest_bars.json @@ -0,0 +1,16 @@ +{ + "AAPL": { + "2026-01-05": {"date": "2026-01-05", "open": 150.0, "high": 155.0, "low": 148.0, "close": 153.0, "volume": 1000000}, + "2026-01-06": {"date": "2026-01-06", "open": 153.0, "high": 160.0, "low": 152.0, "close": 158.0, "volume": 1200000}, + "2026-01-07": {"date": "2026-01-07", "open": 158.0, "high": 162.0, "low": 150.0, "close": 151.0, "volume": 900000}, + "2026-01-08": {"date": "2026-01-08", "open": 151.0, "high": 156.0, "low": 140.0, "close": 141.0, "volume": 1500000}, + "2026-01-09": {"date": "2026-01-09", "open": 141.0, "high": 145.0, "low": 138.0, "close": 143.0, "volume": 800000} + }, + "MSFT": { + "2026-01-05": {"date": "2026-01-05", "open": 300.0, "high": 310.0, "low": 298.0, "close": 307.0, "volume": 500000}, + "2026-01-06": {"date": "2026-01-06", "open": 307.0, "high": 320.0, "low": 305.0, "close": 318.0, "volume": 600000}, + "2026-01-07": {"date": "2026-01-07", "open": 318.0, "high": 322.0, "low": 308.0, "close": 310.0, "volume": 450000}, + "2026-01-08": {"date": "2026-01-08", "open": 310.0, "high": 315.0, "low": 295.0, "close": 297.0, "volume": 700000}, + "2026-01-09": {"date": "2026-01-09", "open": 297.0, "high": 305.0, "low": 293.0, "close": 302.0, "volume": 520000} + } +} diff --git a/tests/fixtures/backtest_manifest.json b/tests/fixtures/backtest_manifest.json new file mode 100644 index 0000000..f2e7689 --- /dev/null +++ b/tests/fixtures/backtest_manifest.json @@ -0,0 +1,15 @@ +{ + "experiment_name": "test_experiment", + "dataset_snapshot_id": "test_snapshot_001", + "description": "Minimal fixture manifest for unit tests.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "risk": { + "max_positions": 3, + "per_trade_risk_pct": 0.02 + } + }, + "splits": [], + "tags": ["test"], + "notes": "Test fixture" +} diff --git a/tests/fixtures/backtest_snapshot/manifest.json b/tests/fixtures/backtest_snapshot/manifest.json new file mode 100644 index 0000000..d38d18a --- /dev/null +++ b/tests/fixtures/backtest_snapshot/manifest.json @@ -0,0 +1,16 @@ +{ + "snapshot_id": "test_golden_001", + "created_at_utc": "2026-03-12T00:00:00+00:00", + "code_commit_hash": "abc1234", + "feature_version": "market_v1", + "parser_version": "rule-1.0.0", + "label_version": "label-1.0.0", + "split_policy": "temporal_70_15_15", + "row_counts": { + "train": 2, + "valid": 0, + "test": 0 + }, + "total_rows": 2, + "output_dir": "tests/fixtures/backtest_snapshot" +} \ No newline at end of file diff --git a/tests/fixtures/backtest_snapshot/train.parquet b/tests/fixtures/backtest_snapshot/train.parquet new file mode 100644 index 0000000000000000000000000000000000000000..2c4a6e8c5fdc0c94b582ec9ecb96fd3c18aef482 GIT binary patch literal 5871 zcmdT|OKcm*8Qx_nm7=7Ym9>jyB0ECiEL_+!mrGL*!mTuX%8DgRlqFF_0fH~lwD<}i zlBM?0LtFGzlJrYX>4igjCEkODFd+!f#IvLz2(j%4p-}XGBovB86C@c5-QC>W ziosihC!lNog+M5zYx?Yr=AY5@A!W)BB)=Y<5v6s_4^Jpm%rbx}LRXl@o-0A>B69}) z(>N66Vw-$;yiG1b$l1uW13sOi_#FoXFfCnUIrZFe0)vqCd-z`6j6bl6(}{G&iBg=c zloVyQZa#n_34AqvEDMuHGHh&5nB@&A#X>Ve=5XJTuHKUzv@gcB5nxxd&HZ9KbKtiD z8R;>V{?csHDL`7Fyv*dt^boN9x3pV!bswS(#dvXMEZGgv=Zi)6-+$luk^A3^gU%@~ zCZV{Pj4WKYp_*}M6*cKL%e?Xv7qD4V!<|910rmC_3<@iK)}U?9t*g=xScNXlj{>_D)yF&n0(pO@~ic&93EbG$*zZq^3H-3x72>92!k3|g=AF=j0oa~ERFnJ{KK3`_3; zR}fBfOZXB_n@fXs6>JpBVXNE{_8w0K$^|zFl^4dAjQ8YuIKt#o7YAf}I2P)~IKTTX zK7UyJ>E!doKaSu33m*@YP+Uv~ad_&*IEL&>EpA2lrcm%8L~ zwb*KBs|}`(K2PkGD=c% z9-KnTaJJpd_-`AV@x8-Brde!vnzf9-z|#UYv>?#X)iGL}4200`v@A{<3~O>^r+3Qx zYC;vJq%m($81{Jk^^qL1Q~t%U{A@`5(4o5ip9UcBO&lR5hX5ZQc-@iD9NuBCD&Wv# z4z}BOG}QM*`H850HKcwvgt3~3Al{zG)?B}5sl}f=}~y((%zU=3FrP@Mz1n zXx6hp5ub|kN22;4m-;7Kum133o`ZP%*KF-`CyGM+N|b*is_(nie~w_9tg{hsdeUOr z-EA`CJW_jSqWm{e{bW@A(mnMprXvpNhj#TfoplQKi720l>U-nr=@>e1HyUw<FG3gSH|*m_`dD^Vx@D9WFT>c2edM;>&-JP28* zA|B|bEs*toqZp^6dG5T|mbtd8wuG=D*?n#sRmzUKKE?do8$Dyw?&)Us= z-%|TV(|s(dpHD*5!KLuc#v%^S(sT^1dzYJlUdrl;%z!KtkPJ;o;Vo3*n*mwBCro%} ziu!8>@c2mx@p$TM-~|dMyeDs{FT8-qPeh2vQ(prwXvX{eP4(k90gs=I5Ra#R4IVqv zmhVm)UR(Z|`r#Gz_$I*x)h}12wXVVqvrw-3N8jM6u-(sDTjdr!cjVv^yjO=y;Ys&BkMb${zu5l@q%G57KQB5qQECz z;+7B%e#6y*p4`h5bR(CjbkhC>u;H(y^cDZE z-mVmYRYLu_0so=9;W6bOYaOmY4|WQ1!v^+KE=5QS^=}O5pA$dRFQ&o}f0V|bfHPq3 zpJfV7Q$bf@TrRIY%0Ul8_3*qx9cgKSahGiN^o{&+@uQXHwa#uTe4I@zXsJNDlCP~b za{6uLkPXxi9K#33*sUKIKkdKcTs3}}*B>504m!J#N7T=w{_KE07eBUYxRDR6`O~SD zz3dJK70H99Pap7~HmT|Sz`ce32Xi|s2RT?iM)}8h@8HF=O8|xl<7vc`EIA!p2;^FGudXdemLFzt7%$?e-Pf%;4dcl%k?#Wgvm>S Junzy~{vY=J&f5S0 literal 0 HcmV?d00001 diff --git a/tests/integration/backtest/__init__.py b/tests/integration/backtest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/backtest/test_backtest_run.py b/tests/integration/backtest/test_backtest_run.py new file mode 100644 index 0000000..92d533f --- /dev/null +++ b/tests/integration/backtest/test_backtest_run.py @@ -0,0 +1,259 @@ +"""Integration tests for the full backtest pipeline (no DB/HTTP — uses SnapshotStore directly).""" +from __future__ import annotations + +import datetime as dt +from pathlib import Path +from zoneinfo import ZoneInfo + +import pytest + +_UTC = ZoneInfo("UTC") + + +def _build_synthetic_store() -> object: + """Build a SnapshotStore with synthetic data for end-to-end testing.""" + from libs.backtest.snapshot_store import SnapshotStore + + # 5 trading days, 2 symbols + dates = [dt.date(2026, 1, d) for d in [5, 6, 7, 8, 9]] + + candidates = { + dt.date(2026, 1, 5): [ + { + "event_id": "EVT::001", + "symbol": "AAPL", + "execution_date": dt.date(2026, 1, 5), + "entry_date": "2026-01-05", + "entry_price": 150.0, + "score": 0.85, + "sector": "Technology", + "event_type": "earnings", + "event_timestamp": "2026-01-02T21:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-02", + "avg_dollar_volume": 5_000_000.0, + "atr_14": 3.0, + }, + ], + dt.date(2026, 1, 6): [ + { + "event_id": "EVT::002", + "symbol": "MSFT", + "execution_date": dt.date(2026, 1, 6), + "entry_date": "2026-01-06", + "entry_price": 300.0, + "score": 0.70, + "sector": "Technology", + "event_type": "guidance", + "event_timestamp": "2026-01-05T21:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-05", + "avg_dollar_volume": 10_000_000.0, + "atr_14": 5.0, + }, + ], + } + + bars = { + "AAPL": { + dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 150.0, "high": 160.0, "low": 148.0, "close": 158.0, "volume": 1_000_000}, + dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 158.0, "high": 170.0, "low": 155.0, "close": 165.0, "volume": 900_000}, + dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 165.0, "high": 175.0, "low": 160.0, "close": 170.0, "volume": 800_000}, + dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 170.0, "high": 180.0, "low": 165.0, "close": 175.0, "volume": 750_000}, + dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 175.0, "high": 185.0, "low": 170.0, "close": 180.0, "volume": 700_000}, + }, + "MSFT": { + dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 300.0, "high": 305.0, "low": 280.0, "close": 282.0, "volume": 500_000}, + dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 282.0, "high": 290.0, "low": 270.0, "close": 272.0, "volume": 480_000}, + dt.date(2026, 1, 8): {"date": dt.date(2026, 1, 8), "open": 272.0, "high": 280.0, "low": 260.0, "close": 265.0, "volume": 450_000}, + dt.date(2026, 1, 9): {"date": dt.date(2026, 1, 9), "open": 265.0, "high": 270.0, "low": 255.0, "close": 258.0, "volume": 420_000}, + }, + } + + return SnapshotStore( + candidates_by_exec_date=candidates, + bars_by_symbol_date=bars, + ) + + +def _make_config(): + from libs.backtest.domain import ( + BacktestConfig, + ExecutionConfig, + ReportingConfig, + RiskConfig, + SignalConfig, + UniverseConfig, + ) + + return BacktestConfig( + strategy_name="test_strategy", + dataset_snapshot_id="test_snapshot", + universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000), + signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5), + risk=RiskConfig( + per_trade_risk_pct=0.01, + max_daily_new_risk_pct=0.05, + max_positions=10, + max_positions_per_sector=5, + ), + execution=ExecutionConfig( + entry_fill_model="next_open", + exit_fill_model="daily_bar_approximation", + slippage_bps_base=10.0, + commission_per_share=0.005, + same_bar_priority="stop_first_conservative", + max_holding_days=10, + ), + reporting=ReportingConfig( + write_trade_blotter=True, + write_equity_curve=True, + write_metrics_summary=True, + generate_plots=False, + ), + ) + + +@pytest.mark.integration +class TestBacktestRunIntegration: + def test_run_completes(self, tmp_path): + """Full run completes without error.""" + from apps.backtester.run import BacktestRunner + from libs.backtest.domain import ExperimentManifest + + store = _build_synthetic_store() + manifest = ExperimentManifest( + experiment_name="test_exp", + dataset_snapshot_id="test_snapshot", + base_config="configs/backtest/defaults.json", + overrides={}, + ) + config = _make_config() + runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) + result = runner.run(output_root=tmp_path) + + assert result.run_id.startswith("bt_") + assert result.total_trading_days >= 0 + assert result.metrics.trade_count >= 0 + + def test_output_files_created(self, tmp_path): + """All expected output files are written.""" + from apps.backtester.run import BacktestRunner + from libs.backtest.domain import ExperimentManifest + + store = _build_synthetic_store() + manifest = ExperimentManifest( + experiment_name="test_exp", + dataset_snapshot_id="test_snapshot", + base_config="configs/backtest/defaults.json", + overrides={}, + ) + config = _make_config() + runner = BacktestRunner(manifest=manifest, config=config, store=store, initial_equity=100_000.0) + result = runner.run(output_root=tmp_path) + + run_dir = tmp_path / result.run_id + assert run_dir.exists() + assert (run_dir / "metadata.json").exists() + assert (run_dir / "manifest.json").exists() + assert (run_dir / "resolved_config.json").exists() + assert (run_dir / "metrics" / "metrics_summary.json").exists() + assert (run_dir / "plots").exists() # empty dir + + def test_equity_curve_has_all_days(self, tmp_path): + """Equity curve has one entry per candidate date.""" + from apps.backtester.run import BacktestRunner + from libs.backtest.domain import ExperimentManifest + + store = _build_synthetic_store() + manifest = ExperimentManifest( + experiment_name="test_exp", + dataset_snapshot_id="test_snapshot", + base_config="configs/backtest/defaults.json", + overrides={}, + ) + config = _make_config() + runner = BacktestRunner(manifest=manifest, config=config, store=store) + result = runner.run() + + # Should have simulated 2 days (2 execution dates in store) + assert result.total_trading_days == 2 + + def test_deterministic_results(self, tmp_path): + """Two runs with same inputs produce identical metrics.""" + from apps.backtester.run import BacktestRunner + from libs.backtest.domain import ExperimentManifest + + manifest = ExperimentManifest( + experiment_name="test_exp", + dataset_snapshot_id="test_snapshot", + base_config="configs/backtest/defaults.json", + overrides={}, + ) + config = _make_config() + + store1 = _build_synthetic_store() + runner1 = BacktestRunner(manifest=manifest, config=config, store=store1) + result1 = runner1.run() + + store2 = _build_synthetic_store() + runner2 = BacktestRunner(manifest=manifest, config=config, store=store2) + result2 = runner2.run() + + assert result1.metrics.trade_count == result2.metrics.trade_count + assert result1.metrics.win_rate == result2.metrics.win_rate + assert result1.metrics.total_return_pct == result2.metrics.total_return_pct + assert result1.total_candidates_seen == result2.total_candidates_seen + assert result1.total_orders_rejected == result2.total_orders_rejected + + def test_no_future_data_used(self, tmp_path): + """Candidates for day D should not appear in a simulation of day D-1.""" + from libs.backtest.snapshot_store import SnapshotStore + + candidates = { + dt.date(2026, 1, 5): [ + { + "event_id": "EVT::001", + "symbol": "AAPL", + "execution_date": dt.date(2026, 1, 5), + "entry_date": "2026-01-05", + "entry_price": 150.0, + "score": 0.85, + "sector": "Technology", + "event_type": "earnings", + "event_timestamp": "2026-01-02T21:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-02", + "avg_dollar_volume": 5_000_000.0, + "atr_14": 3.0, + } + ], + dt.date(2026, 1, 6): [ + { + "event_id": "EVT::FUTURE", + "symbol": "FUTURE_TICKER", + "execution_date": dt.date(2026, 1, 6), + "entry_date": "2026-01-06", + "entry_price": 50.0, + "score": 0.99, + "sector": "Technology", + "event_type": "earnings", + "event_timestamp": "2026-01-05T21:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-05", + "avg_dollar_volume": 1_000_000.0, + "atr_14": 1.0, + } + ], + } + bars = { + "AAPL": {dt.date(2026, 1, 5): {"date": dt.date(2026, 1, 5), "open": 150.0, "high": 160.0, "low": 148.0, "close": 158.0, "volume": 1_000_000}}, + "FUTURE_TICKER": {dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 50.0, "high": 55.0, "low": 48.0, "close": 52.0, "volume": 500_000}}, + } + store = SnapshotStore(candidates_by_exec_date=candidates, bars_by_symbol_date=bars) + + # Querying Jan 5 should NOT return FUTURE_TICKER candidate + rows = store.get_candidates_for_date(dt.date(2026, 1, 5)) + symbols = [r["symbol"] for r in rows] + assert "FUTURE_TICKER" not in symbols + assert "AAPL" in symbols diff --git a/tests/replay/test_determinism.py b/tests/replay/test_determinism.py index 29a67e1..0318224 100644 --- a/tests/replay/test_determinism.py +++ b/tests/replay/test_determinism.py @@ -54,3 +54,150 @@ def test_feature_determinism(sample_parser_output): f1 = compute_event_features(sample_parser_output) f2 = compute_event_features(sample_parser_output) assert f1 == f2 + + +# --------------------------------------------------------------------------- +# Backtest determinism tests +# --------------------------------------------------------------------------- + + +def _build_synthetic_store(): + """Reusable synthetic SnapshotStore for backtest replay tests.""" + import datetime as dt + from zoneinfo import ZoneInfo + + from libs.backtest.snapshot_store import SnapshotStore + + candidates = { + dt.date(2026, 1, 5): [ + { + "event_id": "EVT::REPLAY::001", + "symbol": "AAPL", + "execution_date": dt.date(2026, 1, 5), + "entry_date": "2026-01-05", + "entry_price": 150.0, + "score": 0.85, + "sector": "Technology", + "event_type": "earnings", + "event_timestamp": "2026-01-02T21:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-02", + "avg_dollar_volume": 5_000_000.0, + "atr_14": 3.0, + } + ], + } + bars = { + "AAPL": { + dt.date(2026, 1, 5): { + "date": dt.date(2026, 1, 5), + "open": 150.0, "high": 160.0, "low": 148.0, "close": 157.0, "volume": 1_000_000, + }, + dt.date(2026, 1, 6): { + "date": dt.date(2026, 1, 6), + "open": 157.0, "high": 168.0, "low": 155.0, "close": 165.0, "volume": 900_000, + }, + } + } + return SnapshotStore(candidates_by_exec_date=candidates, bars_by_symbol_date=bars) + + +def _make_backtest_config(): + from libs.backtest.domain import ( + BacktestConfig, + ExecutionConfig, + ReportingConfig, + RiskConfig, + SignalConfig, + UniverseConfig, + ) + + return BacktestConfig( + strategy_name="replay_test", + dataset_snapshot_id="test_snapshot", + universe=UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000), + signal=SignalConfig(score_threshold=0.5, max_candidates_per_day=5), + risk=RiskConfig( + per_trade_risk_pct=0.01, + max_daily_new_risk_pct=0.05, + max_positions=10, + max_positions_per_sector=5, + ), + execution=ExecutionConfig( + entry_fill_model="next_open", + exit_fill_model="daily_bar_approximation", + slippage_bps_base=10.0, + commission_per_share=0.005, + same_bar_priority="stop_first_conservative", + max_holding_days=10, + ), + reporting=ReportingConfig( + write_trade_blotter=False, + write_equity_curve=False, + write_metrics_summary=False, + ), + ) + + +@pytest.mark.replay +def test_backtest_determinism(): + """Running the backtest twice on the same synthetic store yields identical metrics.""" + from apps.backtester.run import BacktestRunner + from libs.backtest.domain import ExperimentManifest + + manifest = ExperimentManifest( + experiment_name="replay_test", + dataset_snapshot_id="test_snapshot", + base_config="configs/backtest/defaults.json", + overrides={}, + ) + config = _make_backtest_config() + + store1 = _build_synthetic_store() + runner1 = BacktestRunner(manifest=manifest, config=config, store=store1, initial_equity=100_000.0) + result1 = runner1.run() + + store2 = _build_synthetic_store() + runner2 = BacktestRunner(manifest=manifest, config=config, store=store2, initial_equity=100_000.0) + result2 = runner2.run() + + # Core metrics must be identical + assert result1.metrics.trade_count == result2.metrics.trade_count + assert result1.metrics.win_rate == result2.metrics.win_rate + assert result1.metrics.total_return_pct == result2.metrics.total_return_pct + assert result1.metrics.max_drawdown_pct == result2.metrics.max_drawdown_pct + assert result1.total_candidates_seen == result2.total_candidates_seen + assert result1.total_orders_rejected == result2.total_orders_rejected + assert result1.total_trading_days == result2.total_trading_days + + +@pytest.mark.replay +def test_backtest_selector_determinism(): + """Selector ranking is deterministic across multiple calls.""" + from libs.backtest.domain import SignalConfig, UniverseConfig + from libs.backtest.selector import select_candidates + + rows = [ + { + "event_id": f"EVT::{'ABCDE'[i]}", + "symbol": "ABCDE"[i], + "entry_date": "2026-01-05", + "entry_price": 100.0 + i, + "score": 0.9 - i * 0.05, + "sector": "Technology", + "event_type": "earnings", + "event_timestamp": "2026-01-02T21:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-02", + "avg_dollar_volume": 5_000_000.0 + i * 100_000, + "atr_14": 2.0, + } + for i in range(5) + ] + u = UniverseConfig(min_price=5.0, min_avg_dollar_volume=100_000) + s = SignalConfig(score_threshold=0.5, max_candidates_per_day=10) + + result1 = select_candidates(rows, u, s) + result2 = select_candidates(rows, u, s) + + assert [c.symbol for c in result1] == [c.symbol for c in result2] diff --git a/tests/unit/backtest/__init__.py b/tests/unit/backtest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/backtest/test_allocator.py b/tests/unit/backtest/test_allocator.py new file mode 100644 index 0000000..732ec30 --- /dev/null +++ b/tests/unit/backtest/test_allocator.py @@ -0,0 +1,238 @@ +"""Unit tests for libs/backtest/allocator.py.""" +from __future__ import annotations + +import datetime as dt +import math +from zoneinfo import ZoneInfo + +import pytest + +from libs.backtest.domain import ( + BacktestConfig, + Candidate, + DailyPortfolioState, + ExecutionConfig, + OpenPosition, + PlannedOrder, + PositionStatus, + RiskConfig, + SignalConfig, + UniverseConfig, +) + +_UTC = ZoneInfo("UTC") +_NOW = dt.datetime(2026, 1, 5, 21, 0, tzinfo=_UTC) +_TODAY = dt.date(2026, 1, 5) +_TOMORROW = dt.date(2026, 1, 6) + + +def _make_candidate(**kwargs) -> Candidate: + defaults = dict( + event_id="EVT::TEST", + symbol="AAPL", + issuer_id=None, + score=0.8, + sector="Technology", + event_type="earnings", + event_timestamp=_NOW, + filing_time_bucket="post_market", + reaction_date=_TODAY, + execution_date=_TOMORROW, + entry_price_est=100.0, + avg_dollar_volume=5_000_000.0, + atr_14=2.0, + score_bucket="high", + ) + defaults.update(kwargs) + return Candidate(**defaults) + + +def _make_portfolio_state(**kwargs) -> DailyPortfolioState: + defaults = dict( + date=_TOMORROW, + equity=100_000.0, + cash_available=100_000.0, + 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=100_000.0, + current_drawdown_pct=0.0, + ) + defaults.update(kwargs) + return DailyPortfolioState(**defaults) + + +def _make_config(**kwargs) -> BacktestConfig: + defaults = dict(strategy_name="test", dataset_snapshot_id="snap_001") + defaults.update(kwargs) + return BacktestConfig(**defaults) + + +class TestComputeStopPrice: + def test_atr_stop(self): + from libs.backtest.allocator import compute_stop_price + + c = _make_candidate(entry_price_est=100.0, atr_14=2.0) + stop = compute_stop_price(c, RiskConfig( + per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, + max_positions=10, max_positions_per_sector=3 + )) + # 1.5 * ATR below price + assert stop == pytest.approx(100.0 - 1.5 * 2.0) + + def test_fallback_stop_when_no_atr(self): + from libs.backtest.allocator import compute_stop_price + + c = _make_candidate(entry_price_est=100.0, atr_14=None) + stop = compute_stop_price(c, RiskConfig( + per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, + max_positions=10, max_positions_per_sector=3 + )) + # 2% fallback + assert stop == pytest.approx(98.0) + + def test_stop_never_negative(self): + from libs.backtest.allocator import compute_stop_price + + c = _make_candidate(entry_price_est=1.0, atr_14=5.0) + stop = compute_stop_price(c, RiskConfig( + per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, + max_positions=10, max_positions_per_sector=3 + )) + assert stop >= 0.01 + + +class TestComputeShares: + def test_basic(self): + from libs.backtest.allocator import compute_shares + + # 1% of 100k = 1000 risk, 100-95=5 stop distance → 200 shares + shares = compute_shares( + 100_000.0, 100.0, 95.0, + RiskConfig(per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, + max_positions=10, max_positions_per_sector=3) + ) + assert shares == 200 + + def test_always_floor(self): + from libs.backtest.allocator import compute_shares + + # Result should always be floor + shares = compute_shares( + 100_000.0, 100.0, 96.7, + RiskConfig(per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, + max_positions=10, max_positions_per_sector=3) + ) + # raw = 1000 / 3.3 ≈ 303.03 → floor = 303 + assert shares == math.floor(1000.0 / 3.3) + + def test_zero_when_stop_above_entry(self): + from libs.backtest.allocator import compute_shares + + shares = compute_shares(100_000.0, 95.0, 100.0, RiskConfig( + per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, + max_positions=10, max_positions_per_sector=3 + )) + assert shares == 0 + + +class TestRunEntryGates: + def test_pass_all_gates(self): + from libs.backtest.allocator import run_entry_gates + + c = _make_candidate() + ps = _make_portfolio_state() + cfg = _make_config() + assert run_entry_gates(c, ps, [], cfg) is None + + def test_gate1_kill_switch(self): + from libs.backtest.allocator import run_entry_gates + + c = _make_candidate() + ps = _make_portfolio_state(current_drawdown_pct=30.0) + assert run_entry_gates(c, ps, [], _make_config()) == "kill_switch_drawdown" + + def test_gate2_max_positions(self): + from libs.backtest.allocator import run_entry_gates + + c = _make_candidate() + ps = _make_portfolio_state() + cfg = _make_config() + cfg.risk.max_positions = 0 # impossible to add + + # Mock 0 positions but max is 0 + result = run_entry_gates(c, ps, [], cfg) + assert result == "max_positions_reached" + + def test_gate4_sector_limit(self): + from libs.backtest.allocator import run_entry_gates + + # Create a position in the same sector + plan = PlannedOrder( + candidate=_make_candidate(symbol="MSFT", sector="Technology"), + shares=10, entry_price_limit=100.0, stop_price=95.0, + target_price=110.0, risk_dollars=50.0, + ) + existing_pos = OpenPosition( + position_id="p1", plan=plan, entry_date=_TODAY, + entry_price=100.0, entry_fill_slippage_bps=10.0, + current_stop=95.0, target_price=110.0, peak_price=100.0, + shares_open=10, shares_total=10, + ) + cfg = _make_config() + cfg.risk.max_positions_per_sector = 1 # only 1 per sector + + c = _make_candidate(symbol="AAPL", sector="Technology") + result = run_entry_gates(c, _make_portfolio_state(), [existing_pos], cfg) + assert result == "sector_limit" + + def test_gate3_duplicate_symbol(self): + from libs.backtest.allocator import run_entry_gates + + plan = PlannedOrder( + candidate=_make_candidate(symbol="AAPL"), + shares=10, entry_price_limit=100.0, stop_price=95.0, + target_price=110.0, risk_dollars=50.0, + ) + existing = OpenPosition( + position_id="p1", plan=plan, entry_date=_TODAY, + entry_price=100.0, entry_fill_slippage_bps=10.0, + current_stop=95.0, target_price=110.0, peak_price=100.0, + shares_open=10, shares_total=10, + ) + c = _make_candidate(symbol="AAPL") + result = run_entry_gates(c, _make_portfolio_state(), [existing], _make_config()) + assert result == "duplicate_symbol" + + def test_gate7_cooldown(self): + from libs.backtest.allocator import run_entry_gates + + c = _make_candidate() + result = run_entry_gates(c, _make_portfolio_state(), [], _make_config(), cooldown_remaining=2) + assert result == "cooldown" + + +class TestBuildPlannedOrder: + def test_valid_order(self): + from libs.backtest.allocator import build_planned_order + + c = _make_candidate(entry_price_est=100.0, atr_14=2.0) + ps = _make_portfolio_state() + order = build_planned_order(c, ps, [], _make_config()) + assert order.skip_reason is None + assert order.shares > 0 + assert order.stop_price < 100.0 + assert order.target_price > 100.0 + + def test_rejected_order_has_skip_reason(self): + from libs.backtest.allocator import build_planned_order + + c = _make_candidate() + ps = _make_portfolio_state(current_drawdown_pct=30.0) + order = build_planned_order(c, ps, [], _make_config()) + assert order.skip_reason == "kill_switch_drawdown" + assert order.shares == 0 diff --git a/tests/unit/backtest/test_calendar.py b/tests/unit/backtest/test_calendar.py new file mode 100644 index 0000000..02e1578 --- /dev/null +++ b/tests/unit/backtest/test_calendar.py @@ -0,0 +1,94 @@ +"""Unit tests for libs/backtest/calendar.py.""" +from __future__ import annotations + +import datetime as dt + +import pytest + +from libs.backtest.calendar import ( + get_trading_days, + is_trading_day, + next_trading_day, + resolve_execution_date, +) + + +class TestResolvExecutionDate: + def test_pre_market_weekday(self): + # pre_market on a trading day: reaction = same day, execution = next trading day + event_date = dt.date(2026, 1, 5) # Monday + exec_date = resolve_execution_date(event_date, "pre_market") + assert is_trading_day(exec_date) + assert exec_date > event_date + + def test_post_market_weekday(self): + # post_market: reaction = next trading day, execution = trading day after that + event_date = dt.date(2026, 1, 5) # Monday + exec_date = resolve_execution_date(event_date, "post_market") + assert is_trading_day(exec_date) + assert exec_date > event_date + + def test_post_market_friday(self): + # post_market Friday → reaction = Monday, execution = Tuesday + friday = dt.date(2026, 1, 2) # Friday + exec_date = resolve_execution_date(friday, "post_market") + assert is_trading_day(exec_date) + # Must be at least Monday + assert exec_date >= dt.date(2026, 1, 5) + + def test_unknown_bucket(self): + # unknown treated same as post_market + event_date = dt.date(2026, 1, 5) + exec_date = resolve_execution_date(event_date, "unknown") + assert is_trading_day(exec_date) + assert exec_date > event_date + + def test_execution_after_reaction(self): + """execution_date should always be strictly after event_date.""" + for bucket in ["pre_market", "regular_hours", "post_market", "unknown"]: + exec_date = resolve_execution_date(dt.date(2026, 1, 5), bucket) + assert exec_date > dt.date(2026, 1, 5), f"Failed for bucket: {bucket}" + + +class TestGetTradingDays: + def test_basic_range(self): + days = get_trading_days(dt.date(2026, 1, 5), dt.date(2026, 1, 9)) + assert len(days) == 5 # Mon-Fri + assert all(is_trading_day(d) for d in days) + + def test_excludes_weekends(self): + days = get_trading_days(dt.date(2026, 1, 3), dt.date(2026, 1, 11)) + for d in days: + assert d.weekday() < 5 # Not Saturday (5) or Sunday (6) + + def test_single_day(self): + days = get_trading_days(dt.date(2026, 1, 5), dt.date(2026, 1, 5)) + assert len(days) == 1 + assert days[0] == dt.date(2026, 1, 5) + + def test_sorted_ascending(self): + days = get_trading_days(dt.date(2026, 1, 5), dt.date(2026, 1, 30)) + assert days == sorted(days) + + +class TestIsTradingDay: + def test_weekday_is_trading(self): + assert is_trading_day(dt.date(2026, 1, 5)) # Monday + + def test_weekend_not_trading(self): + assert not is_trading_day(dt.date(2026, 1, 3)) # Saturday + + def test_sunday_not_trading(self): + assert not is_trading_day(dt.date(2026, 1, 4)) # Sunday + + +class TestNextTradingDay: + def test_friday_to_monday(self): + friday = dt.date(2026, 1, 2) + nxt = next_trading_day(friday) + assert nxt == dt.date(2026, 1, 5) # Monday + + def test_monday_to_tuesday(self): + monday = dt.date(2026, 1, 5) + nxt = next_trading_day(monday) + assert nxt == dt.date(2026, 1, 6) diff --git a/tests/unit/backtest/test_domain.py b/tests/unit/backtest/test_domain.py new file mode 100644 index 0000000..d0fb13a --- /dev/null +++ b/tests/unit/backtest/test_domain.py @@ -0,0 +1,232 @@ +"""Unit tests for libs/backtest/domain.py.""" +from __future__ import annotations + +import datetime as dt +from zoneinfo import ZoneInfo + +import pytest +from pydantic import ValidationError + +from libs.backtest.domain import ( + BacktestConfig, + Candidate, + DailyPortfolioState, + ExitReason, + ExecutionConfig, + ExperimentManifest, + FilledTrade, + MetricsBundle, + OpenPosition, + PlannedOrder, + PositionStatus, + ReportingConfig, + RiskConfig, + SignalConfig, + UniverseConfig, +) + +_UTC = ZoneInfo("UTC") +_NOW = dt.datetime(2026, 1, 5, 14, 30, tzinfo=_UTC) +_TODAY = dt.date(2026, 1, 5) +_TOMORROW = dt.date(2026, 1, 6) + + +def _make_candidate(**kwargs) -> Candidate: + defaults = dict( + event_id="EVT::DOC::TEST::earnings::0", + symbol="AAPL", + issuer_id="ISSUER::0000320193", + score=0.75, + sector="Technology", + event_type="earnings", + event_timestamp=_NOW, + filing_time_bucket="post_market", + reaction_date=_TODAY, + execution_date=_TOMORROW, + entry_price_est=150.0, + avg_dollar_volume=5_000_000.0, + atr_14=3.5, + score_bucket="high", + ) + defaults.update(kwargs) + return Candidate(**defaults) + + +def _make_filled_trade(**kwargs) -> FilledTrade: + defaults = dict( + trade_id="t1", + position_id="p1", + event_id="EVT::TEST", + symbol="AAPL", + entry_date=_TODAY, + exit_date=_TOMORROW, + entry_price=150.0, + exit_price=160.0, + exit_reason=ExitReason.TARGET, + shares=10, + commission=0.10, + slippage_bps=10.0, + gross_pnl=100.0, + net_pnl=99.9, + pnl_pct=0.0667, + r_multiple=2.0, + holding_days=1, + ) + defaults.update(kwargs) + return FilledTrade(**defaults) + + +class TestPositionStatus: + def test_values(self): + assert PositionStatus.PLANNED == "PLANNED" + assert PositionStatus.CLOSED == "CLOSED" + + def test_all_statuses(self): + expected = {"PLANNED", "ENTERED", "PARTIALLY_EXITED", "OPEN", "EXIT_PENDING", "CLOSED", "ARCHIVED"} + assert {s.value for s in PositionStatus} == expected + + +class TestExitReason: + def test_values(self): + assert ExitReason.STOP == "STOP" + assert ExitReason.TARGET == "TARGET" + assert ExitReason.TIME == "TIME" + assert ExitReason.TRAILING == "TRAILING" + assert ExitReason.KILL_SWITCH == "KILL_SWITCH" + assert ExitReason.MISSING_BAR == "MISSING_BAR" + + +class TestCandidate: + def test_basic_creation(self): + c = _make_candidate() + assert c.symbol == "AAPL" + assert c.score == 0.75 + assert c.event_timestamp.tzinfo is not None + + def test_frozen(self): + c = _make_candidate() + with pytest.raises(Exception): # frozen model + c.score = 0.9 + + def test_timezone_aware_timestamp(self): + c = _make_candidate(event_timestamp=dt.datetime(2026, 1, 5, 20, 0, tzinfo=_UTC)) + assert c.event_timestamp.tzinfo is not None + + def test_features_default_empty(self): + c = _make_candidate() + assert c.features == {} + + def test_features_stored(self): + c = _make_candidate(features={"foo": 1.0, "bar": "baz"}) + assert c.features["foo"] == 1.0 + + +class TestFilledTrade: + def test_basic(self): + t = _make_filled_trade() + assert t.net_pnl == 99.9 + assert t.exit_reason == ExitReason.TARGET + + def test_frozen(self): + t = _make_filled_trade() + with pytest.raises(Exception): + t.net_pnl = 0.0 + + def test_stop_exit_reason(self): + t = _make_filled_trade(exit_reason=ExitReason.STOP, net_pnl=-50.0) + assert t.exit_reason == ExitReason.STOP + + +class TestOpenPosition: + def test_mutable(self): + c = _make_candidate() + plan = PlannedOrder( + candidate=c, + shares=10, + entry_price_limit=150.0, + stop_price=144.0, + target_price=162.0, + risk_dollars=60.0, + ) + pos = OpenPosition( + position_id="p1", + plan=plan, + entry_date=_TOMORROW, + entry_price=150.5, + entry_fill_slippage_bps=10.0, + current_stop=144.0, + target_price=162.0, + peak_price=150.5, + shares_open=10, + shares_total=10, + ) + # Should be mutable + pos.days_held = 3 + assert pos.days_held == 3 + pos.current_stop = 146.0 + assert pos.current_stop == 146.0 + + +class TestDailyPortfolioState: + def test_basic(self): + s = DailyPortfolioState( + date=_TODAY, + equity=100_000.0, + cash_available=90_000.0, + gross_exposure=10_000.0, + net_exposure=10_000.0, + reserved_risk_budget=1_000.0, + unrealized_pnl=500.0, + realized_pnl=200.0, + open_positions=["p1"], + daily_new_risk_used=500.0, + peak_equity=100_500.0, + current_drawdown_pct=0.5, + ) + assert s.equity == 100_000.0 + assert len(s.open_positions) == 1 + + +class TestMetricsBundle: + def test_defaults(self): + m = MetricsBundle() + assert m.trade_count == 0 + assert m.win_rate is None + assert m.score_bucket_hit_rate == {} + + def test_with_values(self): + m = MetricsBundle(trade_count=10, win_rate=0.6, total_return_pct=15.0) + assert m.trade_count == 10 + assert m.win_rate == 0.6 + + +class TestConfigModels: + def test_universe_config_defaults(self): + u = UniverseConfig() + assert u.min_price == 5.0 + assert u.exclude_asset_types == [] + + def test_risk_config(self): + r = RiskConfig( + per_trade_risk_pct=0.01, + max_daily_new_risk_pct=0.03, + max_positions=10, + max_positions_per_sector=3, + ) + assert r.per_trade_risk_pct == 0.01 + + def test_backtest_config(self): + cfg = BacktestConfig(strategy_name="test", dataset_snapshot_id="snap_001") + assert cfg.strategy_name == "test" + assert isinstance(cfg.risk, RiskConfig) + assert isinstance(cfg.execution, ExecutionConfig) + + def test_experiment_manifest(self): + m = ExperimentManifest( + experiment_name="test_exp", + dataset_snapshot_id="snap_001", + base_config="configs/backtest/defaults.json", + overrides={}, + ) + assert m.experiment_name == "test_exp" + assert m.splits == [] diff --git a/tests/unit/backtest/test_execution.py b/tests/unit/backtest/test_execution.py new file mode 100644 index 0000000..c452dbf --- /dev/null +++ b/tests/unit/backtest/test_execution.py @@ -0,0 +1,257 @@ +"""Unit tests for libs/backtest/execution.py.""" +from __future__ import annotations + +import datetime as dt +from zoneinfo import ZoneInfo + +import pytest + +from libs.backtest.domain import ( + Candidate, + ExecutionConfig, + ExitReason, + OpenPosition, + PlannedOrder, + PositionStatus, +) + +_UTC = ZoneInfo("UTC") +_NOW = dt.datetime(2026, 1, 5, 21, 0, tzinfo=_UTC) +_TODAY = dt.date(2026, 1, 5) +_TOMORROW = dt.date(2026, 1, 6) +_DAY3 = dt.date(2026, 1, 7) + + +def _make_candidate(**kwargs) -> Candidate: + return Candidate( + event_id="EVT::TEST", + symbol="AAPL", + score=0.8, + sector="Technology", + event_type="earnings", + event_timestamp=_NOW, + filing_time_bucket="post_market", + reaction_date=_TODAY, + execution_date=_TOMORROW, + entry_price_est=100.0, + avg_dollar_volume=5_000_000.0, + atr_14=2.0, + score_bucket="high", + **kwargs, + ) + + +def _make_plan(entry_price=100.0, stop=95.0, target=110.0, shares=100) -> PlannedOrder: + return PlannedOrder( + candidate=_make_candidate(), + shares=shares, + entry_price_limit=entry_price, + stop_price=stop, + target_price=target, + risk_dollars=500.0, + ) + + +def _make_bar(open=101.0, high=108.0, low=98.0, close=105.0, date=None) -> dict: + return { + "date": date or _TOMORROW, + "open": open, + "high": high, + "low": low, + "close": close, + "volume": 1_000_000, + } + + +def _make_exec_config(**kwargs) -> ExecutionConfig: + defaults = dict( + entry_fill_model="next_open", + exit_fill_model="daily_bar_approximation", + slippage_bps_base=10.0, + commission_per_share=0.005, + same_bar_priority="stop_first_conservative", + max_holding_days=10, + ) + defaults.update(kwargs) + return ExecutionConfig(**defaults) + + +def _make_open_position(entry_price=101.0, stop=95.0, target=110.0, days=0, shares=100) -> OpenPosition: + plan = _make_plan(entry_price=100.0, stop=stop, target=target, shares=shares) + return OpenPosition( + position_id="p1", + plan=plan, + entry_date=_TODAY, + entry_price=entry_price, + entry_fill_slippage_bps=10.0, + current_stop=stop, + target_price=target, + peak_price=entry_price, + shares_open=shares, + shares_total=shares, + days_held=days, + ) + + +class TestSimulateEntry: + def test_basic_entry(self): + from libs.backtest.execution import simulate_entry + + plan = _make_plan() + bar = _make_bar(open=100.0) + cfg = _make_exec_config(slippage_bps_base=10.0) + pos = simulate_entry(plan, bar, cfg) + assert pos is not None + # Entry fill = open * (1 + 10/10000) + expected = 100.0 * (1 + 10 / 10_000) + assert pos.entry_price == pytest.approx(expected) + + def test_missing_bar_returns_none(self): + from libs.backtest.execution import simulate_entry + + plan = _make_plan() + assert simulate_entry(plan, None, _make_exec_config()) is None + + def test_zero_open_returns_none(self): + from libs.backtest.execution import simulate_entry + + plan = _make_plan() + bar = _make_bar(open=0.0) + assert simulate_entry(plan, bar, _make_exec_config()) is None + + def test_rejected_plan_returns_none(self): + from libs.backtest.execution import simulate_entry + + plan = PlannedOrder( + candidate=_make_candidate(), + shares=100, + entry_price_limit=100.0, + stop_price=95.0, + target_price=110.0, + risk_dollars=500.0, + skip_reason="max_positions_reached", + ) + bar = _make_bar() + assert simulate_entry(plan, bar, _make_exec_config()) is None + + def test_entry_date_from_bar(self): + from libs.backtest.execution import simulate_entry + + plan = _make_plan() + bar = _make_bar(date=_TOMORROW) + pos = simulate_entry(plan, bar, _make_exec_config()) + assert pos.entry_date == _TOMORROW + + +class TestSimulateExit: + def test_stop_exit(self): + from libs.backtest.execution import simulate_exit + + pos = _make_open_position(entry_price=101.0, stop=95.0, target=115.0) + bar = _make_bar(low=90.0, high=100.0) # low < stop + cfg = _make_exec_config() + trade = simulate_exit(pos, bar, cfg, _TOMORROW) + assert trade is not None + assert trade.exit_reason == ExitReason.STOP + # Fill at stop * (1 - slippage) + expected = 95.0 * (1 - 10 / 10_000) + assert trade.exit_price == pytest.approx(expected) + + def test_target_exit(self): + from libs.backtest.execution import simulate_exit + + pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0) + bar = _make_bar(low=102.0, high=115.0) # high > target + trade = simulate_exit(pos, bar, _make_exec_config(), _TOMORROW) + assert trade is not None + assert trade.exit_reason == ExitReason.TARGET + expected = 110.0 * (1 - 10 / 10_000) + assert trade.exit_price == pytest.approx(expected) + + def test_same_bar_stop_first_conservative(self): + from libs.backtest.execution import simulate_exit + + pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0) + bar = _make_bar(low=90.0, high=115.0) # both stop AND target hit + cfg = _make_exec_config(same_bar_priority="stop_first_conservative") + trade = simulate_exit(pos, bar, cfg, _TOMORROW) + assert trade.exit_reason == ExitReason.STOP + + def test_same_bar_target_first_aggressive(self): + from libs.backtest.execution import simulate_exit + + pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0) + bar = _make_bar(low=90.0, high=115.0) + cfg = _make_exec_config(same_bar_priority="target_first_aggressive") + trade = simulate_exit(pos, bar, cfg, _TOMORROW) + assert trade.exit_reason == ExitReason.TARGET + + def test_time_exit(self): + from libs.backtest.execution import simulate_exit + + pos = _make_open_position(entry_price=101.0, stop=95.0, target=120.0, days=10) + bar = _make_bar(low=100.0, high=105.0, close=103.0) # no stop or target hit + cfg = _make_exec_config(max_holding_days=10) + trade = simulate_exit(pos, bar, cfg, _TOMORROW) + assert trade is not None + assert trade.exit_reason == ExitReason.TIME + + def test_no_exit_when_bar_in_range(self): + from libs.backtest.execution import simulate_exit + + pos = _make_open_position(entry_price=101.0, stop=95.0, target=120.0, days=3) + bar = _make_bar(low=98.0, high=108.0) + trade = simulate_exit(pos, bar, _make_exec_config(), _TOMORROW) + assert trade is None + + def test_missing_bar_no_exit(self): + from libs.backtest.execution import simulate_exit + + pos = _make_open_position() + assert simulate_exit(pos, None, _make_exec_config(), _TOMORROW) is None + + def test_r_multiple_uses_actual_fill(self): + from libs.backtest.execution import simulate_exit + + # entry=101, stop=95 → risk per share = 6 + pos = _make_open_position(entry_price=101.0, stop=95.0, target=113.0) + bar = _make_bar(low=98.0, high=115.0) # target hit + trade = simulate_exit(pos, bar, _make_exec_config(slippage_bps_base=0.0), _TOMORROW) + # R-multiple = (exit - entry) / (entry - stop) = (113 - 101) / (101 - 95) = 12/6 = 2.0 + assert trade.r_multiple == pytest.approx(2.0, rel=0.01) + + def test_pnl_includes_commission(self): + from libs.backtest.execution import simulate_exit + + pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100) + bar = _make_bar(low=98.0, high=115.0) # target hit + cfg = _make_exec_config(slippage_bps_base=0.0, commission_per_share=0.01) + trade = simulate_exit(pos, bar, cfg, _TOMORROW) + # gross = (110 - 100) * 100 = 1000 + # commission = 100 * 0.01 * 2 = 2.0 + assert trade.gross_pnl == pytest.approx(1000.0) + assert trade.net_pnl == pytest.approx(998.0) + + +class TestUpdateTrailingStop: + def test_ratchets_up(self): + from libs.backtest.execution import update_trailing_stop + + pos = _make_open_position(stop=95.0) + update_trailing_stop(pos, _make_bar(low=97.0)) + assert pos.current_stop == pytest.approx(97.0) + + def test_never_moves_down(self): + from libs.backtest.execution import update_trailing_stop + + pos = _make_open_position(stop=95.0) + update_trailing_stop(pos, _make_bar(low=92.0)) + assert pos.current_stop == pytest.approx(95.0) + + def test_updates_peak_price(self): + from libs.backtest.execution import update_trailing_stop + + pos = _make_open_position(entry_price=100.0) + pos.peak_price = 100.0 + update_trailing_stop(pos, _make_bar(high=115.0, low=100.0)) + assert pos.peak_price == pytest.approx(115.0) diff --git a/tests/unit/backtest/test_manifests.py b/tests/unit/backtest/test_manifests.py new file mode 100644 index 0000000..adfe3e7 --- /dev/null +++ b/tests/unit/backtest/test_manifests.py @@ -0,0 +1,198 @@ +"""Unit tests for libs/backtest/manifests.py.""" +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +from libs.backtest.domain import BacktestConfig, ExperimentManifest + + +def _write_json(path: Path, data: dict) -> None: + path.write_text(json.dumps(data)) + + +VALID_BASE_CONFIG = { + "strategy_name": "test_strategy", + "dataset_snapshot_id": "snap_001", + "universe": { + "min_price": 5.0, + "min_avg_dollar_volume": 1_000_000, + "exclude_asset_types": [], + }, + "signal": { + "score_threshold": 0.5, + "max_candidates_per_day": 5, + "execution_timing": "next_open", + }, + "risk": { + "per_trade_risk_pct": 0.01, + "max_daily_new_risk_pct": 0.03, + "max_positions": 10, + "max_positions_per_sector": 3, + }, + "execution": { + "entry_fill_model": "next_open", + "exit_fill_model": "daily_bar_approximation", + "slippage_bps_base": 10.0, + "same_bar_priority": "stop_first_conservative", + }, + "reporting": { + "write_trade_blotter": True, + "write_equity_curve": True, + "write_metrics_summary": True, + }, +} + + +class TestLoadBaseConfig: + def test_load_valid_config(self, tmp_path): + from libs.backtest.manifests import load_base_config + + cfg_file = tmp_path / "defaults.json" + _write_json(cfg_file, VALID_BASE_CONFIG) + loaded = load_base_config(cfg_file) + assert loaded["strategy_name"] == "test_strategy" + + def test_missing_file_raises(self, tmp_path): + from libs.backtest.manifests import load_base_config + + with pytest.raises(FileNotFoundError): + load_base_config(tmp_path / "nonexistent.json") + + +class TestDeepMerge: + def test_simple_override(self): + from libs.backtest.manifests import deep_merge + + base = {"a": 1, "b": 2} + overrides = {"b": 99} + merged = deep_merge(base, overrides) + assert merged["a"] == 1 + assert merged["b"] == 99 + + def test_nested_merge(self): + from libs.backtest.manifests import deep_merge + + base = {"risk": {"max_positions": 10, "per_trade_risk_pct": 0.01}} + overrides = {"risk": {"max_positions": 5}} + merged = deep_merge(base, overrides) + assert merged["risk"]["max_positions"] == 5 + assert merged["risk"]["per_trade_risk_pct"] == 0.01 + + def test_does_not_mutate_base(self): + from libs.backtest.manifests import deep_merge + + base = {"a": {"b": 1}} + overrides = {"a": {"c": 2}} + deep_merge(base, overrides) + assert "c" not in base["a"] + + def test_override_wins(self): + from libs.backtest.manifests import deep_merge + + merged = deep_merge({"x": 1}, {"x": 2}) + assert merged["x"] == 2 + + +class TestLoadManifest: + def test_valid_manifest(self, tmp_path): + from libs.backtest.manifests import load_manifest + + manifest_data = { + "experiment_name": "test_exp", + "dataset_snapshot_id": "snap_001", + "base_config": "configs/backtest/defaults.json", + "overrides": {}, + } + f = tmp_path / "manifest.json" + _write_json(f, manifest_data) + m = load_manifest(f) + assert m.experiment_name == "test_exp" + + def test_missing_file_raises(self, tmp_path): + from libs.backtest.manifests import load_manifest + + with pytest.raises(FileNotFoundError): + load_manifest(tmp_path / "nope.json") + + +class TestResolveConfig: + def test_basic_resolve(self, tmp_path): + from libs.backtest.manifests import load_manifest, resolve_config + + cfg_file = tmp_path / "defaults.json" + _write_json(cfg_file, VALID_BASE_CONFIG) + + manifest_data = { + "experiment_name": "test", + "dataset_snapshot_id": "snap_001", + "base_config": str(cfg_file), + "overrides": {}, + } + m_file = tmp_path / "manifest.json" + _write_json(m_file, manifest_data) + manifest = load_manifest(m_file) + config = resolve_config(manifest) + assert isinstance(config, BacktestConfig) + assert config.strategy_name == "test_strategy" + + def test_overrides_applied(self, tmp_path): + from libs.backtest.manifests import load_manifest, resolve_config + + cfg_file = tmp_path / "defaults.json" + _write_json(cfg_file, VALID_BASE_CONFIG) + + manifest_data = { + "experiment_name": "test", + "dataset_snapshot_id": "snap_001", + "base_config": str(cfg_file), + "overrides": {"risk": {"max_positions": 3}}, + } + m_file = tmp_path / "manifest.json" + _write_json(m_file, manifest_data) + manifest = load_manifest(m_file) + config = resolve_config(manifest) + assert config.risk.max_positions == 3 + assert config.risk.per_trade_risk_pct == 0.01 # from base + + def test_snapshot_id_override(self, tmp_path): + from libs.backtest.manifests import load_manifest, resolve_config + + cfg_file = tmp_path / "defaults.json" + _write_json(cfg_file, VALID_BASE_CONFIG) + + manifest_data = { + "experiment_name": "test", + "dataset_snapshot_id": "snap_001", + "base_config": str(cfg_file), + "overrides": {}, + } + m_file = tmp_path / "manifest.json" + _write_json(m_file, manifest_data) + manifest = load_manifest(m_file) + config = resolve_config(manifest, snapshot_id_override="snap_override") + assert config.dataset_snapshot_id == "snap_override" + + +class TestGenerateRunId: + def test_format(self): + from libs.backtest.manifests import generate_run_id + + cfg = BacktestConfig(strategy_name="my_strategy", dataset_snapshot_id="snap_2026_01_01") + run_id = generate_run_id(cfg) + assert run_id.startswith("bt_") + parts = run_id.split("_") + assert len(parts) >= 4 + + def test_deterministic_for_same_config(self): + """Two calls with same config at same time should have same hash suffix.""" + from libs.backtest.manifests import generate_run_id + + cfg = BacktestConfig(strategy_name="test", dataset_snapshot_id="snap_001") + id1 = generate_run_id(cfg) + id2 = generate_run_id(cfg) + # Hash suffix should be identical + assert id1.split("_")[-1] == id2.split("_")[-1] diff --git a/tests/unit/backtest/test_metrics.py b/tests/unit/backtest/test_metrics.py new file mode 100644 index 0000000..cf40042 --- /dev/null +++ b/tests/unit/backtest/test_metrics.py @@ -0,0 +1,227 @@ +"""Unit tests for libs/backtest/metrics.py.""" +from __future__ import annotations + +import datetime as dt + +import pytest + +from libs.backtest.domain import DailyPortfolioState, ExitReason, FilledTrade + + +def _make_trade( + net_pnl: float, + exit_reason: ExitReason = ExitReason.TARGET, + r_multiple: float = 1.0, + holding_days: int = 5, + exit_date: dt.date = dt.date(2026, 1, 10), + symbol: str = "AAPL", +) -> FilledTrade: + entry_price = 100.0 + return FilledTrade( + trade_id=f"t_{symbol}_{exit_date}_{net_pnl}", + position_id="p1", + event_id="EVT::TEST", + symbol=symbol, + entry_date=dt.date(2026, 1, 5), + exit_date=exit_date, + entry_price=entry_price, + exit_price=entry_price + (net_pnl / max(1, 10)), + exit_reason=exit_reason, + shares=10, + commission=0.1, + slippage_bps=10.0, + gross_pnl=net_pnl + 0.1, + net_pnl=net_pnl, + pnl_pct=net_pnl / (entry_price * 10), + r_multiple=r_multiple, + holding_days=holding_days, + ) + + +def _make_equity_state(date: dt.date, equity: float, n_positions: int = 0) -> DailyPortfolioState: + return DailyPortfolioState( + date=date, + equity=equity, + cash_available=equity, + gross_exposure=0.0, + net_exposure=0.0, + reserved_risk_budget=0.0, + unrealized_pnl=0.0, + realized_pnl=0.0, + open_positions=[f"p{i}" for i in range(n_positions)], + daily_new_risk_used=0.0, + peak_equity=equity, + current_drawdown_pct=0.0, + ) + + +class TestWinRate: + def test_all_wins(self): + from libs.backtest.metrics import compute_win_rate + + trades = [_make_trade(100), _make_trade(200)] + assert compute_win_rate(trades) == 1.0 + + def test_mixed(self): + from libs.backtest.metrics import compute_win_rate + + trades = [_make_trade(100), _make_trade(-50)] + assert compute_win_rate(trades) == pytest.approx(0.5) + + def test_empty_returns_none(self): + from libs.backtest.metrics import compute_win_rate + + assert compute_win_rate([]) is None + + +class TestProfitFactor: + def test_basic(self): + from libs.backtest.metrics import compute_profit_factor + + trades = [_make_trade(200), _make_trade(100), _make_trade(-100)] + pf = compute_profit_factor(trades) + assert pf == pytest.approx(3.0) + + def test_no_losses_returns_none(self): + from libs.backtest.metrics import compute_profit_factor + + trades = [_make_trade(100), _make_trade(200)] + assert compute_profit_factor(trades) is None + + def test_empty_returns_none(self): + from libs.backtest.metrics import compute_profit_factor + + assert compute_profit_factor([]) is None + + +class TestExpectancyR: + def test_positive(self): + from libs.backtest.metrics import compute_expectancy_r + + trades = [_make_trade(100, r_multiple=2.0), _make_trade(-50, r_multiple=-1.0)] + er = compute_expectancy_r(trades) + assert er == pytest.approx(0.5) + + def test_empty_returns_none(self): + from libs.backtest.metrics import compute_expectancy_r + + assert compute_expectancy_r([]) is None + + +class TestTotalReturnPct: + def test_basic(self): + from libs.backtest.metrics import compute_total_return_pct + + curve = [ + _make_equity_state(dt.date(2026, 1, 5), 100_000), + _make_equity_state(dt.date(2026, 1, 6), 110_000), + ] + assert compute_total_return_pct(curve) == pytest.approx(10.0) + + def test_single_point_returns_none(self): + from libs.backtest.metrics import compute_total_return_pct + + assert compute_total_return_pct([_make_equity_state(dt.date(2026, 1, 5), 100_000)]) is None + + +class TestMaxDrawdown: + def test_basic_drawdown(self): + from libs.backtest.metrics import compute_max_drawdown_pct + + curve = [ + _make_equity_state(dt.date(2026, 1, 5), 100_000), + _make_equity_state(dt.date(2026, 1, 6), 120_000), # peak + _make_equity_state(dt.date(2026, 1, 7), 90_000), # drawdown from 120k + _make_equity_state(dt.date(2026, 1, 8), 100_000), + ] + dd = compute_max_drawdown_pct(curve) + # Max drawdown = (120k - 90k) / 120k = 25% + assert dd == pytest.approx(25.0) + + def test_no_drawdown(self): + from libs.backtest.metrics import compute_max_drawdown_pct + + curve = [ + _make_equity_state(dt.date(2026, 1, 5), 100_000), + _make_equity_state(dt.date(2026, 1, 6), 110_000), + ] + assert compute_max_drawdown_pct(curve) == pytest.approx(0.0) + + +class TestSharpeRatio: + def test_positive_sharpe(self): + from libs.backtest.metrics import compute_sharpe_ratio + + # Steady returns → positive Sharpe + curve = [ + _make_equity_state(dt.date(2026, 1, 2) + dt.timedelta(days=i), 100_000 + i * 100) + for i in range(50) + ] + sharpe = compute_sharpe_ratio(curve) + assert sharpe is not None + assert sharpe > 0 + + def test_not_enough_data_returns_none(self): + from libs.backtest.metrics import compute_sharpe_ratio + + curve = [_make_equity_state(dt.date(2026, 1, 5), 100_000)] + assert compute_sharpe_ratio(curve) is None + + +class TestStopExitRate: + def test_basic(self): + from libs.backtest.metrics import compute_stop_exit_rate + + trades = [ + _make_trade(100, ExitReason.TARGET), + _make_trade(-50, ExitReason.STOP), + _make_trade(-30, ExitReason.STOP), + ] + assert compute_stop_exit_rate(trades) == pytest.approx(2 / 3) + + def test_trailing_counts_as_stop(self): + from libs.backtest.metrics import compute_stop_exit_rate + + trades = [ + _make_trade(50, ExitReason.TRAILING), + ] + assert compute_stop_exit_rate(trades) == pytest.approx(1.0) + + +class TestMonthlyWinRate: + def test_basic(self): + from libs.backtest.metrics import compute_monthly_win_rate + + trades = [ + _make_trade(100, exit_date=dt.date(2026, 1, 15)), + _make_trade(200, exit_date=dt.date(2026, 1, 20)), + _make_trade(-50, exit_date=dt.date(2026, 2, 10)), + ] + # Jan: +300 = win, Feb: -50 = loss → 1/2 = 0.5 + mwr = compute_monthly_win_rate(trades) + assert mwr == pytest.approx(0.5) + + +class TestBuildMetricsBundle: + def test_builds_with_trades_and_curve(self): + from libs.backtest.metrics import build_metrics_bundle + + trades = [ + _make_trade(100, ExitReason.TARGET, r_multiple=2.0), + _make_trade(-50, ExitReason.STOP, r_multiple=-1.0), + ] + curve = [ + _make_equity_state(dt.date(2026, 1, 5), 100_000), + _make_equity_state(dt.date(2026, 1, 10), 105_000), + ] + m = build_metrics_bundle(trades, curve) + assert m.trade_count == 2 + assert m.win_rate == pytest.approx(0.5) + assert m.total_return_pct == pytest.approx(5.0) + + def test_empty_trades(self): + from libs.backtest.metrics import build_metrics_bundle + + m = build_metrics_bundle([], []) + assert m.trade_count == 0 + assert m.win_rate is None diff --git a/tests/unit/backtest/test_selector.py b/tests/unit/backtest/test_selector.py new file mode 100644 index 0000000..3afc199 --- /dev/null +++ b/tests/unit/backtest/test_selector.py @@ -0,0 +1,216 @@ +"""Unit tests for libs/backtest/selector.py.""" +from __future__ import annotations + +import datetime as dt +from zoneinfo import ZoneInfo + +import pytest + +from libs.backtest.domain import SignalConfig, UniverseConfig + +_UTC = ZoneInfo("UTC") + + +def _make_raw_row(**kwargs) -> dict: + defaults = { + "event_id": "EVT::TEST::001", + "symbol": "AAPL", + "issuer_id": "ISSUER::0000320193", + "score": 0.75, + "sector": "Technology", + "event_type": "earnings", + "event_timestamp": "2026-01-05T21:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-06", + "entry_date": "2026-01-07", + "entry_price": 150.0, + "avg_dollar_volume": 5_000_000.0, + "atr_14": 3.5, + } + defaults.update(kwargs) + return defaults + + +class TestBuildCandidate: + def test_basic(self): + from libs.backtest.selector import build_candidate + + row = _make_raw_row() + c = build_candidate(row) + assert c is not None + assert c.symbol == "AAPL" + assert c.score == 0.75 + assert c.execution_date == dt.date(2026, 1, 7) + assert c.event_timestamp.tzinfo is not None + + def test_null_timestamp_returns_none(self): + from libs.backtest.selector import build_candidate + + row = _make_raw_row(event_timestamp=None) + assert build_candidate(row) is None + + def test_zero_entry_price_returns_none(self): + from libs.backtest.selector import build_candidate + + row = _make_raw_row(entry_price=0.0) + assert build_candidate(row) is None + + def test_missing_entry_price_returns_none(self): + from libs.backtest.selector import build_candidate + + row = _make_raw_row() + del row["entry_price"] + assert build_candidate(row) is None + + def test_null_exec_date_returns_none(self): + from libs.backtest.selector import build_candidate + + row = _make_raw_row() + del row["entry_date"] + assert build_candidate(row) is None + + def test_sector_defaults_to_unknown(self): + from libs.backtest.selector import build_candidate + + row = _make_raw_row(sector=None) + c = build_candidate(row) + assert c is not None + assert c.sector == "UNKNOWN" + + def test_score_bucket_classification(self): + from libs.backtest.selector import build_candidate + + c = build_candidate(_make_raw_row(score=0.85)) + assert c.score_bucket == "high" + + c = build_candidate(_make_raw_row(score=0.65)) + assert c.score_bucket == "medium_high" + + c = build_candidate(_make_raw_row(score=0.45)) + assert c.score_bucket == "medium" + + c = build_candidate(_make_raw_row(score=0.25)) + assert c.score_bucket == "medium_low" + + c = build_candidate(_make_raw_row(score=0.10)) + assert c.score_bucket == "low" + + +class TestRankCandidates: + def test_sorted_by_score_desc(self): + from libs.backtest.selector import build_candidate, rank_candidates + + rows = [ + _make_raw_row(symbol="A", score=0.5, avg_dollar_volume=1e6), + _make_raw_row(symbol="B", score=0.8, avg_dollar_volume=1e6), + _make_raw_row(symbol="C", score=0.6, avg_dollar_volume=1e6), + ] + candidates = [build_candidate(r) for r in rows] + ranked = rank_candidates([c for c in candidates if c]) + assert ranked[0].symbol == "B" + assert ranked[1].symbol == "C" + assert ranked[2].symbol == "A" + + def test_tiebreak_by_avg_dollar_volume(self): + from libs.backtest.selector import build_candidate, rank_candidates + + rows = [ + _make_raw_row(symbol="A", score=0.7, avg_dollar_volume=1e6), + _make_raw_row(symbol="B", score=0.7, avg_dollar_volume=5e6), + ] + candidates = [build_candidate(r) for r in rows] + ranked = rank_candidates([c for c in candidates if c]) + assert ranked[0].symbol == "B" # higher avg_dollar_volume + + def test_tiebreak_by_symbol_asc(self): + from libs.backtest.selector import build_candidate, rank_candidates + + rows = [ + _make_raw_row(symbol="Z", score=0.7, avg_dollar_volume=1e6), + _make_raw_row(symbol="A", score=0.7, avg_dollar_volume=1e6), + ] + candidates = [build_candidate(r) for r in rows] + ranked = rank_candidates([c for c in candidates if c]) + assert ranked[0].symbol == "A" + + def test_deterministic(self): + from libs.backtest.selector import build_candidate, rank_candidates + + rows = [ + _make_raw_row(symbol="C", score=0.9), + _make_raw_row(symbol="A", score=0.7), + _make_raw_row(symbol="B", score=0.8), + ] + candidates = [build_candidate(r) for r in rows] + r1 = rank_candidates([c for c in candidates if c]) + r2 = rank_candidates([c for c in candidates if c]) + assert [c.symbol for c in r1] == [c.symbol for c in r2] + + +class TestFilterCandidates: + def test_score_threshold(self): + from libs.backtest.selector import build_candidate, filter_by_score + + rows = [ + _make_raw_row(symbol="A", score=0.3), + _make_raw_row(symbol="B", score=0.7), + _make_raw_row(symbol="C", score=0.5), + ] + candidates = [build_candidate(r) for r in rows if build_candidate(r)] + filtered = filter_by_score(candidates, score_threshold=0.5) + assert len(filtered) == 2 + assert all(c.score >= 0.5 for c in filtered) + + def test_min_price_filter(self): + from libs.backtest.selector import build_candidate, filter_by_universe + + u = UniverseConfig(min_price=100.0, min_avg_dollar_volume=0) + rows = [ + _make_raw_row(symbol="CHEAP", entry_price=50.0), + _make_raw_row(symbol="OK", entry_price=150.0), + ] + candidates = [build_candidate(r) for r in rows if build_candidate(r)] + filtered = filter_by_universe(candidates, u) + assert len(filtered) == 1 + assert filtered[0].symbol == "OK" + + def test_min_adv_filter(self): + from libs.backtest.selector import build_candidate, filter_by_universe + + u = UniverseConfig(min_price=0, min_avg_dollar_volume=2_000_000) + rows = [ + _make_raw_row(symbol="ILLIQUID", avg_dollar_volume=500_000), + _make_raw_row(symbol="LIQUID", avg_dollar_volume=5_000_000), + ] + candidates = [build_candidate(r) for r in rows if build_candidate(r)] + filtered = filter_by_universe(candidates, u) + assert len(filtered) == 1 + assert filtered[0].symbol == "LIQUID" + + def test_truncate(self): + from libs.backtest.selector import build_candidate, rank_candidates, truncate_candidates + + rows = [_make_raw_row(symbol=s, score=0.9 - i * 0.1) for i, s in enumerate("ABCDE")] + candidates = rank_candidates([build_candidate(r) for r in rows if build_candidate(r)]) + truncated = truncate_candidates(candidates, max_per_day=3) + assert len(truncated) == 3 + + +class TestSelectCandidates: + def test_full_pipeline(self): + from libs.backtest.selector import select_candidates + + rows = [ + _make_raw_row(symbol="A", score=0.9, avg_dollar_volume=5e6, entry_price=100.0), + _make_raw_row(symbol="B", score=0.3, avg_dollar_volume=5e6, entry_price=100.0), # below threshold + _make_raw_row(symbol="C", score=0.8, avg_dollar_volume=1e4, entry_price=100.0), # low ADV + _make_raw_row(symbol="D", score=0.7, avg_dollar_volume=5e6, entry_price=2.0), # below min_price + ] + u = UniverseConfig(min_price=5.0, min_avg_dollar_volume=1_000_000) + s = SignalConfig(score_threshold=0.5, max_candidates_per_day=10) + result = select_candidates(rows, u, s) + symbols = [c.symbol for c in result] + assert "A" in symbols + assert "B" not in symbols # below threshold + assert "C" not in symbols # low ADV + assert "D" not in symbols # below min_price diff --git a/tests/unit/backtest/test_snapshot_store.py b/tests/unit/backtest/test_snapshot_store.py new file mode 100644 index 0000000..59b5858 --- /dev/null +++ b/tests/unit/backtest/test_snapshot_store.py @@ -0,0 +1,155 @@ +"""Unit tests for libs/backtest/snapshot_store.py.""" +from __future__ import annotations + +import datetime as dt +import json +from pathlib import Path + +import pyarrow as pa +import pyarrow.parquet as pq +import pytest + + +def _build_store_from_fixture(tmp_path: Path) -> object: + """Build a SnapshotStore directly from test data (no DB/HTTP).""" + from libs.backtest.snapshot_store import SnapshotStore + + candidates = { + dt.date(2026, 1, 6): [ + { + "event_id": "EVT::TEST::001", + "symbol": "AAPL", + "execution_date": dt.date(2026, 1, 6), + "entry_date": "2026-01-06", + "entry_price": 150.0, + "score": 0.8, + "sector": "Technology", + "event_type": "earnings", + "event_timestamp": "2026-01-05T21:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-05", + "avg_dollar_volume": 5_000_000.0, + "atr_14": 3.0, + } + ], + dt.date(2026, 1, 7): [ + { + "event_id": "EVT::TEST::002", + "symbol": "MSFT", + "execution_date": dt.date(2026, 1, 7), + "entry_date": "2026-01-07", + "entry_price": 300.0, + "score": 0.6, + "sector": "Technology", + "event_type": "guidance", + "event_timestamp": "2026-01-06T20:00:00+00:00", + "filing_time_bucket": "post_market", + "reaction_date": "2026-01-06", + "avg_dollar_volume": 10_000_000.0, + "atr_14": 5.0, + } + ], + } + + bars = { + "AAPL": { + dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 150.0, "high": 155.0, "low": 148.0, "close": 152.0, "volume": 1_000_000}, + dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 152.0, "high": 162.0, "low": 150.0, "close": 159.0, "volume": 900_000}, + }, + "MSFT": { + dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 300.0, "high": 310.0, "low": 295.0, "close": 305.0, "volume": 500_000}, + }, + } + + return SnapshotStore( + candidates_by_exec_date=candidates, + bars_by_symbol_date=bars, + ) + + +class TestSnapshotStoreQuery: + def test_get_candidates_for_date(self, tmp_path): + store = _build_store_from_fixture(tmp_path) + rows = store.get_candidates_for_date(dt.date(2026, 1, 6)) + assert len(rows) == 1 + assert rows[0]["symbol"] == "AAPL" + + def test_get_candidates_empty_date(self, tmp_path): + store = _build_store_from_fixture(tmp_path) + rows = store.get_candidates_for_date(dt.date(2026, 1, 1)) + assert rows == [] + + def test_no_lookahead(self, tmp_path): + """Candidates for Jan 7 should NOT appear when querying Jan 6.""" + store = _build_store_from_fixture(tmp_path) + rows = store.get_candidates_for_date(dt.date(2026, 1, 6)) + symbols = [r["symbol"] for r in rows] + assert "MSFT" not in symbols + + def test_get_bar_exists(self, tmp_path): + store = _build_store_from_fixture(tmp_path) + bar = store.get_bar("AAPL", dt.date(2026, 1, 6)) + assert bar is not None + assert bar["open"] == 150.0 + assert bar["close"] == 152.0 + + def test_get_bar_missing_returns_none(self, tmp_path): + store = _build_store_from_fixture(tmp_path) + bar = store.get_bar("AAPL", dt.date(2025, 12, 31)) + assert bar is None + + def test_get_bar_unknown_symbol_returns_none(self, tmp_path): + store = _build_store_from_fixture(tmp_path) + assert store.get_bar("UNKNOWN", dt.date(2026, 1, 6)) is None + + def test_all_execution_dates_sorted(self, tmp_path): + store = _build_store_from_fixture(tmp_path) + dates = store.all_execution_dates() + assert dates == sorted(dates) + assert dt.date(2026, 1, 6) in dates + assert dt.date(2026, 1, 7) in dates + + def test_macro_default_empty(self, tmp_path): + store = _build_store_from_fixture(tmp_path) + macro = store.get_macro_for_date(dt.date(2026, 1, 6)) + assert macro == {} + + def test_candidates_copy_returned(self, tmp_path): + """Modifying returned list should not affect internal state.""" + store = _build_store_from_fixture(tmp_path) + rows1 = store.get_candidates_for_date(dt.date(2026, 1, 6)) + rows1.append({"extra": "data"}) + rows2 = store.get_candidates_for_date(dt.date(2026, 1, 6)) + assert len(rows2) == 1 # unchanged + + +class TestSnapshotStoreLoadGuard: + def test_raises_in_running_event_loop(self, tmp_path): + """load() should raise RuntimeError if called from a running event loop.""" + import asyncio + + from libs.backtest.snapshot_store import SnapshotStore + + async def _test(): + with pytest.raises(RuntimeError, match="running event loop"): + SnapshotStore.load(tmp_path, "train", "http://localhost", "postgres://") + + asyncio.run(_test()) + + +class TestSnapshotStoreFromParquet: + def test_compute_date_range(self, tmp_path): + from libs.backtest.snapshot_store import SnapshotStore + + rows = [ + {"entry_date": "2026-01-05"}, + {"entry_date": "2026-01-10"}, + {"entry_date": "2026-01-07"}, + ] + result = SnapshotStore._compute_date_range(rows) + assert result == (dt.date(2026, 1, 5), dt.date(2026, 1, 10)) + + def test_compute_date_range_empty(self, tmp_path): + from libs.backtest.snapshot_store import SnapshotStore + + assert SnapshotStore._compute_date_range([]) is None diff --git a/tests/unit/backtest/test_splits.py b/tests/unit/backtest/test_splits.py new file mode 100644 index 0000000..d40997b --- /dev/null +++ b/tests/unit/backtest/test_splits.py @@ -0,0 +1,89 @@ +"""Unit tests for libs/backtest/splits.py.""" +from __future__ import annotations + +import datetime as dt + +import pytest + +from libs.backtest.calendar import get_trading_days + + +class TestGenerateWalkForwardWindows: + def test_basic(self): + from libs.backtest.splits import generate_walk_forward_windows + + dates = [dt.date(2026, 1, 1) + dt.timedelta(days=i) for i in range(400)] + windows = generate_walk_forward_windows(dates, train_days=252, test_days=63, step_days=63) + assert len(windows) > 0 + for w in windows: + assert w.train_start < w.train_end + assert w.test_start <= w.test_end + assert w.train_end < w.test_start + + def test_no_windows_when_not_enough_data(self): + from libs.backtest.splits import generate_walk_forward_windows + + dates = [dt.date(2026, 1, 1) + dt.timedelta(days=i) for i in range(10)] + windows = generate_walk_forward_windows(dates, train_days=252, test_days=63) + assert windows == [] + + def test_window_indices(self): + from libs.backtest.splits import generate_walk_forward_windows + + dates = [dt.date(2026, 1, 1) + dt.timedelta(days=i) for i in range(400)] + windows = generate_walk_forward_windows(dates, train_days=100, test_days=50, step_days=50) + for i, w in enumerate(windows): + assert w.window_index == i + + def test_repr(self): + from libs.backtest.splits import WalkForwardWindow + + w = WalkForwardWindow( + 0, + dt.date(2026, 1, 1), dt.date(2026, 6, 1), + dt.date(2026, 6, 2), dt.date(2026, 9, 1), + ) + assert "WalkForwardWindow" in repr(w) + + +class TestSplitByYear: + def test_groups_by_year(self): + from libs.backtest.splits import split_by_year + + dates = ( + [dt.date(2024, 12, i) for i in range(1, 10)] + + [dt.date(2025, 1, i) for i in range(1, 10)] + ) + by_year = split_by_year(dates) + assert 2024 in by_year + assert 2025 in by_year + assert all(d.year == 2024 for d in by_year[2024]) + assert all(d.year == 2025 for d in by_year[2025]) + + def test_empty(self): + from libs.backtest.splits import split_by_year + + assert split_by_year([]) == {} + + +class TestSplitByRegime: + def test_basic_grouping(self): + from libs.backtest.splits import split_by_regime + + dates = [dt.date(2026, 1, i) for i in range(1, 11)] + regime_map = { + dt.date(2026, 1, 1): "bull", + dt.date(2026, 1, 2): "bull", + dt.date(2026, 1, 3): "bear", + } + grouped = split_by_regime(dates, regime_map) + assert "bull" in grouped + assert "bear" in grouped + assert "unknown" in grouped # dates without regime + + def test_custom_default(self): + from libs.backtest.splits import split_by_regime + + dates = [dt.date(2026, 1, 5)] + grouped = split_by_regime(dates, {}, default_regime="neutral") + assert "neutral" in grouped