|
|
"""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,
|
|
|
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._attention_cache: dict[tuple[str, dt.date], EventAttentionResponse | None] = {}
|
|
|
self._attention_base_url: str | None = None
|
|
|
self._attention_session: requests.Session | None = None
|
|
|
if any(self._engine_requires_attention(engine) for engine in self.config.get_strategy_engines()):
|
|
|
from libs.common.config import get_settings
|
|
|
|
|
|
settings = get_settings()
|
|
|
self._attention_base_url = settings.stock_oracle_url.rstrip("/")
|
|
|
session = requests.Session()
|
|
|
session.headers.update({"User-Agent": "fithia2-backtester/1.0"})
|
|
|
self._attention_session = 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
|
|
|
|
|
|
# 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
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
# --- 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)
|
|
|
|
|
|
# Store scored candidates for delayed entry lookback
|
|
|
if candidates:
|
|
|
self._recent_scored_candidates[date] = list(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)
|
|
|
|
|
|
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)
|
|
|
|
|
|
# --- Record daily equity curve snapshot ---
|
|
|
market_value_final = self._compute_positions_market_value(date)
|
|
|
unrealized_final = market_value_final - sum(
|
|
|
p.entry_price * p.shares_open for p in self._open_positions
|
|
|
)
|
|
|
self._equity = self._cash + market_value_final
|
|
|
self._peak_equity = max(self._peak_equity, self._equity)
|
|
|
final_drawdown = (
|
|
|
(self._peak_equity - self._equity) / self._peak_equity * 100.0
|
|
|
if self._peak_equity > 0
|
|
|
else 0.0
|
|
|
)
|
|
|
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_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 _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_requires_attention(self, engine: Any) -> bool:
|
|
|
if (
|
|
|
self.config.signal.scoring_model 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_v9", "return_max_long_v9g", "return_max_long_v10"}
|
|
|
and engine.direction != "short_only"
|
|
|
):
|
|
|
return True
|
|
|
return any(
|
|
|
value is not None
|
|
|
for value in (
|
|
|
engine.attention_min_wiki_spike_10d,
|
|
|
engine.attention_min_wiki_zscore_20d,
|
|
|
engine.attention_max_wiki_spike_10d,
|
|
|
engine.attention_max_wiki_zscore_20d,
|
|
|
engine.attention_min_article_count_3d,
|
|
|
engine.attention_min_us_article_count_3d,
|
|
|
engine.attention_min_resolver_confidence,
|
|
|
)
|
|
|
)
|
|
|
|
|
|
def _engine_requires_attention_data(self, engine: Any) -> bool:
|
|
|
"""Return True when the engine has at least one minimum-style gate.
|
|
|
|
|
|
Minimum gates require an actual attention payload to validate, while
|
|
|
maximum-only caps can treat missing data as "no veto".
|
|
|
"""
|
|
|
return any(
|
|
|
value is not None
|
|
|
for value in (
|
|
|
engine.attention_min_wiki_spike_10d,
|
|
|
engine.attention_min_wiki_zscore_20d,
|
|
|
engine.attention_min_article_count_3d,
|
|
|
engine.attention_min_us_article_count_3d,
|
|
|
engine.attention_min_resolver_confidence,
|
|
|
)
|
|
|
)
|
|
|
|
|
|
def _apply_attention_filters(
|
|
|
self,
|
|
|
candidates: list[Candidate],
|
|
|
engine: Any,
|
|
|
) -> list[Candidate]:
|
|
|
if not candidates or not self._engine_requires_attention(engine):
|
|
|
return candidates[: self.config.signal.max_candidates_per_day]
|
|
|
|
|
|
filtered: list[Candidate] = []
|
|
|
requires_data = self._engine_requires_attention_data(engine)
|
|
|
threshold = (
|
|
|
engine.score_threshold_override
|
|
|
if engine.score_threshold_override is not None
|
|
|
else self.config.signal.score_threshold
|
|
|
)
|
|
|
for candidate in candidates:
|
|
|
attention = self._get_event_attention(candidate)
|
|
|
if attention is None:
|
|
|
if not requires_data:
|
|
|
filtered.append(candidate)
|
|
|
continue
|
|
|
if not self._passes_attention_filters(engine, attention):
|
|
|
continue
|
|
|
enriched = self._attach_attention_features(candidate, attention)
|
|
|
enriched = self._maybe_rescore_with_attention(enriched)
|
|
|
if enriched.score >= threshold:
|
|
|
filtered.append(enriched)
|
|
|
|
|
|
filtered = rank_candidates(filtered, self.config.signal.ranking_fields)
|
|
|
return filtered[: self.config.signal.max_candidates_per_day]
|
|
|
|
|
|
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 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,
|
|
|
)
|
|
|
|
|
|
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_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)
|
|
|
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."""
|
|
|
execution_updates: dict[str, Any] = {}
|
|
|
|
|
|
max_holding_days = candidate.engine_max_holding_days
|
|
|
if max_holding_days is None:
|
|
|
evt_profile = self.config.get_event_profile(candidate.event_type)
|
|
|
if evt_profile and evt_profile.max_holding_days_override is not None:
|
|
|
max_holding_days = evt_profile.max_holding_days_override
|
|
|
if max_holding_days is not None:
|
|
|
execution_updates["max_holding_days"] = max_holding_days
|
|
|
|
|
|
if candidate.engine_target_atr_multiplier is not None:
|
|
|
execution_updates["target_atr_multiplier"] = candidate.engine_target_atr_multiplier
|
|
|
if candidate.engine_trailing_model is not None:
|
|
|
execution_updates["trailing_model"] = candidate.engine_trailing_model
|
|
|
if candidate.engine_trailing_warmup_days is not None:
|
|
|
execution_updates["trailing_warmup_days"] = candidate.engine_trailing_warmup_days
|
|
|
if candidate.engine_early_failure_close_below_entry_and_reaction_close is not None:
|
|
|
execution_updates["early_failure_close_below_entry_and_reaction_close"] = (
|
|
|
candidate.engine_early_failure_close_below_entry_and_reaction_close
|
|
|
)
|
|
|
if candidate.engine_early_failure_no_progress_days is not None:
|
|
|
execution_updates["early_failure_no_progress_days"] = (
|
|
|
candidate.engine_early_failure_no_progress_days
|
|
|
)
|
|
|
if candidate.engine_early_failure_no_progress_r is not None:
|
|
|
execution_updates["early_failure_no_progress_r"] = (
|
|
|
candidate.engine_early_failure_no_progress_r
|
|
|
)
|
|
|
if candidate.engine_early_failure_no_progress_fraction is not None:
|
|
|
execution_updates["early_failure_no_progress_fraction"] = (
|
|
|
candidate.engine_early_failure_no_progress_fraction
|
|
|
)
|
|
|
if self.config.execution.use_tiered_targets and self.config.signal.a_tier_score_threshold is not None:
|
|
|
if candidate.score >= self.config.signal.a_tier_score_threshold:
|
|
|
if self.config.execution.a_tier_target_1_r is not None:
|
|
|
execution_updates["target_1_r"] = self.config.execution.a_tier_target_1_r
|
|
|
if self.config.execution.a_tier_target_1_fraction is not None:
|
|
|
execution_updates["target_1_fraction"] = self.config.execution.a_tier_target_1_fraction
|
|
|
else:
|
|
|
if self.config.execution.non_a_tier_target_1_r is not None:
|
|
|
execution_updates["target_1_r"] = self.config.execution.non_a_tier_target_1_r
|
|
|
if self.config.execution.non_a_tier_target_1_fraction is not None:
|
|
|
execution_updates["target_1_fraction"] = self.config.execution.non_a_tier_target_1_fraction
|
|
|
if candidate.engine_target_1_r is not None:
|
|
|
execution_updates["target_1_r"] = candidate.engine_target_1_r
|
|
|
if candidate.engine_target_1_fraction is not None:
|
|
|
execution_updates["target_1_fraction"] = candidate.engine_target_1_fraction
|
|
|
|
|
|
# Adaptive exit: adjust trailing warmup based on close_location zone.
|
|
|
# Only overrides trailing_warmup_days (not max_holding_days) to avoid
|
|
|
# cutting profitable drift trades short.
|
|
|
exec_cfg = self.config.execution
|
|
|
if exec_cfg.adaptive_exit_enabled:
|
|
|
cl = candidate.features.get("close_location")
|
|
|
if cl is not None:
|
|
|
try:
|
|
|
cl_val = float(cl)
|
|
|
except (TypeError, ValueError):
|
|
|
cl_val = None
|
|
|
if cl_val is not None:
|
|
|
if cl_val >= exec_cfg.adaptive_exit_exhaustion_close_min:
|
|
|
execution_updates["trailing_warmup_days"] = exec_cfg.adaptive_exit_exhaustion_trailing_warmup
|
|
|
elif exec_cfg.adaptive_exit_orderly_close_min <= cl_val <= exec_cfg.adaptive_exit_orderly_close_max:
|
|
|
execution_updates["trailing_warmup_days"] = exec_cfg.adaptive_exit_orderly_trailing_warmup
|
|
|
|
|
|
if not execution_updates:
|
|
|
return self.config.execution
|
|
|
return self.config.execution.model_copy(update=execution_updates)
|
|
|
|
|
|
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:
|
|
|
allowed_victims = 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
|
|
|
|
|
|
eligible: list[tuple[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 victim_candidate.entry_timing_policy != candidate.entry_timing_policy:
|
|
|
continue
|
|
|
if 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
|
|
|
|
|
|
proceeds = close_value * position.shares_open
|
|
|
if proceeds < shortfall:
|
|
|
continue
|
|
|
|
|
|
eligible.append((victim_candidate.score, proceeds, -position.days_held, position))
|
|
|
|
|
|
if not eligible:
|
|
|
return None
|
|
|
|
|
|
eligible.sort(key=lambda item: (item[0], item[1], item[2]))
|
|
|
return eligible[0][3]
|
|
|
|
|
|
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 _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 == "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()
|