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
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,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
|
||||
@ -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…
Reference in New Issue