feat: implement Phase 4 -- event-driven backtester

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 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent f69a8b4731
commit 2f4d9f61f7

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

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

@ -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"]
}
}

@ -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."
}

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

@ -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))

@ -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",
]

@ -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

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

@ -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

@ -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 {}),
)

@ -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

@ -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 26: 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)

@ -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

@ -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}
}
}

@ -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"
}

@ -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"
}

Binary file not shown.

@ -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

@ -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]

@ -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

@ -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)

@ -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 == []

@ -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)

@ -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]

@ -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

@ -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

@ -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

@ -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
Loading…
Cancel
Save