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

2570 lines
111 KiB
Python

This file contains ambiguous Unicode characters!

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

"""BacktestRunner: main simulation class and CLI entry point."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
import statistics
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any
import requests
from libs.backtest.allocator import (
_cap_shares_by_position_limits,
_cap_shares_to_remaining_risk_budget,
_remaining_risk_budget_dollars,
_resolve_effective_per_trade_risk_pct,
_resolve_stop_risk_config,
_resolve_sizing_equity,
build_planned_order,
compute_shares,
compute_stop_price,
)
from libs.backtest.artifacts import create_run_directory, write_all_artifacts
from libs.backtest.domain import (
BacktestConfig,
Candidate,
DailyPortfolioState,
ExecutionConfig,
ExitReason,
ExperimentManifest,
ExperimentResult,
FilledTrade,
MetricsBundle,
OpenPosition,
PlannedOrder,
PositionStatus,
RobustnessHorizonSummary,
RobustnessMatrixSummary,
SplitResult,
WalkForwardAggregate,
WalkForwardFoldResult,
WalkForwardGapStats,
WalkForwardSummary,
)
from libs.backtest.execution import (
simulate_scheduled_open_exit,
simulate_entry,
simulate_exit,
simulate_kill_switch_exit,
simulate_recycle_close_exit,
simulate_rotation_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 rank_candidates, select_candidates
from libs.backtest.snapshot_store import SnapshotStore
from libs.backtest.splits import generate_robustness_windows, generate_walk_forward_windows
from libs.common.logging import get_logger
from libs.common.time_utils import utc_now
from libs.oracle_client.models import EventAttentionResponse
logger = get_logger(__name__)
_KILL_SWITCH_DRAWDOWN_PCT = 25.0
def _get_git_commit_hash() -> str:
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True, text=True, timeout=5,
)
return result.stdout.strip() or "unknown"
except Exception:
return "unknown"
class BacktestRunner:
"""Event-driven backtester simulation engine."""
def __init__(
self,
manifest: ExperimentManifest,
config: BacktestConfig,
store: SnapshotStore,
initial_equity: float = 100_000.0,
split_name: str | None = None,
enable_engine_analysis: bool = True,
) -> None:
self.split_name = split_name
self.manifest = manifest
self.config = config
self.store = store
self.initial_equity = initial_equity
self.enable_engine_analysis = enable_engine_analysis
self._active_strategy_engines = self.config.get_active_strategy_engines()
self._shadow_strategy_engines = self.config.get_shadow_strategy_engines()
from libs.backtest.attention import AttentionFilterService
from libs.common.config import get_settings
settings = get_settings()
self._attention_service = AttentionFilterService(
oracle_url=settings.stock_oracle_url,
scoring_model=config.signal.scoring_model,
)
# Legacy attributes for backward compat with remaining inline methods
self._attention_cache = self._attention_service._cache
self._attention_base_url = self._attention_service._base_url or None
self._attention_session = self._attention_service._session
# 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
self._fixed_capital_sizing = config.risk.fixed_capital_sizing
# Cash parking (idle cash → SPY/QQQ/SGOV)
self._parking_shares: int = 0
self._parking_avg_price: float = 0.0
self._parking_current_symbol: str = "" # tracks which symbol is currently parked
# Stats
self._total_candidates_seen = 0
self._total_orders_rejected = 0
self._peak_equity = initial_equity
self._realized_pnl = 0.0
self._daily_new_risk_used = 0.0
self._consecutive_losses = 0
self._cooldown_remaining = 0
self._kill_switch_triggered = False
self._kill_switch_cooldown_remaining = 0
self._engine_daily_new_risk_used: dict[str, float] = defaultdict(float)
self._scheduled_add_ons: dict[dt.date, list[Candidate]] = defaultdict(list)
self._scheduled_delayed_entries: dict[dt.date, list[Candidate]] = defaultdict(list)
self._recent_scored_candidates: dict[dt.date, list[Candidate]] = {}
self._pending_open_exits: dict[dt.date, list[dict[str, Any]]] = defaultdict(list)
self._parent_add_on_counts: dict[str, int] = defaultdict(int)
self._simulation_dates: list[dt.date] = []
self._next_trading_day: dict[dt.date, dt.date] = {}
@property
def _sizing_equity(self) -> float:
"""Equity used for position sizing. Returns initial_capital when fixed_capital_sizing is enabled."""
if self._fixed_capital_sizing:
return self.initial_equity
return self._equity
def _compute_portfolio_exposure(self, date: dt.date) -> tuple[float, float]:
"""Return (gross, net) exposure using current close notional when available."""
gross = 0.0
net = 0.0
for pos in self._open_positions:
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
close = (
float(bar["close"])
if bar and bar.get("close") is not None and float(bar["close"]) > 0
else pos.entry_price
)
notional = close * pos.shares_open
gross += abs(notional)
net += -notional if pos.plan.candidate.trade_direction == "short" else notional
return gross, net
def _compute_buying_power(self, equity: float, gross_exposure: float) -> float:
multiplier = self.config.risk.buying_power_multiplier or 1.0
max_gross = max(0.0, equity * multiplier)
return max(0.0, max_gross - gross_exposure)
def run(self, output_root: str | Path | None = None) -> ExperimentResult:
"""Execute the full simulation. Returns ExperimentResult."""
started_at = utc_now()
run_id = generate_run_id(self.config)
logger.info("backtest_start", run_id=run_id, strategy=self.config.strategy_name)
exec_dates = self.store.all_execution_dates()
if not exec_dates:
logger.warning("backtest_no_dates", run_id=run_id)
# Iterate ALL trading days (not just candidate days) so stop/target/time
# exits are checked every day, not just on days with new candidates.
all_dates = self._get_simulation_dates()
self._simulation_dates = list(all_dates)
self._next_trading_day = {
all_dates[idx]: all_dates[idx + 1]
for idx in range(len(all_dates) - 1)
}
# Record initial equity state (before any trades)
if all_dates:
self._equity_curve.append(
DailyPortfolioState(
date=all_dates[0],
equity=self.initial_equity,
sizing_equity=self.initial_equity,
cash_available=self._compute_buying_power(self.initial_equity, 0.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=self.initial_equity,
current_drawdown_pct=0.0,
)
)
for date in all_dates:
self._simulate_day(date)
# Force-close any remaining open positions at end of backtest
if self._open_positions:
last_date = all_dates[-1] if all_dates else dt.date.today()
self._force_close_all(last_date, reason="end_of_backtest")
finished_at = utc_now()
metrics = build_metrics_bundle(
self._closed_trades, self._equity_curve, self._candidate_map
)
metrics = metrics.model_copy(update=self._build_benchmark_and_contribution_metrics(metrics))
per_engine_metrics = (
self._build_per_engine_metrics()
if self.enable_engine_analysis and self.config.get_strategy_engines()
else {}
)
# Create run directory and write artifacts
run_dir = None
artifact_paths: dict[str, str] = {}
if output_root is not None:
run_dir = create_run_directory(output_root, run_id)
git_hash = _get_git_commit_hash()
artifact_paths = write_all_artifacts(
run_dir=run_dir,
run_id=run_id,
manifest=self.manifest,
config=self.config,
metrics=metrics,
trades=self._closed_trades,
equity_curve=self._equity_curve,
open_positions=self._open_positions,
candidate_map=self._candidate_map,
started_at=started_at,
finished_at=finished_at,
git_hash=git_hash,
total_trading_days=len(self._equity_curve),
total_candidates_seen=self._total_candidates_seen,
total_orders_rejected=self._total_orders_rejected,
split_name=self.split_name,
per_engine_metrics=per_engine_metrics,
)
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
self._engine_daily_new_risk_used = defaultdict(float)
# Decrement cooldowns
if self._cooldown_remaining > 0:
self._cooldown_remaining -= 1
if self._kill_switch_cooldown_remaining > 0:
self._kill_switch_cooldown_remaining -= 1
# --- CASH PARKING: sell parked position to free cash for today's trades ---
macro_data_early = self.store.get_macro_for_date(date)
if self._parking_shares > 0 and macro_data_early:
if self._parking_current_symbol == "sgov":
# SGOV: accrue daily interest
daily_rate = self.config.risk.cash_parking_sgov_annual_rate / 252
interest = self._parking_shares * self._parking_avg_price * daily_rate
self._cash += self._parking_shares * self._parking_avg_price + interest
self._realized_pnl += interest
else:
park_close = macro_data_early.get(f"{self._parking_current_symbol}_close")
if park_close and park_close > 0:
proceeds = self._parking_shares * park_close
parking_pnl = proceeds - (self._parking_shares * self._parking_avg_price)
self._cash += proceeds
self._realized_pnl += parking_pnl
self._parking_shares = 0
self._parking_avg_price = 0.0
self._parking_current_symbol = ""
# Increment days_held for all open positions
for pos in self._open_positions:
pos.days_held += 1
# --- OPENING EXITS (scheduled on prior close) ---
if self._pending_open_exits.get(date):
self._process_pending_open_exits(date)
# --- SEASONAL RESET: close stale/underwater positions before peak event season ---
if (
self.config.risk.seasonal_reset_enabled
and self._open_positions
and date.month == self.config.risk.seasonal_reset_month
and date.day >= self.config.risk.seasonal_reset_day
):
to_close = []
for pos in self._open_positions:
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
if bar is None or bar.get("close") is None:
continue
close_price = float(bar["close"])
# Only close underwater or stale positions (held > 10 days with low R)
stop_dist = abs(pos.entry_price - pos.plan.stop_price)
unrealized_r = (close_price - pos.entry_price) / stop_dist if stop_dist > 0 else 0.0
if unrealized_r < 0.3 and pos.days_held >= 5:
to_close.append(pos)
for pos in to_close:
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
trade = simulate_rotation_exit(pos, bar, date, self._build_effective_execution_config(pos.plan.candidate))
if trade:
self._closed_trades.append(trade)
self._candidate_map[trade.trade_id] = pos.plan.candidate
self._realized_pnl += trade.net_pnl
self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
self._open_positions = [p for p in self._open_positions if p.position_id != pos.position_id]
logger.info("seasonal_reset", date=str(date), symbol=pos.plan.candidate.symbol, unrealized_r=round(unrealized_r, 2))
# --- EXITS FIRST (using today's OHLCV) ---
# Build position → candidate lookup for attribution mapping
pos_to_candidate = {pos.position_id: pos.plan.candidate for pos in self._open_positions}
newly_closed: list[FilledTrade] = []
still_open: list[OpenPosition] = []
for pos in self._open_positions:
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
# Kill switch: force close
if self._kill_switch_triggered:
trade = simulate_kill_switch_exit(pos, bar, date, self.config.execution)
newly_closed.append(trade)
continue
if bar is None:
# Missing bar — hold position (do not impute zero)
still_open.append(pos)
continue
# Update trailing stop if configured
if self.config.execution.trailing_model:
update_trailing_stop(
pos, bar,
self.config.execution.trailing_model,
warmup_days=self.config.execution.trailing_warmup_days,
)
effective_exec = self._build_effective_execution_config(pos.plan.candidate)
prev_status = pos.status
trade = simulate_exit(pos, bar, effective_exec, date)
if trade is not None:
newly_closed.append(trade)
# Partial exit: status just changed from ENTERED to PARTIALLY_EXITED
# Keep position open for remaining shares
if prev_status == PositionStatus.ENTERED and pos.status == PositionStatus.PARTIALLY_EXITED:
still_open.append(pos)
else:
pending_exit = self._evaluate_pending_open_exit(pos, bar, effective_exec, date)
if pending_exit is not None:
self._queue_pending_open_exit(date, pending_exit)
still_open.append(pos)
# Process closed trades
for trade in newly_closed:
self._closed_trades.append(trade)
# Map trade to candidate for attribution
cand = pos_to_candidate.get(trade.position_id)
if cand:
self._candidate_map[trade.trade_id] = cand
self._realized_pnl += trade.net_pnl
self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
# Track consecutive losses for cooldown
if trade.net_pnl < 0:
self._consecutive_losses += 1
else:
self._consecutive_losses = 0
if (
self.config.risk.cooldown_after_loss_streak > 0
and self._consecutive_losses >= self.config.risk.cooldown_after_loss_streak
):
self._cooldown_remaining = self.config.risk.cooldown_days
self._consecutive_losses = 0
self._open_positions = still_open
# --- Compute current equity for kill-switch check ---
market_value = self._compute_positions_market_value(date)
unrealized = market_value - sum(
p.entry_price * p.shares_open for p in self._open_positions
)
self._equity = self._cash + market_value
self._peak_equity = max(self._peak_equity, self._equity)
drawdown_pct = (
(self._peak_equity - self._equity) / self._peak_equity * 100.0
if self._peak_equity > 0
else 0.0
)
if drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT and not self._kill_switch_triggered:
logger.warning("kill_switch_triggered", date=str(date), drawdown_pct=drawdown_pct)
if self.config.risk.kill_switch_log_only:
logger.info("kill_switch_log_only_mode", date=str(date))
# Don't trigger — just observe
else:
self._kill_switch_triggered = True
if self.config.risk.backtest_mode == "research":
self._kill_switch_cooldown_remaining = self.config.risk.kill_switch_cooldown_days
# Research mode: reset kill switch after cooldown expires
# Reset peak_equity to current equity so drawdown restarts from 0
if (
self._kill_switch_triggered
and self.config.risk.backtest_mode == "research"
and self._kill_switch_cooldown_remaining <= 0
):
self._kill_switch_triggered = False
self._peak_equity = self._equity
drawdown_pct = 0.0
logger.info("kill_switch_reset", date=str(date))
# --- ENTRIES (only if kill switch not triggered) ---
if not self._kill_switch_triggered:
portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized)
candidates = self._select_candidates_for_date(date)
shadow_candidates = self._select_shadow_candidates_for_date(date)
# Store scored candidates for delayed entry lookback
recent_candidates = list(candidates)
if shadow_candidates:
recent_candidates.extend(shadow_candidates)
if recent_candidates:
self._recent_scored_candidates[date] = recent_candidates
# Prune old entries (keep last 10 trading days)
cutoff = max(0, len(self._simulation_dates) - 15)
if cutoff > 0:
idx = self._simulation_dates.index(date) if date in self._simulation_dates else -1
if idx >= 15:
old_date = self._simulation_dates[idx - 15]
self._recent_scored_candidates.pop(old_date, None)
# Inject delayed entry candidates
delayed = self._scheduled_delayed_entries.pop(date, [])
if delayed:
candidates = list(candidates) + delayed
self._total_candidates_seen += len(candidates)
macro_data = self.store.get_macro_for_date(date)
candidates = self._reorder_candidates_for_funding(candidates, portfolio_state, macro_data)
# --- ROTATION: proactively close stale positions if good candidates exist ---
n_rotated = self._attempt_rotation_exits(date, candidates)
if n_rotated > 0:
mv = self._compute_positions_market_value(date)
self._equity = self._cash + mv
unrealized = mv - sum(p.entry_price * p.shares_open for p in self._open_positions)
portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized)
for candidate in candidates:
plan = build_planned_order(
candidate=candidate,
portfolio_state=portfolio_state,
open_positions=self._open_positions,
config=self.config,
execution_config=self._build_effective_execution_config(candidate),
cooldown_remaining=self._cooldown_remaining,
macro_data=macro_data,
engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id],
)
if plan.skip_reason is not None:
if plan.skip_reason == "insufficient_cash" and self._attempt_same_day_cash_recycle(
date=date,
candidate=candidate,
portfolio_state=portfolio_state,
):
mv = self._compute_positions_market_value(date)
self._equity = self._cash + mv
ur = mv - sum(
p.entry_price * p.shares_open
for p in self._open_positions
)
portfolio_state = self._build_portfolio_state(date, drawdown_pct, ur)
plan = build_planned_order(
candidate=candidate,
portfolio_state=portfolio_state,
open_positions=self._open_positions,
config=self.config,
execution_config=self._build_effective_execution_config(candidate),
cooldown_remaining=self._cooldown_remaining,
macro_data=macro_data,
engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id],
)
if plan.skip_reason is not None:
self._total_orders_rejected += 1
self._release_add_on_reservation(candidate)
logger.debug(
"order_rejected",
engine_id=candidate.engine_id,
symbol=candidate.symbol,
reason=plan.skip_reason,
date=str(date),
)
continue
bar = self.store.get_bar(candidate.symbol, candidate.execution_date)
gap_skip_reason = self._check_next_open_gap_cap(candidate, bar)
if gap_skip_reason is not None:
self._total_orders_rejected += 1
self._release_add_on_reservation(candidate)
logger.debug(
"order_rejected",
engine_id=candidate.engine_id,
symbol=candidate.symbol,
reason=gap_skip_reason,
date=str(date),
)
continue
pos = simulate_entry(
plan,
bar,
self._build_effective_execution_config(candidate),
)
if pos is not None:
pos.parent_position_id = candidate.parent_position_id
pos.is_add_on = candidate.is_add_on
self._open_positions.append(pos)
self._cash -= pos.entry_price * pos.shares_total
self._daily_new_risk_used += plan.risk_dollars
self._engine_daily_new_risk_used[candidate.engine_id] += plan.risk_dollars
# Update equity and portfolio state for next candidate
mv = self._compute_positions_market_value(date)
self._equity = self._cash + mv
ur = mv - sum(
p.entry_price * p.shares_open
for p in self._open_positions
)
portfolio_state = self._build_portfolio_state(
date, drawdown_pct, ur
)
else:
self._release_add_on_reservation(candidate)
self._schedule_add_on_candidates(date)
self._schedule_delayed_entry_candidates(date)
# --- CASH PARKING: invest idle cash ---
if self.config.risk.cash_parking_enabled:
macro_data_eod = self.store.get_macro_for_date(date) or {}
park_mode = self.config.risk.cash_parking_symbol # "spy", "qqq", "dynamic", "sgov"
# Resolve which symbol to park in
spy_c = macro_data_eod.get("spy_close")
spy_s = macro_data_eod.get("spy_sma_20")
qqq_c = macro_data_eod.get("qqq_close")
qqq_s = macro_data_eod.get("qqq_sma_20")
spy_up = spy_c and spy_s and spy_c > spy_s
qqq_up = qqq_c and qqq_s and qqq_c > qqq_s
if park_mode == "dynamic":
# 3-way dynamic: QQQ (aggressive) / SPY (defensive) / SGOV (safe)
if spy_up and qqq_up:
chosen_sym = "qqq"
elif spy_up:
chosen_sym = "spy"
elif qqq_up:
chosen_sym = "qqq"
else:
chosen_sym = "sgov"
elif park_mode == "sgov":
chosen_sym = "sgov"
else:
# Fixed symbol (spy/qqq) with gate → SGOV fallback
chosen_sym = park_mode
if self.config.risk.cash_parking_trend_gate:
sym_up = spy_up if park_mode == "spy" else qqq_up
if not sym_up:
chosen_sym = "sgov" # gate failed → SGOV fallback
mv_for_parking = self._compute_positions_market_value(date)
equity_est = self._cash + mv_for_parking
reserve = equity_est * self.config.risk.cash_parking_reserve_pct
investable = max(0.0, self._cash - reserve)
if chosen_sym == "sgov":
if investable > 0:
self._parking_shares = 1
self._parking_avg_price = investable
self._parking_current_symbol = "sgov"
self._cash -= investable
else:
park_close_eod = macro_data_eod.get(f"{chosen_sym}_close")
if park_close_eod and park_close_eod > 0 and investable >= park_close_eod:
shares = int(investable / park_close_eod)
self._parking_shares = shares
self._parking_avg_price = park_close_eod
self._parking_current_symbol = chosen_sym
self._cash -= shares * park_close_eod
# --- Record daily equity curve snapshot ---
market_value_final = self._compute_positions_market_value(date)
macro_for_eq = self.store.get_macro_for_date(date) or {}
if self._parking_shares > 0 and self._parking_current_symbol == "sgov":
parking_value = self._parking_avg_price # SGOV: face value
elif self._parking_shares > 0 and self._parking_current_symbol:
park_price = macro_for_eq.get(f"{self._parking_current_symbol}_close", self._parking_avg_price)
parking_value = self._parking_shares * park_price
else:
parking_value = 0.0
unrealized_final = market_value_final - sum(
p.entry_price * p.shares_open for p in self._open_positions
)
self._equity = self._cash + market_value_final + parking_value
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
)
gross_exposure, net_exposure = self._compute_portfolio_exposure(date)
self._equity_curve.append(
DailyPortfolioState(
date=date,
equity=self._equity,
sizing_equity=self._sizing_equity,
cash_available=self._compute_buying_power(self._equity, gross_exposure),
gross_exposure=gross_exposure,
net_exposure=net_exposure,
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 _get_simulation_dates(self) -> list[dt.date]:
"""Return the full trading-day simulation range for the configured engines."""
if not self.config.get_strategy_engines():
return self.store.all_trading_days()
include_reaction_dates = any(
engine.entry_timing_policy == "reaction_close"
for engine in self._active_strategy_engines
)
return self.store.all_trading_days(include_reaction_dates=include_reaction_dates)
def _select_candidates_for_date(self, date: dt.date) -> list[Candidate]:
"""Select daily candidates for single-engine or multi-engine mode."""
if not self.config.get_strategy_engines():
raw_rows = self.store.get_candidates_for_date(date)
return select_candidates(
raw_rows,
self.config.universe,
self.config.signal,
event_type_profiles=self.config.event_type_profiles or None,
)
if not self._active_strategy_engines:
return []
engine_queues: dict[str, list[Candidate]] = {}
reserved_event_ids: set[str] = set()
reserved_symbols: set[str] = set()
for engine in self._active_strategy_engines:
if not self._engine_allowed_for_date(engine, date):
continue
if not self._engine_uses_snapshot_candidates(engine):
continue
prelimit = self.config.signal.max_candidates_per_day
if self._engine_requires_attention(engine):
prelimit = max(prelimit * 5, prelimit)
raw_rows = (
self.store.get_candidates_for_reaction_date(date)
if engine.entry_timing_policy == "reaction_close"
else self.store.get_candidates_for_date(date)
)
selected = select_candidates(
raw_rows,
self.config.universe,
self.config.signal,
event_type_profiles=self.config.event_type_profiles or None,
strategy_engine=engine,
truncate_to=prelimit,
excluded_event_ids=reserved_event_ids,
excluded_symbols=reserved_symbols,
)
selected = self._apply_attention_filters(selected, engine)
if selected:
engine_queues[engine.engine_id] = selected
if engine.residual_reserve_selected:
reserved_event_ids.update(candidate.event_id for candidate in selected)
reserved_symbols.update(candidate.symbol.upper() for candidate in selected)
scheduled_add_ons = self._scheduled_add_ons.pop(date, [])
if scheduled_add_ons:
grouped_add_ons: dict[str, list[Candidate]] = defaultdict(list)
for candidate in scheduled_add_ons:
grouped_add_ons[candidate.engine_id].append(candidate)
for engine_id, candidates in grouped_add_ons.items():
engine_queues.setdefault(engine_id, [])
engine_queues[engine_id].extend(rank_candidates(candidates))
if self.config.strategy_engine_selection_mode == "global_score":
merged = []
for candidates in engine_queues.values():
merged.extend(candidates)
merged = rank_candidates(merged, self.config.signal.ranking_fields)
return merged[: self.config.signal.max_candidates_per_day]
if self.config.strategy_engine_selection_mode == "interleave_head_score":
return self._interleave_engine_candidates_by_head_score(engine_queues)
return self._interleave_engine_candidates(engine_queues)
def _select_shadow_candidates_for_date(self, date: dt.date) -> list[Candidate]:
"""Select shadow candidates used only for synthetic lookback logic."""
if not self._shadow_strategy_engines:
return []
selected_shadow: list[Candidate] = []
reserved_event_ids: set[str] = set()
reserved_symbols: set[str] = set()
for engine in self._shadow_strategy_engines:
if not self._engine_allowed_for_date(engine, date):
continue
if not self._engine_uses_snapshot_candidates(engine):
continue
prelimit = self.config.signal.max_candidates_per_day
if self._engine_requires_attention(engine):
prelimit = max(prelimit * 5, prelimit)
raw_rows = (
self.store.get_candidates_for_reaction_date(date)
if engine.entry_timing_policy == "reaction_close"
else self.store.get_candidates_for_date(date)
)
selected = select_candidates(
raw_rows,
self.config.universe,
self.config.signal,
event_type_profiles=self.config.event_type_profiles or None,
strategy_engine=engine,
truncate_to=prelimit,
excluded_event_ids=reserved_event_ids,
excluded_symbols=reserved_symbols,
)
selected = self._apply_attention_filters(selected, engine)
if selected:
selected_shadow.extend(selected)
if engine.residual_reserve_selected:
reserved_event_ids.update(candidate.event_id for candidate in selected)
reserved_symbols.update(candidate.symbol.upper() for candidate in selected)
return selected_shadow
def _engine_uses_snapshot_candidates(self, engine: Any) -> bool:
if getattr(engine, "synthetic_only", False):
return False
# Backward compatibility: delayed add-ons were always intended to be
# scheduled synthetic child lots, not direct snapshot candidates.
if engine.engine_id == "delayed_add_on_long":
return False
return True
def _engine_allowed_for_date(self, engine: Any, date: dt.date) -> bool:
allowed_regimes = getattr(engine, "allowed_macro_regimes", None)
if not allowed_regimes:
return True
if not self.config.risk.macro_regime_enabled:
return True
return self._macro_regime_state_for_date(date) in set(allowed_regimes)
def _engine_requires_attention(self, engine: Any) -> bool:
return self._attention_service.engine_requires_attention(engine)
def _engine_requires_attention_data(self, engine: Any) -> bool:
return self._attention_service.engine_requires_attention_data(engine)
def _apply_attention_filters(
self,
candidates: list[Candidate],
engine: Any,
) -> list[Candidate]:
"""Delegate to shared AttentionFilterService."""
return self._attention_service.apply_filters(
candidates, engine, self.config.signal,
)
def _get_event_attention(self, candidate: Candidate) -> EventAttentionResponse | None:
event_date = candidate.event_date or candidate.reaction_date
cache_key = (candidate.symbol, event_date)
if cache_key in self._attention_cache:
return self._attention_cache[cache_key]
if not self._attention_base_url or self._attention_session is None:
self._attention_cache[cache_key] = None
return None
try:
response = self._attention_session.get(
f"{self._attention_base_url}/api/v1/attention/event/{candidate.symbol}",
params={"event_date": event_date.isoformat()},
timeout=30,
)
if response.status_code >= 400:
logger.debug(
"attention_fetch_failed",
symbol=candidate.symbol,
event_date=event_date.isoformat(),
status_code=response.status_code,
)
self._attention_cache[cache_key] = None
return None
payload = EventAttentionResponse.model_validate(response.json())
except Exception as exc:
logger.warning(
"attention_fetch_error",
symbol=candidate.symbol,
event_date=event_date.isoformat(),
error=str(exc),
)
self._attention_cache[cache_key] = None
return None
self._attention_cache[cache_key] = payload
return payload
def _passes_attention_filters(
self,
engine: Any,
attention: EventAttentionResponse,
) -> bool:
if (
engine.attention_min_wiki_spike_10d is not None
and (
attention.wiki.spike_10d is None
or attention.wiki.spike_10d < engine.attention_min_wiki_spike_10d
)
):
return False
if (
engine.attention_min_wiki_zscore_20d is not None
and (
attention.wiki.zscore_20d is None
or attention.wiki.zscore_20d < engine.attention_min_wiki_zscore_20d
)
):
return False
if (
engine.attention_max_wiki_spike_10d is not None
and (
attention.wiki.spike_10d is not None
and attention.wiki.spike_10d > engine.attention_max_wiki_spike_10d
)
):
return False
if (
engine.attention_max_wiki_zscore_20d is not None
and (
attention.wiki.zscore_20d is not None
and attention.wiki.zscore_20d > engine.attention_max_wiki_zscore_20d
)
):
return False
if (
engine.attention_min_article_count_3d is not None
and attention.news.article_count_3d < engine.attention_min_article_count_3d
):
return False
if (
engine.attention_min_us_article_count_3d is not None
and attention.news.us_article_count_3d < engine.attention_min_us_article_count_3d
):
return False
if (
engine.attention_min_resolver_confidence is not None
and attention.entity.resolver_confidence < engine.attention_min_resolver_confidence
):
return False
return True
def _attach_attention_features(
self,
candidate: Candidate,
attention: EventAttentionResponse,
) -> Candidate:
features = dict(candidate.features)
features.update(
{
"attention_wiki_spike_10d": attention.wiki.spike_10d,
"attention_wiki_zscore_20d": attention.wiki.zscore_20d,
"attention_article_count_3d": attention.news.article_count_3d,
"attention_us_article_count_3d": attention.news.us_article_count_3d,
"attention_gdelt_status": attention.news.gdelt_status,
"attention_resolver_confidence": attention.entity.resolver_confidence,
}
)
return candidate.model_copy(update={"features": features})
def _maybe_rescore_with_attention(self, candidate: Candidate) -> Candidate:
if self.config.signal.scoring_model not in {"return_max_long_v1", "return_max_long_v2", "return_max_long_v3", "return_max_long_v4", "return_max_long_v5", "return_max_long_v6", "return_max_long_v7", "return_max_long_v8", "return_max_long_v12", "return_max_long_v12r", "return_max_long_v12b", "return_max_long_v12o"}:
return candidate
from libs.backtest.scoring import (
compute_return_max_long_score,
compute_return_max_long_score_v2,
compute_return_max_long_score_v3,
compute_return_max_long_score_v4,
compute_return_max_long_score_v5,
compute_return_max_long_score_v6,
compute_return_max_long_score_v7,
compute_return_max_long_score_v8,
compute_return_max_long_score_v9,
compute_return_max_long_score_v9g,
compute_return_max_long_score_v10,
compute_return_max_long_score_v11,
compute_return_max_long_score_v11g,
)
rescored_features = dict(candidate.features)
rescored_features.update(
{
"event_type": candidate.event_type,
"event_direction": rescored_features.get("event_direction"),
}
)
if self.config.signal.scoring_model == "return_max_long_v11":
score = compute_return_max_long_score_v11(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v11g":
score = compute_return_max_long_score_v11g(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v10":
score = compute_return_max_long_score_v10(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v9g":
score = compute_return_max_long_score_v9g(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v9":
score = compute_return_max_long_score_v9(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v8":
score = compute_return_max_long_score_v8(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v7":
score = compute_return_max_long_score_v7(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v6":
score = compute_return_max_long_score_v6(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v3":
score = compute_return_max_long_score_v3(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v5":
score = compute_return_max_long_score_v5(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v4":
score = compute_return_max_long_score_v4(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v2":
score = compute_return_max_long_score_v2(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v12":
from libs.backtest.scoring import compute_return_max_long_score_v12
score = compute_return_max_long_score_v12(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v12r":
from libs.backtest.scoring import compute_return_max_long_score_v12r
score = compute_return_max_long_score_v12r(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v12b":
from libs.backtest.scoring import compute_return_max_long_score_v12b
score = compute_return_max_long_score_v12b(rescored_features)
elif self.config.signal.scoring_model == "return_max_long_v12o":
from libs.backtest.scoring import compute_return_max_long_score_v12o
score = compute_return_max_long_score_v12o(rescored_features)
else:
score = compute_return_max_long_score(rescored_features)
return candidate.model_copy(update={"score": score})
def _check_next_open_gap_cap(
self,
candidate: Candidate,
bar: dict[str, Any] | None,
) -> str | None:
if candidate.entry_timing_policy != "next_open":
return None
if candidate.engine_next_open_gap_cap_pct is None:
return None
if bar is None or bar.get("open") is None:
return None
reaction_close = candidate.features.get("event_close")
if reaction_close is None:
return None
try:
reaction_close = float(reaction_close)
except (TypeError, ValueError):
return None
if reaction_close <= 0:
return None
gap = float(bar["open"]) / reaction_close - 1.0
if gap > candidate.engine_next_open_gap_cap_pct:
return "next_open_gap_cap"
return None
def _interleave_engine_candidates(
self,
engine_queues: dict[str, list[Candidate]],
) -> list[Candidate]:
"""Round-robin engine queues using manifest order."""
if not engine_queues:
return []
working = {
engine_id: list(candidates)
for engine_id, candidates in engine_queues.items()
}
ordered: list[Candidate] = []
while True:
advanced = False
for engine in self._active_strategy_engines:
queue = working.get(engine.engine_id, [])
if not queue:
continue
ordered.append(queue.pop(0))
advanced = True
if not advanced:
break
return ordered[: self.config.signal.max_candidates_per_day]
def _interleave_engine_candidates_by_head_score(
self,
engine_queues: dict[str, list[Candidate]],
) -> list[Candidate]:
"""Round-robin by picking the strongest current head candidate each turn."""
if not engine_queues:
return []
working = {
engine_id: list(candidates)
for engine_id, candidates in engine_queues.items()
}
engine_order = {
engine.engine_id: index
for index, engine in enumerate(self._active_strategy_engines)
}
ordered: list[Candidate] = []
while True:
head_pool: list[tuple[float, int, Candidate]] = []
for engine in self._active_strategy_engines:
queue = working.get(engine.engine_id, [])
if not queue:
continue
candidate = queue[0]
head_pool.append(
(
candidate.score,
-engine_order.get(engine.engine_id, 0),
candidate,
)
)
if not head_pool:
break
_, _, winner = max(head_pool, key=lambda item: (item[0], item[1]))
ordered.append(working[winner.engine_id].pop(0))
return ordered[: self.config.signal.max_candidates_per_day]
def _reorder_candidates_for_funding(
self,
candidates: list[Candidate],
portfolio_state: DailyPortfolioState,
macro_data: dict[str, Any] | None,
) -> list[Candidate]:
mode = self.config.strategy_engine_selection_mode
if mode not in {
"interleave_cap_efficiency_soft",
"interleave_cap_efficiency_strict",
"interleave_cash_tiebreak",
}:
return candidates
ranked: list[tuple[float, float, int, Candidate]] = []
skipped: list[tuple[int, Candidate]] = []
for idx, candidate in enumerate(candidates):
plan = build_planned_order(
candidate=candidate,
portfolio_state=portfolio_state,
open_positions=self._open_positions,
config=self.config,
execution_config=self._build_effective_execution_config(candidate),
cooldown_remaining=self._cooldown_remaining,
macro_data=macro_data,
engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id],
)
if plan.skip_reason is not None or plan.shares <= 0:
skipped.append((idx, candidate))
continue
estimated_cash = max(float(plan.shares * candidate.entry_price_est), 1.0)
if mode == "interleave_cash_tiebreak":
score_band = math.floor(candidate.score / 0.02)
efficiency = float(score_band)
cash_rank = estimated_cash
elif mode == "interleave_cap_efficiency_strict":
efficiency = candidate.score / estimated_cash
cash_rank = candidate.score
else:
efficiency = candidate.score / math.sqrt(estimated_cash)
cash_rank = candidate.score
ranked.append((efficiency, cash_rank, idx, candidate))
if mode == "interleave_cash_tiebreak":
ranked.sort(key=lambda item: (-item[0], item[1], item[2]))
else:
ranked.sort(key=lambda item: (-item[0], -item[1], item[2]))
ordered = [candidate for _, _, _, candidate in ranked]
ordered.extend(candidate for _, candidate in sorted(skipped, key=lambda item: item[0]))
return ordered
def _build_effective_execution_config(self, candidate: Candidate) -> ExecutionConfig:
"""Resolve per-engine and per-event execution overrides.
Delegates to shared function in libs.backtest.execution for consistency
with PaperTradingEngine.
"""
from libs.backtest.execution import build_effective_execution_config
return build_effective_execution_config(candidate, self.config)
def _build_per_engine_metrics(self) -> dict[str, dict[str, Any]]:
"""Compute per-engine trade attribution from the main run's trades.
Instead of re-running the full backtest N times (one per engine),
group actual trades by engine_id and compute metrics for each group.
"""
# Group trades by engine_id
engine_trades: dict[str, list[FilledTrade]] = defaultdict(list)
engine_candidates: dict[str, dict[str, Candidate]] = defaultdict(dict)
for trade in self._closed_trades:
cand = self._candidate_map.get(trade.trade_id)
eid = cand.engine_id if cand else "unknown"
engine_trades[eid].append(trade)
if cand:
engine_candidates[eid][trade.trade_id] = cand
summaries: dict[str, dict[str, Any]] = {}
for engine in self.config.get_strategy_engines():
eid = engine.engine_id
trades = engine_trades.get(eid, [])
cand_map = engine_candidates.get(eid, {})
if trades:
engine_metrics = build_metrics_bundle(trades, self._equity_curve, cand_map)
metrics_dict = engine_metrics.model_dump(mode="json")
else:
metrics_dict = MetricsBundle().model_dump(mode="json")
summaries[eid] = {
"engine_id": eid,
"shadow_only": engine.shadow_only,
"event_types": list(engine.event_types),
"timing_class": engine.timing_class,
"direction": engine.direction,
"entry_timing_policy": engine.entry_timing_policy,
"max_holding_days": engine.max_holding_days,
"engine_risk_budget_pct": engine.engine_risk_budget_pct,
"target_atr_multiplier_override": engine.target_atr_multiplier_override,
"target_1_r_override": engine.target_1_r_override,
"target_1_fraction_override": engine.target_1_fraction_override,
"trailing_model_override": engine.trailing_model_override,
"trailing_warmup_days_override": engine.trailing_warmup_days_override,
"trade_count": len(trades),
"net_pnl": round(sum(t.net_pnl for t in trades), 4),
"win_rate": (
round(sum(1 for t in trades if t.net_pnl > 0) / len(trades), 4)
if trades else None
),
"metrics": metrics_dict,
}
return summaries
def _is_a_tier(self, candidate: Candidate) -> bool:
threshold = self.config.signal.a_tier_score_threshold
return threshold is not None and candidate.score >= threshold
def _macro_regime_state_for_date(self, date: dt.date) -> str:
macro_data = self.store.get_macro_for_date(date)
if not self.config.risk.macro_regime_enabled:
return "disabled"
if self.config.risk.macro_regime_mode == "spy_qqq_scaler":
spy_close = macro_data.get("spy_close")
spy_sma = macro_data.get("spy_sma_20")
qqq_close = macro_data.get("qqq_close")
qqq_sma = macro_data.get("qqq_sma_20")
if None in (spy_close, spy_sma, qqq_close, qqq_sma):
return "unknown"
spy_on = float(spy_close) >= float(spy_sma)
qqq_on = float(qqq_close) >= float(qqq_sma)
if spy_on and qqq_on:
return "risk_on"
if spy_on or qqq_on:
return "neutral"
return "risk_off"
spy_close = macro_data.get("spy_close")
spy_sma = macro_data.get("spy_sma_20")
if spy_close is None or spy_sma is None:
return "unknown"
return "risk_off" if float(spy_close) < float(spy_sma) else "risk_on"
def _queue_pending_open_exit(self, date: dt.date, payload: dict[str, Any]) -> None:
next_date = self._next_trading_day.get(date)
if next_date is None:
return
existing = self._pending_open_exits.get(next_date, [])
if any(
item.get("position_id") == payload.get("position_id")
and item.get("reason") == payload.get("reason")
for item in existing
):
return
self._pending_open_exits[next_date].append(payload)
def _evaluate_pending_open_exit(
self,
position: OpenPosition,
bar: dict[str, Any],
execution_config: ExecutionConfig,
date: dt.date,
) -> dict[str, Any] | None:
if position.plan.candidate.trade_direction != "long":
return None
close_value = bar.get("close")
if close_value is None:
return None
close_value = float(close_value)
reaction_close = position.plan.candidate.features.get("event_close")
if reaction_close is None:
reaction_close = position.plan.candidate.entry_price_est
try:
reaction_close = float(reaction_close)
except (TypeError, ValueError):
reaction_close = position.plan.candidate.entry_price_est
if (
execution_config.early_failure_close_below_entry_and_reaction_close
and position.days_held == 1
and close_value < position.entry_price
and close_value < reaction_close
):
return {
"position_id": position.position_id,
"reason": "EARLY_FAILURE",
"fraction": 1.0,
}
no_progress_days = execution_config.early_failure_no_progress_days
no_progress_r = execution_config.early_failure_no_progress_r
if (
no_progress_days is not None
and no_progress_r is not None
and position.days_held >= no_progress_days
and position.days_held == no_progress_days
and position.status != PositionStatus.PARTIALLY_EXITED
):
initial_r = abs(position.entry_price - position.plan.stop_price)
progress_price = position.entry_price + initial_r * no_progress_r
if close_value < progress_price:
return {
"position_id": position.position_id,
"reason": "NO_PROGRESS",
"fraction": execution_config.early_failure_no_progress_fraction or 0.5,
}
trigger_r = execution_config.early_pop_giveback_trigger_r
min_r = execution_config.early_pop_giveback_min_r
from_peak_pct = execution_config.early_pop_giveback_from_peak_pct
if (
trigger_r is not None
and min_r is not None
and from_peak_pct is not None
and position.status != PositionStatus.PARTIALLY_EXITED
):
event_direction = str(position.plan.candidate.features.get("event_direction", "")).lower()
guidance_status = str(position.plan.candidate.features.get("guidance_status", "")).lower()
is_unknown_inline = (
event_direction == "unknown"
and guidance_status == "inline_or_maintained"
)
days_min = execution_config.early_pop_giveback_days_min or 1
days_max = execution_config.early_pop_giveback_days_max or position.days_held
if not is_unknown_inline and days_min <= position.days_held <= days_max:
initial_r = abs(position.entry_price - position.plan.stop_price)
if initial_r > 0:
peak_progress = position.peak_price - position.entry_price
close_progress = close_value - position.entry_price
gave_back_r = close_progress < (initial_r * min_r)
gave_back_pct = close_value < (position.peak_price * (1.0 - from_peak_pct))
if peak_progress >= (initial_r * trigger_r) and (gave_back_r or gave_back_pct):
return {
"position_id": position.position_id,
"reason": "GIVEBACK",
"fraction": execution_config.early_pop_giveback_fraction or 1.0,
}
return None
def _process_pending_open_exits(self, date: dt.date) -> None:
payloads = self._pending_open_exits.pop(date, [])
if not payloads:
return
by_position_id = {payload["position_id"]: payload for payload in payloads}
remaining_positions: list[OpenPosition] = []
for position in self._open_positions:
payload = by_position_id.get(position.position_id)
if payload is None:
remaining_positions.append(position)
continue
bar = self.store.get_bar(position.plan.candidate.symbol, date)
if bar is None:
remaining_positions.append(position)
continue
trade = simulate_scheduled_open_exit(
position=position,
bar=bar,
config=self._build_effective_execution_config(position.plan.candidate),
current_date=date,
reason=payload["reason"],
fraction=float(payload.get("fraction", 1.0)),
)
if trade is None:
remaining_positions.append(position)
continue
self._closed_trades.append(trade)
self._candidate_map[trade.trade_id] = position.plan.candidate
self._realized_pnl += trade.net_pnl
self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
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
if position.shares_open > 0:
remaining_positions.append(position)
self._open_positions = remaining_positions
def _schedule_add_on_candidates(self, date: dt.date) -> None:
next_date = self._next_trading_day.get(date)
if next_date is None:
return
add_on_engine = next(
(engine for engine in self._active_strategy_engines if engine.engine_id == "delayed_add_on_long"),
None,
)
if add_on_engine is None:
return
if self._macro_regime_state_for_date(date) == "risk_off":
return
for position in self._open_positions:
if position.is_add_on:
continue
current_add_on_count = self._parent_add_on_counts.get(position.position_id, 0)
max_add_on_count = max(1, add_on_engine.add_on_max_count)
if current_add_on_count >= max_add_on_count:
continue
if position.plan.candidate.trade_direction != "long":
continue
min_days_held = add_on_engine.add_on_min_parent_days_held or 1
max_days_held = add_on_engine.add_on_max_parent_days_held or 2
if position.days_held < min_days_held or position.days_held > max_days_held:
continue
if (
add_on_engine.add_on_schedule_days
and position.days_held not in set(add_on_engine.add_on_schedule_days)
):
continue
if (
add_on_engine.add_on_parent_score_min is not None
and position.plan.candidate.score < add_on_engine.add_on_parent_score_min
):
continue
if (
add_on_engine.add_on_parent_engine_ids
and position.plan.candidate.engine_id not in add_on_engine.add_on_parent_engine_ids
):
continue
bar = self.store.get_bar(position.plan.candidate.symbol, date)
if bar is None or bar.get("close") is None:
continue
close_value = float(bar["close"])
if close_value <= position.entry_price:
continue
reaction_close = position.plan.candidate.features.get("event_close")
if reaction_close is None:
reaction_close = position.plan.candidate.entry_price_est
try:
reaction_close = float(reaction_close)
except (TypeError, ValueError):
reaction_close = position.plan.candidate.entry_price_est
if close_value <= reaction_close:
continue
high = bar.get("high")
low = bar.get("low")
if high is None or low is None or float(high) <= float(low):
continue
close_location = (close_value - float(low)) / (float(high) - float(low))
close_location_min = add_on_engine.add_on_close_location_min or 0.65
if close_location < close_location_min:
continue
initial_r = abs(position.entry_price - position.plan.stop_price)
progress_levels = add_on_engine.add_on_progress_r_levels or []
if progress_levels:
progress_r_min = progress_levels[min(current_add_on_count, len(progress_levels) - 1)]
else:
progress_r_min = add_on_engine.add_on_progress_r_min or 0.5
if close_value <= position.entry_price + (progress_r_min * initial_r):
continue
if add_on_engine.add_on_require_above_reaction_high:
reaction_high = position.plan.candidate.features.get("reaction_day_high")
try:
if reaction_high is None or close_value <= float(reaction_high):
continue
except (TypeError, ValueError):
continue
size_fraction = add_on_engine.add_on_size_fraction or 0.5
forced_shares = max(1, int(position.shares_open * size_fraction))
candidate = position.plan.candidate.model_copy(
update={
"engine_id": add_on_engine.engine_id,
"entry_timing_policy": "next_open",
"execution_date": next_date,
"reaction_date": date,
"entry_price_est": close_value,
"engine_max_holding_days": add_on_engine.max_holding_days,
"engine_risk_budget_pct": add_on_engine.engine_risk_budget_pct,
"engine_target_atr_multiplier": add_on_engine.target_atr_multiplier_override,
"engine_target_1_r": add_on_engine.target_1_r_override,
"engine_target_1_fraction": add_on_engine.target_1_fraction_override,
"engine_trailing_model": add_on_engine.trailing_model_override,
"engine_trailing_warmup_days": add_on_engine.trailing_warmup_days_override,
"engine_next_open_gap_cap_pct": add_on_engine.next_open_gap_cap_pct,
"engine_add_on_max_count": add_on_engine.add_on_max_count,
"engine_add_on_size_fraction": add_on_engine.add_on_size_fraction,
"parent_position_id": position.position_id,
"is_add_on": True,
"forced_shares": forced_shares,
"features": {
**position.plan.candidate.features,
"add_on_signal_date": date.isoformat(),
"add_on_close_location": close_location,
"add_on_progress_r_min": progress_r_min,
"add_on_index": current_add_on_count + 1,
},
}
)
self._scheduled_add_ons[next_date].append(candidate)
self._parent_add_on_counts[position.position_id] += 1
def _schedule_delayed_entry_candidates(self, date: dt.date) -> None:
"""Generate delayed-entry candidates from past events where drift is confirmed.
For engines with delayed_entry_lookback_days set, look back N trading days
to find scored candidates whose price has continued drifting upward.
This captures the continuation phase of PEAD after initial momentum is confirmed.
"""
next_date = self._next_trading_day.get(date)
if next_date is None:
return
delayed_engines = [
e for e in self._active_strategy_engines
if e.delayed_entry_lookback_days is not None
]
if not delayed_engines:
return
open_symbols = {p.plan.candidate.symbol for p in self._open_positions}
for engine in delayed_engines:
lookback = engine.delayed_entry_lookback_days
source_engine_ids = set(engine.delayed_entry_source_engine_ids or [])
min_drift = engine.delayed_entry_min_drift_pct or 0.0
cl_min = engine.delayed_entry_close_location_min or 0.50
# Find the date that was `lookback` trading days ago
try:
sim_idx = self._simulation_dates.index(date)
except ValueError:
continue
if sim_idx < lookback:
continue
lookback_date = self._simulation_dates[sim_idx - lookback]
past_candidates = self._recent_scored_candidates.get(lookback_date, [])
for past_cand in past_candidates:
if source_engine_ids and past_cand.engine_id not in source_engine_ids:
continue
if past_cand.symbol in open_symbols:
continue
if past_cand.trade_direction != "long":
continue
bar = self.store.get_bar(past_cand.symbol, date)
if bar is None or bar.get("close") is None:
continue
close_value = float(bar["close"])
reaction_close = past_cand.entry_price_est
if reaction_close <= 0:
continue
drift_pct = (close_value - reaction_close) / reaction_close
if drift_pct < min_drift:
continue
high = bar.get("high")
low = bar.get("low")
if high is None or low is None or float(high) <= float(low):
continue
today_cl = (close_value - float(low)) / (float(high) - float(low))
if today_cl < cl_min:
continue
candidate = past_cand.model_copy(
update={
"engine_id": engine.engine_id,
"entry_timing_policy": "next_open",
"execution_date": next_date,
"reaction_date": date,
"entry_price_est": close_value,
"engine_max_holding_days": engine.max_holding_days,
"engine_risk_budget_pct": engine.engine_risk_budget_pct,
"engine_per_trade_risk_pct": engine.per_trade_risk_pct_override,
"engine_target_1_r": engine.target_1_r_override,
"engine_target_1_fraction": engine.target_1_fraction_override,
"engine_trailing_model": engine.trailing_model_override,
"engine_trailing_warmup_days": engine.trailing_warmup_days_override,
"engine_stop_atr_multiplier": engine.stop_atr_multiplier_override,
"engine_next_open_gap_cap_pct": engine.next_open_gap_cap_pct,
"engine_use_reaction_day_low_stop": False,
"shadow_only": engine.shadow_only,
"is_add_on": False,
"parent_position_id": None,
"forced_shares": None,
"features": {
**past_cand.features,
"delayed_entry_signal_date": date.isoformat(),
"delayed_entry_drift_pct": round(drift_pct, 4),
"delayed_entry_close_location": round(today_cl, 4),
},
}
)
self._scheduled_delayed_entries[next_date].append(candidate)
def _release_add_on_reservation(self, candidate: Candidate) -> None:
if not candidate.is_add_on or candidate.parent_position_id is None:
return
reserved = self._parent_add_on_counts.get(candidate.parent_position_id, 0)
if reserved <= 0:
return
self._parent_add_on_counts[candidate.parent_position_id] = reserved - 1
def _build_benchmark_and_contribution_metrics(self, metrics: MetricsBundle) -> dict[str, Any]:
updates: dict[str, Any] = {}
qqq_return = self._compute_qqq_benchmark_return_pct()
updates["qqq_benchmark_return_pct"] = qqq_return
updates["excess_vs_qqq_pct"] = (
metrics.total_return_pct - qqq_return
if metrics.total_return_pct is not None and qqq_return is not None
else None
)
short_net_pnl = sum(
trade.net_pnl
for trade in self._closed_trades
if self._candidate_map.get(trade.trade_id) is not None
and self._candidate_map[trade.trade_id].trade_direction == "short"
)
long_net_pnl = sum(
trade.net_pnl
for trade in self._closed_trades
if self._candidate_map.get(trade.trade_id) is not None
and self._candidate_map[trade.trade_id].trade_direction == "long"
)
total_net_pnl = long_net_pnl + short_net_pnl
updates["long_net_pnl"] = round(long_net_pnl, 4)
updates["short_net_pnl"] = round(short_net_pnl, 4)
if total_net_pnl != 0:
updates["long_pnl_contribution_pct"] = long_net_pnl / total_net_pnl * 100.0
updates["short_pnl_contribution_pct"] = short_net_pnl / total_net_pnl * 100.0
else:
updates["long_pnl_contribution_pct"] = None
updates["short_pnl_contribution_pct"] = None
return updates
def _compute_qqq_benchmark_return_pct(self) -> float | None:
if not self._equity_curve:
return None
first = None
last = None
for state in self._equity_curve:
qqq_close = self.store.get_macro_for_date(state.date).get("qqq_close")
if qqq_close is None:
continue
if first is None:
first = qqq_close
last = qqq_close
if first in (None, 0) or last is None:
return None
return (float(last) - float(first)) / float(first) * 100.0
def _compute_positions_market_value(self, date: dt.date) -> float:
"""Market value of all open positions using today's close.
For long: market_value = close * shares.
For short: market_value = (2 * entry - close) * shares.
This reflects that a short position gains when price falls:
the "value" of a short at entry is entry_price * shares,
and PnL = (entry - close) * shares, so effective value = entry + PnL = (2*entry - close).
Falls back to entry_price when bar is missing (assumes no change
rather than treating the position as worthless).
"""
total = 0.0
for pos in self._open_positions:
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
is_short = pos.plan.candidate.trade_direction == "short"
if bar and bar.get("close"):
close = float(bar["close"])
if is_short:
total += (2.0 * pos.entry_price - close) * pos.shares_open
else:
total += close * pos.shares_open
else:
total += pos.entry_price * pos.shares_open
return total
def _compute_unrealized_pnl(self, date: dt.date) -> float:
"""Unrealized PnL = market_value cost_basis."""
market_value = self._compute_positions_market_value(date)
cost_basis = sum(p.entry_price * p.shares_open for p in self._open_positions)
return market_value - cost_basis
def _build_portfolio_state(
self,
date: dt.date,
drawdown_pct: float,
unrealized: float,
) -> DailyPortfolioState:
gross_exposure, net_exposure = self._compute_portfolio_exposure(date)
return DailyPortfolioState(
date=date,
equity=self._equity,
sizing_equity=self._sizing_equity,
cash_available=self._compute_buying_power(self._equity, gross_exposure),
gross_exposure=gross_exposure,
net_exposure=net_exposure,
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 _attempt_same_day_cash_recycle(
self,
date: dt.date,
candidate: Candidate,
portfolio_state: DailyPortfolioState,
) -> bool:
engine = next(
(item for item in self._active_strategy_engines if item.engine_id == candidate.engine_id),
None,
)
if engine is None or not engine.recycle_on_cash_block:
return False
if candidate.trade_direction != "long":
return False
if candidate.entry_timing_policy not in {"reaction_close", "next_open"}:
return False
shortfall = self._estimate_cash_shortfall(candidate, portfolio_state)
if shortfall <= 0:
return False
victim = self._select_recycle_victim(
date=date,
candidate=candidate,
shortfall=shortfall,
engine=engine,
)
if victim is None:
return False
return self._execute_same_day_recycle_exit(victim, date, candidate)
def _estimate_cash_shortfall(
self,
candidate: Candidate,
portfolio_state: DailyPortfolioState,
) -> float:
trade_risk_pct = _resolve_effective_per_trade_risk_pct(candidate, self.config)
stop_price = compute_stop_price(candidate, _resolve_stop_risk_config(candidate, self.config))
shares = compute_shares(
_resolve_sizing_equity(portfolio_state),
candidate.entry_price_est,
stop_price,
self.config.risk,
risk_pct_override=trade_risk_pct,
)
shares = _cap_shares_by_position_limits(shares, candidate, portfolio_state, self.config)
if self.config.risk.allow_budget_downsizing:
remaining_risk, _, _ = _remaining_risk_budget_dollars(
candidate,
portfolio_state,
self.config,
engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id],
)
shares = _cap_shares_to_remaining_risk_budget(
shares,
candidate.entry_price_est,
stop_price,
remaining_risk,
)
required_notional = max(0.0, shares * float(candidate.entry_price_est))
return max(0.0, required_notional - portfolio_state.cash_available)
def _select_recycle_victim(
self,
*,
date: dt.date,
candidate: Candidate,
shortfall: float,
engine: Any,
) -> OpenPosition | None:
allow_any_engine = bool(getattr(engine, "recycle_allow_any_victim_engine", False))
allow_cross_timing = bool(getattr(engine, "recycle_allow_cross_timing", False))
allowed_victims = None if allow_any_engine else set(
engine.recycle_allowed_victim_engine_ids or [candidate.engine_id]
)
min_days = engine.recycle_min_days_held or 0
min_delta = engine.recycle_min_score_delta or 0.0
max_victim_fitness = getattr(engine, "recycle_max_victim_fitness", None)
max_victim_unrealized_r = getattr(engine, "recycle_max_victim_unrealized_r", None)
use_generic_victim_filter = (
allow_any_engine
or allow_cross_timing
or max_victim_fitness is not None
or max_victim_unrealized_r is not None
)
eligible: list[tuple[float, float, float, float, int, OpenPosition]] = []
for position in self._open_positions:
victim_candidate = position.plan.candidate
if victim_candidate.trade_direction != candidate.trade_direction:
continue
if not allow_cross_timing and victim_candidate.entry_timing_policy != candidate.entry_timing_policy:
continue
if allowed_victims is not None and victim_candidate.engine_id not in allowed_victims:
continue
if position.days_held < min_days:
continue
if candidate.score < (victim_candidate.score + min_delta):
continue
bar = self.store.get_bar(victim_candidate.symbol, date)
if bar is None or bar.get("close") is None:
continue
close_value = float(bar["close"])
if close_value <= 0:
continue
if engine.recycle_positive_pnl_only and close_value < position.entry_price:
continue
effective_exec = self._build_effective_execution_config(victim_candidate)
fitness = self._compute_hold_fitness(position, bar, effective_exec)
stop_dist = abs(position.entry_price - position.plan.stop_price)
unrealized_r = (
(close_value - position.entry_price) / stop_dist
if stop_dist > 0
else 0.0
)
if max_victim_fitness is not None and fitness > max_victim_fitness:
continue
if max_victim_unrealized_r is not None and unrealized_r > max_victim_unrealized_r:
continue
proceeds = close_value * position.shares_open
if proceeds < shortfall:
continue
if use_generic_victim_filter:
eligible.append(
(fitness, unrealized_r, victim_candidate.score, proceeds, -position.days_held, position)
)
else:
eligible.append(
(victim_candidate.score, proceeds, -position.days_held, proceeds, -position.days_held, position)
)
if not eligible:
return None
eligible.sort(key=lambda item: item[:-1])
return eligible[0][-1]
def _execute_same_day_recycle_exit(
self,
position: OpenPosition,
date: dt.date,
candidate: Candidate,
) -> bool:
bar = self.store.get_bar(position.plan.candidate.symbol, date)
trade = simulate_recycle_close_exit(
position=position,
bar=bar,
current_date=date,
config=self._build_effective_execution_config(position.plan.candidate),
)
if trade is None:
return False
self._closed_trades.append(trade)
self._candidate_map[trade.trade_id] = position.plan.candidate
self._realized_pnl += trade.net_pnl
self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
self._open_positions = [
existing for existing in self._open_positions if existing.position_id != position.position_id
]
logger.info(
"same_day_recycle_exit",
date=str(date),
victim_symbol=position.plan.candidate.symbol,
victim_engine=position.plan.candidate.engine_id,
replacement_symbol=candidate.symbol,
replacement_engine=candidate.engine_id,
)
return True
def _compute_hold_fitness(
self,
position: OpenPosition,
bar: dict[str, Any],
effective_exec: ExecutionConfig,
) -> float:
"""Multi-factor fitness score for a held position. Lower = weaker hold."""
close_price = float(bar.get("close", position.entry_price))
max_days = effective_exec.max_holding_days or 25
time_used = min(1.0, position.days_held / max_days)
stop_dist = abs(position.entry_price - position.plan.stop_price)
unrealized_r = (close_price - position.entry_price) / stop_dist if stop_dist > 0 else 0.0
target_r = effective_exec.target_1_r or effective_exec.a_tier_target_1_r or 2.0
progress = unrealized_r / target_r if target_r > 0 else 0.0
peak = position.peak_price if position.peak_price > 0 else position.entry_price
peak_dd = (peak - close_price) / peak if peak > 0 else 0.0
return 0.4 * progress + 0.3 * (1.0 - time_used) + 0.3 * (1.0 - peak_dd)
def _attempt_rotation_exits(
self,
date: dt.date,
candidates: list[Candidate],
) -> int:
"""Proactively close stale positions when good opportunities exist today.
Returns number of positions rotated out.
"""
# Check if any engine has rotation enabled
rotation_engines = {
e.engine_id: e for e in self._active_strategy_engines if e.rotation_enabled
}
if not rotation_engines:
return 0
# Check if there's a credible opportunity today
min_score = min(e.rotation_min_candidate_score for e in rotation_engines.values())
has_opportunity = any(c.score >= min_score for c in candidates)
if not has_opportunity:
return 0
rotated = 0
positions_to_remove: list[str] = []
for position in self._open_positions:
engine_cfg = rotation_engines.get(position.plan.candidate.engine_id)
if engine_cfg is None:
continue
if position.days_held < engine_cfg.rotation_min_days_held:
continue
if position.status != PositionStatus.ENTERED:
continue
bar = self.store.get_bar(position.plan.candidate.symbol, date)
if bar is None or bar.get("close") is None:
continue
effective_exec = self._build_effective_execution_config(position.plan.candidate)
fitness = self._compute_hold_fitness(position, bar, effective_exec)
unrealized_r = (
(float(bar["close"]) - position.entry_price) /
max(0.01, abs(position.entry_price - position.plan.stop_price))
)
if fitness >= engine_cfg.rotation_fitness_threshold:
continue
if (
engine_cfg.rotation_max_unrealized_r is not None
and unrealized_r > engine_cfg.rotation_max_unrealized_r
):
continue
trade = simulate_rotation_exit(
position=position,
bar=bar,
current_date=date,
config=effective_exec,
)
if trade is None:
continue
self._closed_trades.append(trade)
self._candidate_map[trade.trade_id] = position.plan.candidate
self._realized_pnl += trade.net_pnl
self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
positions_to_remove.append(position.position_id)
rotated += 1
logger.info(
"rotation_exit",
date=str(date),
symbol=position.plan.candidate.symbol,
fitness=round(fitness, 3),
days_held=position.days_held,
unrealized_r=round(unrealized_r, 2),
)
if positions_to_remove:
self._open_positions = [
p for p in self._open_positions
if p.position_id not in positions_to_remove
]
return rotated
def _force_close_all(self, date: dt.date, reason: str = "force_close") -> None:
"""Close all open positions (end of backtest or kill switch)."""
for pos in list(self._open_positions):
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
trade = simulate_kill_switch_exit(pos, bar, date, self.config.execution)
self._closed_trades.append(trade)
self._candidate_map[trade.trade_id] = pos.plan.candidate
self._realized_pnl += trade.net_pnl
self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
self._open_positions = []
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _build_store(
manifest: ExperimentManifest,
config: BacktestConfig,
split_name: str,
snapshot_dir_override: str | None = None,
) -> SnapshotStore:
from libs.common.config import get_settings
s = get_settings()
snapshot_dir = Path(snapshot_dir_override or s.parquet_dir) / config.dataset_snapshot_id
# Resolve scoring function from config
scoring_fn = None
if config.signal.scoring_model == "pead":
from libs.backtest.scoring import compute_pead_score
from functools import partial
scoring_fn = partial(
compute_pead_score,
reaction_threshold=config.signal.pead_reaction_threshold,
volume_threshold=config.signal.pead_volume_threshold,
)
elif config.signal.scoring_model == "return_max_long_v1":
from libs.backtest.scoring import compute_return_max_long_score
scoring_fn = compute_return_max_long_score
elif config.signal.scoring_model == "return_max_long_v2":
from libs.backtest.scoring import compute_return_max_long_score_v2
scoring_fn = compute_return_max_long_score_v2
elif config.signal.scoring_model == "return_max_long_v3":
from libs.backtest.scoring import compute_return_max_long_score_v3
scoring_fn = compute_return_max_long_score_v3
elif config.signal.scoring_model == "return_max_long_v4":
from libs.backtest.scoring import compute_return_max_long_score_v4
scoring_fn = compute_return_max_long_score_v4
elif config.signal.scoring_model == "return_max_long_v5":
from libs.backtest.scoring import compute_return_max_long_score_v5
scoring_fn = compute_return_max_long_score_v5
elif config.signal.scoring_model == "return_max_long_v6":
from libs.backtest.scoring import compute_return_max_long_score_v6
scoring_fn = compute_return_max_long_score_v6
elif config.signal.scoring_model == "return_max_long_v7":
from libs.backtest.scoring import compute_return_max_long_score_v7
scoring_fn = compute_return_max_long_score_v7
elif config.signal.scoring_model == "return_max_long_v8":
from libs.backtest.scoring import compute_return_max_long_score_v8
scoring_fn = compute_return_max_long_score_v8
elif config.signal.scoring_model == "return_max_long_v9":
from libs.backtest.scoring import compute_return_max_long_score_v9
scoring_fn = compute_return_max_long_score_v9
elif config.signal.scoring_model == "return_max_long_v9g":
from libs.backtest.scoring import compute_return_max_long_score_v9g
scoring_fn = compute_return_max_long_score_v9g
elif config.signal.scoring_model == "return_max_long_v10":
from libs.backtest.scoring import compute_return_max_long_score_v10
scoring_fn = compute_return_max_long_score_v10
elif config.signal.scoring_model == "return_max_long_v11":
from libs.backtest.scoring import compute_return_max_long_score_v11
scoring_fn = compute_return_max_long_score_v11
elif config.signal.scoring_model == "return_max_long_v11g":
from libs.backtest.scoring import compute_return_max_long_score_v11g
scoring_fn = compute_return_max_long_score_v11g
elif config.signal.scoring_model == "return_max_long_v11_surprise":
from libs.backtest.scoring import compute_return_max_long_score_v11_surprise
scoring_fn = compute_return_max_long_score_v11_surprise
elif config.signal.scoring_model == "return_max_long_v12":
from libs.backtest.scoring import compute_return_max_long_score_v12
scoring_fn = compute_return_max_long_score_v12
elif config.signal.scoring_model == "return_max_long_v12r":
from libs.backtest.scoring import compute_return_max_long_score_v12r
scoring_fn = compute_return_max_long_score_v12r
elif config.signal.scoring_model == "return_max_long_v12b":
from libs.backtest.scoring import compute_return_max_long_score_v12b
scoring_fn = compute_return_max_long_score_v12b
elif config.signal.scoring_model == "return_max_long_v12o":
from libs.backtest.scoring import compute_return_max_long_score_v12o
scoring_fn = compute_return_max_long_score_v12o
elif config.signal.scoring_model == "return_max_long_v13":
from libs.backtest.scoring import compute_return_max_long_score_v13
scoring_fn = compute_return_max_long_score_v13
elif config.signal.scoring_model == "return_max_long_v13h":
from libs.backtest.scoring import compute_return_max_long_score_v13h
scoring_fn = compute_return_max_long_score_v13h
elif config.signal.scoring_model == "return_max_long_v13e":
from libs.backtest.scoring import compute_return_max_long_score_v13e
scoring_fn = compute_return_max_long_score_v13e
elif config.signal.scoring_model == "return_max_long_v13s":
from libs.backtest.scoring import compute_return_max_long_score_v13s
scoring_fn = compute_return_max_long_score_v13s
elif config.signal.scoring_model == "return_max_long_v15":
from libs.backtest.scoring import compute_return_max_long_score_v15
scoring_fn = compute_return_max_long_score_v15
elif config.signal.scoring_model == "return_max_long_v15b":
from libs.backtest.scoring import compute_return_max_long_score_v15b
scoring_fn = compute_return_max_long_score_v15b
elif config.signal.scoring_model == "return_max_long_v15c":
from libs.backtest.scoring import compute_return_max_long_score_v15c
scoring_fn = compute_return_max_long_score_v15c
elif config.signal.scoring_model == "return_max_long_v15d":
from libs.backtest.scoring import compute_return_max_long_score_v15d
scoring_fn = compute_return_max_long_score_v15d
elif config.signal.scoring_model == "return_max_long_v15e":
from libs.backtest.scoring import compute_return_max_long_score_v15e
scoring_fn = compute_return_max_long_score_v15e
elif config.signal.scoring_model == "return_max_long_v15f":
from libs.backtest.scoring import compute_return_max_long_score_v15f
scoring_fn = compute_return_max_long_score_v15f
elif config.signal.scoring_model == "return_max_long_v14":
from libs.backtest.scoring import compute_return_max_long_score_v14
scoring_fn = compute_return_max_long_score_v14
elif config.signal.scoring_model == "return_max_long_v14_ou":
from libs.backtest.scoring import compute_return_max_long_score_v14_ou
scoring_fn = compute_return_max_long_score_v14_ou
elif config.signal.scoring_model == "return_max_long_v14_gp":
from libs.backtest.scoring import compute_return_max_long_score_v14_gp
scoring_fn = compute_return_max_long_score_v14_gp
elif config.signal.scoring_model == "return_max_long_v14_mt":
from libs.backtest.scoring import compute_return_max_long_score_v14_mt
scoring_fn = compute_return_max_long_score_v14_mt
elif config.signal.scoring_model == "return_max_long_v14e":
from libs.backtest.scoring import compute_return_max_long_score_v14e
scoring_fn = compute_return_max_long_score_v14e
elif config.signal.scoring_model == "oversold_bounce":
from libs.backtest.scoring import compute_oversold_bounce_score
scoring_fn = compute_oversold_bounce_score
elif config.signal.scoring_model == "patient_drift":
from libs.backtest.scoring import compute_patient_drift_score
scoring_fn = compute_patient_drift_score
elif config.signal.scoring_model == "microstructure":
from libs.backtest.scoring import compute_microstructure_score
scoring_fn = compute_microstructure_score
return SnapshotStore.load(
snapshot_dir=snapshot_dir,
split_name=split_name,
oracle_url=s.stock_oracle_url,
db_dsn=s.postgres_dsn,
scoring_fn=scoring_fn,
)
def _build_split_result_from_metrics(run_id: str, metrics: MetricsBundle) -> SplitResult:
return SplitResult(
run_id=run_id,
trade_count=metrics.trade_count,
profit_factor=metrics.profit_factor,
total_return_pct=metrics.total_return_pct,
win_rate=metrics.win_rate,
max_drawdown_pct=metrics.max_drawdown_pct,
sharpe_ratio=metrics.sharpe_ratio,
monthly_win_rate=metrics.monthly_win_rate,
equity_curve_r_squared=metrics.equity_curve_r_squared,
avg_gross_exposure_pct=metrics.avg_gross_exposure_pct,
avg_net_exposure_pct=metrics.avg_net_exposure_pct,
days_in_market_pct=metrics.days_in_market_pct,
)
def _effective_profit_factor(result: SplitResult) -> float | None:
if result.profit_factor is not None:
return result.profit_factor
if result.trade_count > 0 and result.win_rate is not None and result.win_rate >= 0.999:
return 3.0
return None
def _build_walk_forward_aggregate(results: list[SplitResult]) -> WalkForwardAggregate:
returns = [r.total_return_pct for r in results if r.total_return_pct is not None]
profit_factors = [
pf for pf in (_effective_profit_factor(r) for r in results) if pf is not None
]
drawdowns = [r.max_drawdown_pct for r in results if r.max_drawdown_pct is not None]
positive_folds = [
r for r in results if r.total_return_pct is not None and r.total_return_pct > 0
]
trade_counts = [float(r.trade_count) for r in results]
win_rates = [r.win_rate for r in results if r.win_rate is not None]
return WalkForwardAggregate(
mean_return_pct=round(statistics.mean(returns), 2) if returns else None,
median_return_pct=round(statistics.median(returns), 2) if returns else None,
worst_return_pct=round(min(returns), 2) if returns else None,
positive_fold_rate_pct=round(len(positive_folds) / len(results) * 100.0, 1) if results else None,
mean_profit_factor=round(statistics.mean(profit_factors), 2) if profit_factors else None,
mean_max_drawdown_pct=round(statistics.mean(drawdowns), 2) if drawdowns else None,
mean_trade_count=round(statistics.mean(trade_counts), 1) if trade_counts else None,
mean_win_rate=round(statistics.mean(win_rates), 4) if win_rates else None,
)
def _build_walk_forward_gap_stats(
train_results: list[SplitResult],
test_results: list[SplitResult],
) -> WalkForwardGapStats:
gaps = [
(train.total_return_pct or 0.0) - (test.total_return_pct or 0.0)
for train, test in zip(train_results, test_results, strict=False)
if train.total_return_pct is not None and test.total_return_pct is not None
]
test_returns = [r.total_return_pct for r in test_results if r.total_return_pct is not None]
fold_return_cv = None
if len(test_returns) >= 2:
mean_ret = statistics.mean(test_returns)
if abs(mean_ret) > 1e-9:
fold_return_cv = round(statistics.stdev(test_returns) / abs(mean_ret), 3)
return WalkForwardGapStats(
mean_train_test_return_gap_pct=round(statistics.mean(gaps), 2) if gaps else None,
worst_train_test_return_gap_pct=round(max(gaps), 2) if gaps else None,
fold_return_cv=fold_return_cv,
)
def _build_merged_snapshot_store(
manifest: ExperimentManifest,
config: BacktestConfig,
snapshot_dir_override: str | None,
) -> SnapshotStore:
stores: list[SnapshotStore] = []
for split in ["train", "valid", "test"]:
try:
stores.append(_build_store(manifest, config, split, snapshot_dir_override=snapshot_dir_override))
except FileNotFoundError:
continue
if not stores:
raise FileNotFoundError("No snapshot splits found.")
merged_candidates: dict[dt.date, dict[tuple[Any, ...], dict[str, Any]]] = defaultdict(dict)
merged_bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
merged_macro: dict[dt.date, dict[str, Any]] = {}
for store in stores:
for exec_date in store.all_execution_dates():
for candidate in store.get_candidates_for_date(exec_date):
dedupe_key = (
candidate.get("event_id"),
candidate.get("symbol"),
candidate.get("execution_date"),
candidate.get("reaction_date"),
)
merged_candidates[exec_date].setdefault(dedupe_key, candidate)
for symbol, bars in store._bars.items():
merged_bars.setdefault(symbol, {}).update(bars)
for macro_date, macro_values in store._macro.items():
merged_macro.setdefault(macro_date, {}).update(macro_values)
return SnapshotStore(
candidates_by_exec_date={
date: list(rows.values())
for date, rows in merged_candidates.items()
},
bars_by_symbol_date=merged_bars,
macro_by_date=merged_macro,
)
def run_walk_forward(
manifest: ExperimentManifest,
config: BacktestConfig,
snapshot_dir_override: str | None,
initial_equity: float,
output_root: str,
train_days: int = 252,
test_days: int = 63,
step_days: int | None = None,
) -> WalkForwardSummary:
"""Run rolling walk-forward validation with explicit train/test folds."""
step_days = step_days or test_days
merged_store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override)
all_dates = merged_store.all_trading_days(include_reaction_dates=True)
if not all_dates:
raise RuntimeError("No trading days found in merged snapshot data for walk-forward run.")
windows = generate_walk_forward_windows(
all_dates,
train_days=train_days,
test_days=test_days,
step_days=step_days,
)
if not windows:
raise RuntimeError(
f"Not enough data for walk-forward windows (need {train_days + test_days} days, have {len(all_dates)})."
)
wf_root = Path(output_root) / "walk_forward"
wf_root.mkdir(parents=True, exist_ok=True)
fold_results: list[WalkForwardFoldResult] = []
train_split_results: list[SplitResult] = []
test_split_results: list[SplitResult] = []
for window in windows:
fold_dir = wf_root / f"fold_{window.window_index:02d}"
train_store = merged_store.slice_by_date_range(window.train_start, window.train_end)
test_store = merged_store.slice_by_date_range(window.test_start, window.test_end)
train_runner = BacktestRunner(
manifest=manifest,
config=config,
store=train_store,
initial_equity=initial_equity,
split_name=f"wf_train_{window.window_index:02d}",
)
train_result = train_runner.run(output_root=fold_dir / "train")
test_runner = BacktestRunner(
manifest=manifest,
config=config,
store=test_store,
initial_equity=initial_equity,
split_name=f"wf_test_{window.window_index:02d}",
)
test_result = test_runner.run(output_root=fold_dir / "test")
train_metrics = _build_split_result_from_metrics(train_result.run_id, train_result.metrics)
test_metrics = _build_split_result_from_metrics(test_result.run_id, test_result.metrics)
train_split_results.append(train_metrics)
test_split_results.append(test_metrics)
fold_results.append(
WalkForwardFoldResult(
fold_index=window.window_index,
train_start=window.train_start,
train_end=window.train_end,
test_start=window.test_start,
test_end=window.test_end,
train_run_id=train_result.run_id,
test_run_id=test_result.run_id,
train_metrics=train_metrics,
test_metrics=test_metrics,
)
)
print(
f"Fold {window.window_index:02d}: "
f"train {window.train_start}{window.train_end} "
f"| test {window.test_start}{window.test_end} "
f"| train_ret={train_result.metrics.total_return_pct or 0:.2f}% "
f"| test_ret={test_result.metrics.total_return_pct or 0:.2f}% "
f"| test_trades={test_result.metrics.trade_count}"
)
summary = WalkForwardSummary(
train_days=train_days,
test_days=test_days,
step_days=step_days,
fold_count=len(fold_results),
folds=fold_results,
train_aggregate=_build_walk_forward_aggregate(train_split_results),
test_aggregate=_build_walk_forward_aggregate(test_split_results),
gap_stats=_build_walk_forward_gap_stats(train_split_results, test_split_results),
)
summary_path = wf_root / "walk_forward_summary.json"
summary_path.write_text(summary.model_dump_json(indent=2))
print(f"\n--- Walk-Forward Summary ({summary.fold_count} folds) ---")
if summary.test_aggregate.mean_return_pct is not None:
print(f"Test mean return: {summary.test_aggregate.mean_return_pct:.2f}%")
if summary.test_aggregate.median_return_pct is not None:
print(f"Test median return: {summary.test_aggregate.median_return_pct:.2f}%")
if summary.test_aggregate.worst_return_pct is not None:
print(f"Test worst return: {summary.test_aggregate.worst_return_pct:.2f}%")
if summary.test_aggregate.positive_fold_rate_pct is not None:
print(f"Test positive fold rate: {summary.test_aggregate.positive_fold_rate_pct:.1f}%")
if summary.gap_stats.mean_train_test_return_gap_pct is not None:
print(f"Mean train-test gap: {summary.gap_stats.mean_train_test_return_gap_pct:.2f}%")
print(f"Summary written to: {summary_path}")
return summary
def run_robustness_matrix(
manifest: ExperimentManifest,
config: BacktestConfig,
snapshot_dir_override: str | None,
initial_equity: float,
output_root: str,
horizons_days: list[int],
step_days: int = 21,
) -> RobustnessMatrixSummary:
"""Run rolling horizon robustness validation over multiple start dates."""
merged_store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override)
all_dates = merged_store.all_trading_days(include_reaction_dates=True)
if not all_dates:
raise RuntimeError("No trading days found in merged snapshot data for robustness matrix.")
windows_by_horizon = generate_robustness_windows(
all_dates,
horizons_days=horizons_days,
step_days=step_days,
)
if not windows_by_horizon:
raise RuntimeError(
f"Not enough data for robustness windows (need {min(horizons_days)} days, have {len(all_dates)})."
)
rm_root = Path(output_root) / "robustness_matrix"
rm_root.mkdir(parents=True, exist_ok=True)
horizon_summaries: list[RobustnessHorizonSummary] = []
overall_results: list[SplitResult] = []
for horizon_days in sorted(windows_by_horizon):
window_results: list[SplitResult] = []
for window in windows_by_horizon[horizon_days]:
window_store = merged_store.slice_by_date_range(window.start, window.end)
runner = BacktestRunner(
manifest=manifest,
config=config,
store=window_store,
initial_equity=initial_equity,
split_name=f"rm_{horizon_days}_{window.window_index:02d}",
)
result = runner.run(output_root=None)
split_result = _build_split_result_from_metrics(result.run_id, result.metrics)
window_results.append(split_result)
overall_results.append(split_result)
aggregate = _build_walk_forward_aggregate(window_results)
horizon_summaries.append(
RobustnessHorizonSummary(
horizon_days=horizon_days,
window_count=len(window_results),
mean_return_pct=aggregate.mean_return_pct,
median_return_pct=aggregate.median_return_pct,
worst_return_pct=aggregate.worst_return_pct,
positive_window_rate_pct=aggregate.positive_fold_rate_pct,
mean_max_drawdown_pct=aggregate.mean_max_drawdown_pct,
)
)
overall_positive = [
result for result in overall_results
if result.total_return_pct is not None and result.total_return_pct > 0
]
overall_returns = [result.total_return_pct for result in overall_results if result.total_return_pct is not None]
summary = RobustnessMatrixSummary(
horizons_days=sorted(windows_by_horizon),
step_days=step_days,
overall_window_count=len(overall_results),
overall_positive_window_rate_pct=(
round(len(overall_positive) / len(overall_results) * 100.0, 1)
if overall_results else None
),
overall_worst_return_pct=round(min(overall_returns), 2) if overall_returns else None,
horizon_summaries=horizon_summaries,
)
summary_path = rm_root / "robustness_matrix_summary.json"
summary_path.write_text(summary.model_dump_json(indent=2))
print("\n--- Robustness Matrix Summary ---")
print(f"Horizons: {', '.join(str(h) for h in summary.horizons_days)}")
print(f"Window count: {summary.overall_window_count}")
if summary.overall_positive_window_rate_pct is not None:
print(f"Positive window rate: {summary.overall_positive_window_rate_pct:.1f}%")
if summary.overall_worst_return_pct is not None:
print(f"Worst window return: {summary.overall_worst_return_pct:.2f}%")
print(f"Summary written to: {summary_path}")
return summary
def main() -> None:
parser = argparse.ArgumentParser(description="ACE-F Backtester")
parser.add_argument("--manifest", required=True, help="Path to experiment manifest JSON")
parser.add_argument("--snapshot-id", help="Override dataset_snapshot_id")
parser.add_argument("--snapshot-dir", help="Override snapshot root directory (default: data/parquet/)")
parser.add_argument("--split", default="train", help="Split name (train/valid/test)")
parser.add_argument("--output-root", default="./runs", help="Output root directory")
parser.add_argument("--initial-equity", type=float, default=100_000.0)
parser.add_argument("--config-root", default=".", help="Root dir for resolving config paths")
parser.add_argument(
"--walk-forward",
action="store_true",
help="Run walk-forward cross-validation instead of single backtest",
)
parser.add_argument("--wf-train-days", type=int, default=252, help="Walk-forward train window (trading days)")
parser.add_argument("--wf-test-days", type=int, default=63, help="Walk-forward test window (trading days)")
parser.add_argument("--wf-step-days", type=int, default=None, help="Walk-forward step size (default: wf-test-days)")
parser.add_argument(
"--robustness-matrix",
action="store_true",
help="Run rolling horizon robustness matrix instead of single backtest",
)
parser.add_argument(
"--rm-horizons",
default="21,63,126,252,504",
help="Comma-separated robustness horizons in trading days",
)
parser.add_argument("--rm-step-days", type=int, default=21, help="Robustness matrix step size (trading days)")
parser.add_argument("--mode", choices=["research", "live"], default=None,
help="Backtest mode: research (kill switch resets) or live (permanent)")
args = parser.parse_args()
manifest = load_manifest(args.manifest)
config = resolve_config(manifest, config_root=args.config_root, snapshot_id_override=args.snapshot_id)
if args.mode:
config.risk.backtest_mode = args.mode
if args.walk_forward and args.robustness_matrix:
raise SystemExit("Use either --walk-forward or --robustness-matrix, not both.")
if args.walk_forward:
run_walk_forward(
manifest=manifest,
config=config,
snapshot_dir_override=args.snapshot_dir,
initial_equity=args.initial_equity,
output_root=args.output_root,
train_days=args.wf_train_days,
test_days=args.wf_test_days,
step_days=args.wf_step_days,
)
elif args.robustness_matrix:
horizons = [int(part.strip()) for part in args.rm_horizons.split(",") if part.strip()]
run_robustness_matrix(
manifest=manifest,
config=config,
snapshot_dir_override=args.snapshot_dir,
initial_equity=args.initial_equity,
output_root=args.output_root,
horizons_days=horizons,
step_days=args.rm_step_days,
)
else:
store = _build_store(manifest, config, args.split, snapshot_dir_override=args.snapshot_dir)
runner = BacktestRunner(
manifest=manifest,
config=config,
store=store,
initial_equity=args.initial_equity,
split_name=args.split,
)
result = runner.run(output_root=args.output_root)
print(f"Run complete: {result.run_id}")
print(f"Trades: {result.metrics.trade_count}")
if result.metrics.total_return_pct is not None:
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
# Print SQS score
from libs.backtest.tracker import compute_sqs
sqs_score, sqs_breakdown = compute_sqs(result.metrics)
print(f"SQS: {sqs_score} ({', '.join(f'{k}={v}' for k, v in sqs_breakdown.items())})")
if __name__ == "__main__":
main()