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.

9621 lines
438 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
import time
from bisect import bisect_right
from collections import Counter, 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.dividend_calendar import load_pit_dividend_calendar
from libs.backtest.earnings_calendar import (
OraclePointInTimeEarningsCalendar,
load_pit_earnings_calendar,
)
from libs.backtest.earnings_runup import (
EARNINGS_RUNUP_EVENT_TYPE,
AttentionZscoreProvider,
_PitCalendarUpcomingEarningsAdapter,
_SnapshotStoreBarAdapter,
build_earnings_runup_candidates,
)
from libs.backtest.peer_sympathy import (
PEER_SYMPATHY_EVENT_TYPE,
LeaderPrint,
build_peer_sympathy_candidates,
)
from libs.backtest.vol_breakout_52w import (
VOL_BREAKOUT_52W_EVENT_TYPE,
_SnapshotStoreBarAdapter as _VolBreakout52wBarAdapter,
build_candidates as build_vol_breakout_52w_candidates,
)
from libs.backtest.form4_calendar import load_pit_form4_calendar
from libs.backtest.ownership_calendar import load_pit_ownership_calendar
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.proxies import peer_candidates_for_symbol
from libs.backtest.metrics import build_metrics_bundle
from libs.backtest.non_core_allocator import (
classify_non_core_overlap_class,
classify_parking_class,
compute_marginal_score,
compute_native_rank_pct,
compute_overlap_penalty,
normalize_hold_days_est,
normalize_liquidity_penalty,
normalize_parking_proxy,
)
from libs.backtest.selector import rank_candidates, select_candidates
from libs.backtest.snapshots import resolve_snapshot_path
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
_DIVIDEND_CAPTURE_ENGINE_ID = "idle_dividend_capture"
_DIVIDEND_CAPTURE_EVENT_TYPE = "dividend_capture"
_FORM4_CAPTURE_ENGINE_ID = "idle_form4_capture"
_FORM4_CAPTURE_EVENT_TYPE = "form4_capture"
_OWNERSHIP_CAPTURE_ENGINE_ID = "idle_ownership_13d_capture"
_OWNERSHIP_CAPTURE_EVENT_TYPE = "ownership_13d_capture"
_RISK_OFF_ALPHA_ENGINE_ID = "idle_risk_off_alpha"
_RISK_OFF_ALPHA_EVENT_TYPE = "risk_off_alpha"
_OWNERSHIP_RUNTIME_HOUSEKEEPING_PHRASES = (
"continued to hold",
"shareholding percentage",
"shareholding percent",
"no amendment to this item",
"change in the number of outstanding",
"number of outstanding shares",
"resulted solely from",
"solely as a result of",
"solely due to",
)
_OWNERSHIP_RUNTIME_STRUCTURAL_PHRASES = (
"exchange agreement",
"in connection with the reorganization",
)
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 _OracleSurprisePrefetchedEarningsAdapter:
"""In-memory PIT upcoming-earnings provider built from Oracle's
``/api/v1/earnings/surprise/{symbol}`` endpoint.
The earnings_runup builder calls ``get_next_reaction_date`` once per
universe symbol per day. Per-symbol-per-day HTTP calls are infeasible
(>1M calls), and Oracle's bulk calendar endpoint times out under load
for 900+ symbols × 1000+ days. This adapter takes the cheaper path:
* One ``earnings/surprise`` HTTP call per universe symbol at construction
(~900 calls total, parallelized with a thread pool).
* Returned ``reported_date`` values are folded into an in-memory
``PointInTimeEarningsCalendar`` whose ``as_of_date`` for each report
is set to ``reported_date - announcement_window_days`` (default 30)
to model the public-announcement lead time.
* After construction, every ``get_next_reaction_date`` call is a pure
bisect-and-dict-lookup; no further network traffic.
PIT integrity: the announcement-window heuristic is conservative
(companies typically announce 4-6 weeks ahead), and the runup engine
only ever queries 3-7 trading days before the event so the entry is
well within the announcement window. The reaction-date offset reuses
``compute_reaction_date`` with ``post_market`` (the most common bucket
and the Oracle bulk endpoint's default normalization) since the
surprise endpoint omits the time-of-day field.
Raises ``RuntimeError`` if the Oracle base URL is missing (config
error) or if EVERY prefetch call fails (data unavailable). Per-symbol
failures are logged and treated as empty (the symbol is simply absent
from the calendar).
"""
_DEFAULT_QUARTERS = 24
_DEFAULT_ANNOUNCEMENT_LEAD_DAYS = 30
_DEFAULT_REACTION_TIME_BUCKET = "post_market"
# Local Oracle proxy returns 502 under bursty parallel load. 4 is the
# empirically-stable upper bound; we add per-symbol retry on top.
_DEFAULT_PARALLELISM = 4
_RETRY_HTTP_STATUSES: tuple[int, ...] = (502, 503, 504)
_RETRY_MAX_ATTEMPTS = 3
_RETRY_BACKOFF_SECONDS = 0.5
def __init__(
self,
oracle_url: str,
trading_days: list[dt.date],
universe_symbols: list[str],
timeout: float = 30.0,
quarters: int | None = None,
announcement_lead_days: int | None = None,
parallelism: int | None = None,
) -> None:
if not oracle_url:
raise RuntimeError(
"_OracleSurprisePrefetchedEarningsAdapter requires non-empty oracle_url"
)
self._base_url = oracle_url.rstrip("/")
self._timeout = float(timeout)
self.trading_days = list(trading_days)
self._universe = sorted({s.strip().upper() for s in universe_symbols if s and str(s).strip()})
self._quarters = int(quarters or self._DEFAULT_QUARTERS)
self._announcement_lead_days = int(
announcement_lead_days or self._DEFAULT_ANNOUNCEMENT_LEAD_DAYS
)
self._parallelism = max(1, int(parallelism or self._DEFAULT_PARALLELISM))
self._calendar: Any = None # PointInTimeEarningsCalendar, built lazily
self._build_stats: dict[str, int] = {}
def _build_calendar(self) -> None:
import concurrent.futures as _cf
from libs.backtest.earnings_calendar import (
EarningsCalendarEntry,
PointInTimeEarningsCalendar,
)
from libs.labeler.reaction_date import compute_reaction_date as _compute_reaction_date
session = requests.Session()
endpoint = f"{self._base_url}/api/v1/earnings/surprise"
quarters_param = self._quarters
# Bound entries to the simulation window (with announcement-lead margin)
# so we don't try to compute reaction dates for very old reports that
# fall outside the exchange calendar's coverage.
if self.trading_days:
window_start_date = self.trading_days[0] - dt.timedelta(
days=self._announcement_lead_days + 90
)
window_end_date = self.trading_days[-1] + dt.timedelta(days=400)
else:
window_start_date = dt.date(2007, 1, 1)
window_end_date = dt.date(2099, 1, 1)
def fetch_one(symbol: str) -> tuple[str, list[dict[str, Any]]]:
last_err: Any = None
for attempt in range(self._RETRY_MAX_ATTEMPTS):
try:
resp = session.get(
f"{endpoint}/{symbol}",
params={"quarters": quarters_param},
timeout=self._timeout,
)
if resp.status_code in self._RETRY_HTTP_STATUSES:
last_err = f"http_{resp.status_code}"
time.sleep(self._RETRY_BACKOFF_SECONDS * (attempt + 1))
continue
resp.raise_for_status()
body = resp.json()
return symbol, body.get("quarters", [])
except Exception as exc: # noqa: BLE001
last_err = exc
time.sleep(self._RETRY_BACKOFF_SECONDS * (attempt + 1))
logger.debug(
"earnings_runup_surprise_fetch_failed",
symbol=symbol,
error=str(last_err),
attempts=self._RETRY_MAX_ATTEMPTS,
)
return symbol, []
entries: list[EarningsCalendarEntry] = []
successes = 0
empty_responses = 0
failures = 0
start_ts = time.time()
with _cf.ThreadPoolExecutor(max_workers=self._parallelism) as pool:
futures = {pool.submit(fetch_one, sym): sym for sym in self._universe}
for fut in _cf.as_completed(futures):
sym, quarters = fut.result()
if not quarters:
if quarters == []:
# We can't distinguish "API failure" from "no history"
# at this layer; both manifested as []. The fetch_one
# logger call already differentiates; here we just count.
empty_responses += 1
continue
successes += 1
for q in quarters:
rd_str = q.get("reported_date") or q.get("fiscal_date_ending")
if not rd_str:
continue
try:
reported_date = dt.date.fromisoformat(str(rd_str)[:10])
except ValueError:
continue
# Skip dates outside the simulation window (with margin)
# to avoid exchange-calendar OutOfBounds errors and to
# keep the in-memory entries set small.
if reported_date < window_start_date:
continue
if reported_date > window_end_date:
continue
try:
reaction_date = _compute_reaction_date(
reported_date, self._DEFAULT_REACTION_TIME_BUCKET
)
except Exception: # noqa: BLE001
continue
if reaction_date is None:
continue
announce_date = reported_date - dt.timedelta(
days=self._announcement_lead_days
)
entries.append(
EarningsCalendarEntry(
symbol=sym.upper(),
as_of_date=announce_date,
expected_reaction_date=reaction_date,
expected_event_date=reported_date,
filing_time_bucket=self._DEFAULT_REACTION_TIME_BUCKET,
source="oracle.earnings.surprise",
)
)
elapsed = time.time() - start_ts
if successes == 0 and empty_responses == 0:
raise RuntimeError(
"earnings_runup PIT prefetch produced 0 entries: every Oracle "
"earnings/surprise call failed. Check Oracle reachability."
)
self._calendar = PointInTimeEarningsCalendar(entries)
self._build_stats = {
"universe_size": len(self._universe),
"symbols_with_history": successes,
"symbols_empty_or_failed": empty_responses,
"entries": len(entries),
"elapsed_seconds": int(elapsed),
}
logger.info(
"earnings_runup_pit_prefetch_complete",
**self._build_stats,
announcement_lead_days=self._announcement_lead_days,
quarters=self._quarters,
parallelism=self._parallelism,
)
def get_next_reaction_date(
self,
symbol: str,
as_of_date: dt.date,
max_lookahead_calendar_days: int,
) -> dt.date | None:
if self._calendar is None:
self._build_calendar()
try:
idx = self.trading_days.index(as_of_date)
except ValueError:
return None
cutoff = as_of_date + dt.timedelta(days=max_lookahead_calendar_days)
allowed = [d for d in self.trading_days[idx + 1:] if d <= cutoff]
if not allowed:
return None
result = self._calendar.get_known_upcoming_reaction_dates(
as_of_date=as_of_date,
allowed_reaction_dates=allowed,
symbols=[symbol],
)
return result.get(str(symbol).upper())
class _BacktestAttentionZscoreAdapter:
"""Bridge AttentionFilterService to the earnings_runup AttentionZscoreProvider Protocol.
Calls Stock Oracle's per-event attention endpoint using ``as_of_date`` (T-1) as
the event date; the returned wiki.zscore_20d is computed over a 20-day baseline
that ends strictly before ``as_of_date`` per Oracle convention.
Includes a per-symbol "no wiki coverage" cache: if Oracle returns a payload
with ``wiki_title=None`` (entity not resolvable to Wikipedia), we mark the
symbol unresolvable and short-circuit subsequent date queries. This is
essential for an earnings_runup backtest with a 900+ symbol universe — most
mid-cap tickers lack wiki coverage and re-querying them on every decision
date would dominate runtime.
"""
def __init__(self, attention_service: Any) -> None:
self._service = attention_service
# Symbols whose Oracle attention payload reports wiki_title=None on
# any sample date. Z-scores are wiki-derived, so absence of wiki
# coverage means zscore_20d is permanently None.
self._no_wiki_symbols: set[str] = set()
# Diagnostic counters for post-run analysis.
self._fetch_null_count: int = 0
self._fetch_total_count: int = 0
self._short_circuit_count: int = 0
def get_zscore_20d(self, symbol: str, as_of_date: dt.date) -> float | None:
sym = symbol.upper()
if sym in self._no_wiki_symbols:
self._short_circuit_count += 1
return None
# Manually invoke the cached fetch path used by AttentionFilterService.
from libs.backtest.domain import Candidate as _Candidate
try:
probe = _Candidate(
event_id=f"_runup_probe_{sym}_{as_of_date.isoformat()}",
symbol=sym,
score=0.0,
sector="UNKNOWN",
event_type="earnings_runup_probe",
event_timestamp=dt.datetime.combine(as_of_date, dt.time(16, 0), tzinfo=dt.timezone.utc),
event_date=as_of_date,
filing_time_bucket="post_market",
reaction_date=as_of_date,
execution_date=as_of_date,
entry_price_est=0.01,
avg_dollar_volume=0.0,
score_bucket="medium",
)
except Exception: # noqa: BLE001
return None
self._fetch_total_count += 1
attention = self._service._get_event_attention(probe)
if attention is None:
self._fetch_null_count += 1
return None
wiki_title = (
getattr(getattr(attention, "entity", None), "wiki_title", None)
or (getattr(attention, "metadata", {}) or {}).get("wiki_title")
)
if not wiki_title:
# No wiki coverage for this symbol → permanently no z-score.
self._no_wiki_symbols.add(sym)
self._fetch_null_count += 1
return None
zscore = getattr(attention.wiki, "zscore_20d", None)
if zscore is None:
self._fetch_null_count += 1
return zscore
class _RunnerPeerResolver:
"""Adapt BacktestRunner state to peer_sympathy.PeerResolver.
Reuses the existing leader-follower peer-set wiring on the runner so the
PeerSympathy engine reads the SAME peer universe as the leader-follower
engine (curated per-leader peers, sector ETF holdings, optional allowlist).
"""
def __init__(self, runner: "BacktestRunner") -> None:
self._runner = runner
def peers_for_leader(
self,
engine: "StrategyEngineConfig",
leader_symbol: str,
leader_sector: str,
) -> list[str]:
return self._runner._leader_follower_peer_candidates(
engine, leader_symbol, leader_sector
)
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()
self._primary_strategy_engines = [
engine for engine in self._active_strategy_engines
if not getattr(engine, "post_allocation_idle_only", False)
]
self._post_allocation_idle_engines = [
engine for engine in self._active_strategy_engines
if getattr(engine, "post_allocation_idle_only", False)
]
self._strategy_engine_lookup = {
engine.engine_id: self.config.resolve_strategy_engine(engine)
for engine in self.config.strategy_engines
}
self._capital_bucket_specs: dict[str, float] = {}
for engine in self._active_strategy_engines:
allocation = getattr(engine, "capital_bucket_allocation_pct", None)
if allocation is None:
continue
bucket_id = getattr(engine, "capital_bucket_id", None) or engine.engine_id
self._capital_bucket_specs[bucket_id] = max(
self._capital_bucket_specs.get(bucket_id, 0.0),
float(allocation),
)
from libs.backtest.attention import AttentionFilterService
from libs.common.config import get_settings
settings = get_settings()
pit_calendar_path = config.earnings_calendar_pit_path or str(settings.earnings_calendar_pit_path)
self._pit_earnings_calendar = load_pit_earnings_calendar(pit_calendar_path)
dividend_calendar_path = self.config.dividend_capture.pit_calendar_path
self._pit_dividend_calendar = (
load_pit_dividend_calendar(dividend_calendar_path)
if dividend_calendar_path
else None
)
form4_events_path = self.config.form4_capture.pit_events_path
self._pit_form4_calendar = (
load_pit_form4_calendar(form4_events_path)
if form4_events_path
else None
)
ownership_events_path = self.config.ownership_capture.pit_events_path
self._pit_ownership_calendar = (
load_pit_ownership_calendar(ownership_events_path)
if ownership_events_path
else None
)
self._oracle_pit_earnings_calendar = OraclePointInTimeEarningsCalendar(
settings.stock_oracle_url,
timeout=float(settings.stock_oracle_timeout),
)
self._attention_service = AttentionFilterService(
oracle_url=settings.stock_oracle_url,
scoring_model=config.signal.scoring_model,
timeout=float(settings.stock_oracle_timeout),
)
# 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
self._primary_candidate_slate_stats: dict[dt.date, dict[str, int]] = {}
# 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
self._parking_gate_in_sgov: bool = False # state for hysteresis/recovery gates
self._parking_sgov_value: float = 0.0 # parallel SGOV for proportional mode
self._parking_entry_date: dt.date | None = None # when parking was bought
self._parking_trade_counter: int = 0
self._parking_peak_price: float = 0.0 # highest close since parking entry
self._parking_stopped_out: bool = False # waiting for recovery after stop
self._parking_trend_sgov: bool = False # momentum negative, waiting for re-entry threshold
self._parking_sgov_entry_value: float = 0.0 # original SGOV investment (before interest)
self._parking_sgov_last_price: float = 0.0
self._parking_sgov_mark_date: dt.date | None = None
self._parking_sold_today: bool = False # prevent same-day re-buy (day trading)
self._parking_freed_for_cash_today: bool = False # avoid same-day re-parking after cash-use liquidation
self._parking_target_cache_date: dt.date | None = None
self._parking_target_cache_value: str | None = None
self._parking_target_cache_valid: bool = False
self._parking_committed_target: str | None = None
self._parking_pending_target: str | None = None
self._parking_pending_target_days: int = 0
# Overlay shock brake / dwell cap state
self._parking_overlay_brake_cooldown: int = 0 # remaining cooldown days after brake
self._parking_overlay_hold_days: int = 0 # consecutive days holding overlay symbol
# Blend mode state (QQQM + TQQQ dual position)
self._parking_blend_tqqq_shares: int = 0
self._parking_blend_tqqq_avg_price: float = 0.0
self._parking_blend_qqqm_shares: int = 0
self._parking_blend_qqqm_avg_price: float = 0.0
# 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._last_simulation_date: dt.date | None = None
self._simulation_date_index: dict[dt.date, int] = {}
self._next_trading_day: dict[dt.date, dt.date] = {}
self._dividend_capture_trade_counter: int = 0
self._form4_capture_trade_counter: int = 0
self._ownership_capture_trade_counter: int = 0
self._lookback_entry_enabled: bool = config.execution.lookback_entry_enabled
self._lookback_injected: bool = False
self._non_core_allocator_shadow_rows: list[dict[str, Any]] = []
self._non_core_allocator_shadow_row_indices_by_date: dict[dt.date, list[int]] = defaultdict(list)
def _get_known_upcoming_earnings_by_symbol(
self,
as_of_date: dt.date,
allowed_reaction_dates: list[dt.date],
calendar_mode: str = "future_row",
symbols: list[str] | None = None,
) -> dict[str, dt.date]:
if calendar_mode in {"pit_calendar", "pit_then_fallback"}:
pit_matches: dict[str, dt.date] = {}
if self._pit_earnings_calendar is not None:
pit_matches = self._pit_earnings_calendar.get_known_upcoming_reaction_dates(
as_of_date=as_of_date,
allowed_reaction_dates=allowed_reaction_dates,
symbols=symbols,
)
if not pit_matches:
pit_matches = self._oracle_pit_earnings_calendar.get_known_upcoming_reaction_dates(
as_of_date=as_of_date,
allowed_reaction_dates=allowed_reaction_dates,
symbols=symbols,
)
if calendar_mode == "pit_calendar" or pit_matches:
return pit_matches
upcoming_earnings_by_symbol: dict[str, dt.date] = {}
symbol_filter = {
str(symbol).strip().upper()
for symbol in (symbols or [])
if str(symbol).strip()
}
for future_date in allowed_reaction_dates:
for row in self.store.get_candidates_for_reaction_date(future_date):
symbol = str(row.get("symbol") or "").upper()
if not symbol or symbol in upcoming_earnings_by_symbol:
continue
if symbol_filter and symbol not in symbol_filter:
continue
if str(row.get("event_type") or "") != "earnings_release":
continue
upcoming_earnings_by_symbol[symbol] = future_date
return upcoming_earnings_by_symbol
@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 _sleeve_equity_est(self, date: dt.date) -> float:
"""Equity estimate for sleeve budget calculations.
When ``fixed_capital_sizing`` is enabled, returns ``initial_equity`` so
that sleeve allocations (parking, form4, ownership, risk-off, idle-alpha)
stay proportional to the starting capital rather than compounding with
portfolio growth.
"""
if self._fixed_capital_sizing:
return float(self.initial_equity)
market_value = self._compute_positions_market_value(date)
return self._cash + market_value + self._get_parking_value(date)
def _resolve_close_price(self, symbol: str, date: dt.date, fallback: float) -> float:
"""Best-effort close price: today's bar → latest prior bar → entry price."""
bar = self.store.get_bar(symbol, date)
if bar and bar.get("close") is not None and float(bar["close"]) > 0:
return float(bar["close"])
latest = self.store.get_latest_bar_on_or_before(symbol, date)
if latest is not None:
_, prev_bar = latest
if prev_bar.get("close") is not None and float(prev_bar["close"]) > 0:
return float(prev_bar["close"])
return fallback
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:
close = self._resolve_close_price(
pos.plan.candidate.symbol, date, 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 _get_candidate_capital_bucket_id(self, candidate: Candidate) -> str | None:
return candidate.engine_capital_bucket_id
def _get_candidate_capital_bucket_allocation_pct(self, candidate: Candidate) -> float | None:
allocation = candidate.engine_capital_bucket_allocation_pct
if allocation is None:
return None
return float(allocation)
def _capital_bucket_notional(self, bucket_id: str, date: dt.date) -> float:
notional = 0.0
for position in self._open_positions:
pos_bucket = self._get_candidate_capital_bucket_id(position.plan.candidate)
if pos_bucket != bucket_id:
continue
bar = self.store.get_bar(position.plan.candidate.symbol, date)
price = (
float(bar["close"])
if bar and bar.get("close") is not None and float(bar["close"]) > 0
else position.entry_price
)
notional += abs(price * position.shares_open)
return notional
def _capital_bucket_entry_cost(self, bucket_id: str) -> float:
entry_cost = 0.0
for position in self._open_positions:
pos_bucket = self._get_candidate_capital_bucket_id(position.plan.candidate)
if pos_bucket != bucket_id:
continue
entry_cost += abs(position.entry_price * position.shares_open)
return entry_cost
def _capital_bucket_realized_pnl(self, bucket_id: str) -> float:
realized = 0.0
for trade in self._closed_trades:
candidate = self._candidate_map.get(trade.trade_id)
if candidate is None:
continue
if self._get_candidate_capital_bucket_id(candidate) != bucket_id:
continue
realized += float(trade.net_pnl)
return realized
def _capital_bucket_equity(self, bucket_id: str, date: dt.date) -> float:
allocation = self._capital_bucket_specs.get(bucket_id)
if allocation is None:
return 0.0
initial_bucket_equity = self.initial_equity * allocation
market_value = self._capital_bucket_notional(bucket_id, date)
entry_cost = self._capital_bucket_entry_cost(bucket_id)
unrealized = market_value - entry_cost
return max(
0.0,
initial_bucket_equity + self._capital_bucket_realized_pnl(bucket_id) + unrealized,
)
def _capital_bucket_cash_available(self, bucket_id: str, date: dt.date) -> float:
market_value = self._capital_bucket_notional(bucket_id, date)
return max(0.0, self._capital_bucket_equity(bucket_id, date) - market_value)
def _active_capital_bucket_ids_for_candidates(self, candidates: list[Candidate]) -> set[str]:
active_bucket_ids = {
bucket_id
for bucket_id in (
self._get_candidate_capital_bucket_id(candidate)
for candidate in candidates
)
if bucket_id
}
for position in self._open_positions:
bucket_id = self._get_candidate_capital_bucket_id(position.plan.candidate)
if bucket_id:
active_bucket_ids.add(bucket_id)
return active_bucket_ids
def _adjust_portfolio_state_for_candidate(
self,
*,
date: dt.date,
candidate: Candidate,
portfolio_state: DailyPortfolioState,
active_bucket_ids: set[str],
) -> DailyPortfolioState:
if not self._capital_bucket_specs or portfolio_state.cash_available <= 0:
return portfolio_state
configured_bucket_ids = set(self._capital_bucket_specs)
if not configured_bucket_ids:
return portfolio_state
candidate_bucket = self._get_candidate_capital_bucket_id(candidate)
relevant_bucket_ids = configured_bucket_ids & active_bucket_ids
if candidate_bucket and candidate_bucket in configured_bucket_ids:
relevant_bucket_ids.add(candidate_bucket)
if not relevant_bucket_ids:
return portfolio_state
bucket_cash_available = {
bucket_id: self._capital_bucket_cash_available(bucket_id, date)
for bucket_id in relevant_bucket_ids
}
bucket_equity = {
bucket_id: self._capital_bucket_equity(bucket_id, date)
for bucket_id in relevant_bucket_ids
}
sizing_equity = _resolve_sizing_equity(portfolio_state)
if candidate_bucket and candidate_bucket in relevant_bucket_ids:
adjusted_cash = min(
portfolio_state.cash_available,
bucket_cash_available[candidate_bucket],
)
adjusted_sizing_equity = bucket_equity[candidate_bucket]
else:
adjusted_cash = max(
0.0,
portfolio_state.cash_available - sum(bucket_cash_available.values()),
)
adjusted_sizing_equity = max(
0.0,
sizing_equity - sum(bucket_equity.values()),
)
if (
math.isclose(adjusted_cash, portfolio_state.cash_available, rel_tol=0.0, abs_tol=1e-9)
and math.isclose(
adjusted_sizing_equity,
sizing_equity,
rel_tol=0.0,
abs_tol=1e-9,
)
):
return portfolio_state
return portfolio_state.model_copy(
update={
"cash_available": adjusted_cash,
"sizing_equity": adjusted_sizing_equity,
}
)
def _reset_parallel_sgov_state(self) -> None:
self._parking_sgov_value = 0.0
self._parking_sgov_entry_value = 0.0
self._parking_sgov_last_price = 0.0
self._parking_sgov_mark_date = None
def _mark_parallel_sgov_to_market(
self,
date: dt.date,
macro: dict[str, Any] | None = None,
) -> float:
if self._parking_sgov_value <= 1e-9:
self._reset_parallel_sgov_state()
return 0.0
macro_data = macro or self.store.get_macro_for_date(date) or {}
close_raw = macro_data.get("sgov_close")
close = float(close_raw) if close_raw is not None else 0.0
if close > 0:
if self._parking_sgov_last_price > 0 and self._parking_sgov_mark_date != date:
self._parking_sgov_value *= close / self._parking_sgov_last_price
self._parking_sgov_last_price = close
self._parking_sgov_mark_date = date
return self._parking_sgov_value
def _allocate_parallel_sgov(
self,
*,
date: dt.date,
amount: float,
macro: dict[str, Any] | None = None,
) -> None:
if amount <= 0:
return
self._mark_parallel_sgov_to_market(date, macro)
self._parking_sgov_value += amount
self._parking_sgov_entry_value += amount
macro_data = macro or {}
close_raw = macro_data.get("sgov_close")
close = float(close_raw) if close_raw is not None else 0.0
if close > 0:
self._parking_sgov_last_price = close
self._parking_sgov_mark_date = date
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._last_simulation_date = all_dates[-1] if all_dates else None
self._simulation_date_index = {
sim_date: idx for idx, sim_date in enumerate(self._simulation_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.
last_date = all_dates[-1] if all_dates else dt.date.today()
if self._open_positions:
self._force_close_all(last_date, reason="end_of_backtest")
# Liquidate remaining parking
if self._parking_shares > 0 or self._parking_sgov_value > 0:
self._liquidate_parking(last_date, timing="close")
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,
non_core_allocator_shadow_rows=self._non_core_allocator_shadow_rows,
)
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)
self._parking_sold_today = False
self._parking_freed_for_cash_today = False
self._parking_target_cache_date = None
self._parking_target_cache_value = None
self._parking_target_cache_valid = False
# Parking-timing discriminator: parking buys fire at OPEN on days with no event
# activity (pure idle day), and at CLOSE on days where event entries/exits freed
# cash that then gets reinvested as leftover.
self._had_event_activity_today = False
self._day_start_open_positions_count = len(self._open_positions)
self._day_start_closed_trades_count = len(self._closed_trades)
# Decrement cooldowns
if self._cooldown_remaining > 0:
self._cooldown_remaining -= 1
if self._kill_switch_cooldown_remaining > 0:
self._kill_switch_cooldown_remaining -= 1
if self._parking_overlay_brake_cooldown > 0:
self._parking_overlay_brake_cooldown -= 1
# --- CASH PARKING: check gate, sell if signal changed, hold if same ---
# Check gate + trailing stop: sell parking if signal changed or stop hit
if (self._parking_shares > 0 or self._parking_current_symbol == "tqqq_blend") and self.config.risk.cash_parking_enabled:
sold = False
# Trailing stop: track peak, exit if down X% from peak
stop_pct = self.config.risk.cash_parking_stop_pct
if stop_pct > 0 and self._parking_current_symbol not in ("sgov", "", "tqqq_blend"):
macro_early = self.store.get_macro_for_date(date) or {}
cur_price = macro_early.get(f"{self._parking_current_symbol}_close")
if cur_price:
self._parking_peak_price = max(self._parking_peak_price, cur_price)
if self._parking_peak_price > 0 and cur_price < self._parking_peak_price * (1 - stop_pct):
self._liquidate_parking(date)
self._parking_stopped_out = True
self._commit_parking_target("sgov")
sold = True
# Shock brake: fast exit from overlay symbol on vol acceleration / trend break
overlay_sym = (self.config.risk.cash_parking_low_vol_overlay_symbol or "").lower()
if not sold and overlay_sym and self.config.risk.cash_parking_overlay_shock_brake_enabled:
is_overlay = (
self._parking_current_symbol == overlay_sym
or self._parking_current_symbol == "tqqq_blend"
)
if is_overlay:
macro_early = self.store.get_macro_for_date(date) or {}
if self._check_overlay_shock_brake(macro_early):
self._liquidate_parking(date)
self._parking_overlay_brake_cooldown = self.config.risk.cash_parking_overlay_shock_brake_cooldown_days
self._parking_overlay_hold_days = 0
sold = True
# Skip same-day re-buy: next day's gate evaluates conditions fresh.
# On crash days this routes to SGOV (vol spike); on mild days back to QQQM.
self._parking_freed_for_cash_today = True
# Dwell cap: max consecutive days holding overlay symbol
max_hold = self.config.risk.cash_parking_overlay_max_hold_days
if not sold and overlay_sym and max_hold > 0:
is_overlay = (
self._parking_current_symbol == overlay_sym
or self._parking_current_symbol == "tqqq_blend"
)
if is_overlay and self._parking_overlay_hold_days >= max_hold:
self._liquidate_parking(date)
self._parking_overlay_hold_days = 0
sold = True
base_sym = self.config.risk.cash_parking_symbol
self._commit_parking_target(base_sym)
# Dwell revalidation: periodically re-check overlay conditions
reval = self.config.risk.cash_parking_overlay_revalidation_days
if not sold and overlay_sym and reval > 0:
is_overlay = (
self._parking_current_symbol == overlay_sym
or self._parking_current_symbol == "tqqq_blend"
)
if is_overlay and self._parking_overlay_hold_days > 0 and self._parking_overlay_hold_days % reval == 0:
macro_reval = self.store.get_macro_for_date(date) or {}
park_mode = self.config.risk.cash_parking_symbol
if self._evaluate_low_vol_overlay_target(macro_reval, park_mode) is None:
self._liquidate_parking(date)
self._parking_overlay_hold_days = 0
sold = True
self._commit_parking_target(park_mode)
# Track overlay hold days
if not sold and overlay_sym:
is_overlay = (
self._parking_current_symbol == overlay_sym
or self._parking_current_symbol == "tqqq_blend"
)
if is_overlay:
self._parking_overlay_hold_days += 1
# Dynamic blend rebalancing: adjust TQQQ/QQQM weights based on current vol
if not sold and self._parking_current_symbol == "tqqq_blend":
if self.config.risk.cash_parking_overlay_blend_enabled:
macro_rebal = self.store.get_macro_for_date(date) or {}
self._rebalance_parking_blend(date, macro_rebal)
# Gate signal check
if not sold:
target = self._evaluate_parking_target(date)
if (
target == "sgov"
and self.config.risk.cash_parking_gate_mode == "volatility"
):
macro_early = self.store.get_macro_for_date(date) or {}
relay_target = self._evaluate_defensive_relay_target(
date,
macro_early,
self.config.risk.cash_parking_symbol,
)
if relay_target is not None:
target = relay_target
if target and target != self._parking_current_symbol:
# Blend is an overlay on base parking — keep blend when base gate
# is satisfied and overlay conditions still hold
base_park = self.config.risk.cash_parking_symbol
if self._parking_current_symbol == "tqqq_blend" and target == base_park:
macro_gate = self.store.get_macro_for_date(date) or {}
if self._evaluate_low_vol_overlay_target(macro_gate, base_park) is None:
self._liquidate_parking(date)
else:
self._liquidate_parking(date)
# Check recovery after stop-out: re-enter when momentum confirms bounce
if self._parking_stopped_out and self.config.risk.cash_parking_enabled:
macro_rec = self.store.get_macro_for_date(date) or {}
rec_days = self.config.risk.cash_parking_stop_recovery_days
rec_pct = self.config.risk.cash_parking_stop_recovery_pct
sym = self.config.risk.cash_parking_symbol
if sym == "dynamic":
sym = "qqq"
mom = macro_rec.get(f"{sym}_mom_{rec_days}")
if mom is not None and mom >= rec_pct:
self._parking_stopped_out = False # recovery confirmed, allow re-entry
# Increment days_held for all open positions
for pos in self._open_positions:
pos.days_held += 1
# --- DIVIDEND CAPTURE OPEN EXITS ---
self._process_dividend_capture_open_exits(date)
# --- OPENING EXITS (scheduled on prior close) ---
if self._pending_open_exits.get(date):
self._process_pending_open_exits(date)
# --- RISK-OFF ALPHA OPEN EXITS ---
self._process_risk_off_alpha_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:
ks_bar, ks_date = bar, date
if ks_bar is None:
latest = self.store.get_latest_bar_on_or_before(pos.plan.candidate.symbol, date)
if latest is not None:
ks_date, ks_bar = latest
trade = simulate_kill_switch_exit(pos, ks_bar, ks_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
# Resolve per-engine effective execution config FIRST so trailing
# honors engine_trailing_model overrides (e.g. EarningsRunup pct_3
# with activation gate). Falls back to global trailing_model when
# no engine override is set.
effective_exec = self._build_effective_execution_config(pos.plan.candidate)
# Update trailing stop if configured (honor engine-level override).
if effective_exec.trailing_model:
update_trailing_stop(
pos, bar,
effective_exec.trailing_model,
warmup_days=effective_exec.trailing_warmup_days,
pct_activation=effective_exec.trailing_pct_activation,
pct_giveback=effective_exec.trailing_pct_giveback,
)
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._get_parking_value(date)
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)
# Same-day reaction_close synthetic candidates (peer-sympathy v2
# salvage variant) must be staged BEFORE _select_candidates_for_date
# because that function consumes _scheduled_add_ons[date] inline.
self._schedule_peer_sympathy_candidates(date, phase="reaction_close")
candidates = self._select_candidates_for_date(date)
# Lookback: on the first simulation day inject pre-start events still within mhd
if self._lookback_entry_enabled and not self._lookback_injected:
lookback = self._collect_lookback_candidates(date)
if lookback:
candidates = list(lookback) + list(candidates)
self._lookback_injected = True
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, [])
idle_delayed_candidates: list[Candidate] = []
if delayed:
primary_delayed_candidates: list[Candidate] = []
for delayed_candidate in delayed:
if self._is_post_allocation_idle_engine_id(delayed_candidate.engine_id):
idle_delayed_candidates.append(delayed_candidate)
else:
primary_delayed_candidates.append(delayed_candidate)
if primary_delayed_candidates:
candidates = list(candidates) + primary_delayed_candidates
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)
self._primary_candidate_slate_stats[date] = {
"candidate_count": len(candidates),
"unique_sector_count": len({candidate.sector for candidate in candidates}),
}
# --- ROTATION: proactively close stale positions if good candidates exist ---
n_rotated = self._attempt_rotation_exits(date, candidates)
if n_rotated > 0:
portfolio_state = self._refresh_portfolio_state(date, drawdown_pct)
portfolio_state = self._execute_candidate_entries(
date=date,
candidates=candidates,
portfolio_state=portfolio_state,
drawdown_pct=drawdown_pct,
macro_data=macro_data,
allow_same_day_cash_recycle=True,
allow_parking_cash_release=True,
)
self._schedule_add_on_candidates(date)
self._schedule_delayed_entry_candidates(date)
self._schedule_leader_follower_candidates(date)
self._schedule_earnings_runup_candidates(date)
self._schedule_peer_sympathy_candidates(date, phase="next_open")
self._schedule_vol_breakout_52w_candidates(date)
self._schedule_macro_short_candidates(date)
self._schedule_macro_long_candidates(date)
idle_candidates: list[Candidate] = []
shadow_form4_payloads: list[dict[str, Any]] = []
shadow_ownership_payloads: list[dict[str, Any]] = []
# --- IDLE-ONLY POST-ALLOCATION ENTRIES ---
if not self._kill_switch_triggered and self._post_allocation_idle_engines:
portfolio_state = self._refresh_portfolio_state(date, drawdown_pct)
idle_candidates = self._select_post_allocation_idle_candidates_for_date(date)
if idle_delayed_candidates:
idle_candidates = list(idle_candidates) + self._tag_post_allocation_idle_candidates(idle_delayed_candidates)
if idle_candidates:
self._total_candidates_seen += len(idle_candidates)
macro_data = self.store.get_macro_for_date(date)
idle_candidates = self._apply_idle_alpha_meta_allocator(
date=date,
candidates=idle_candidates,
portfolio_state=portfolio_state,
)
idle_candidates = self._reorder_candidates_for_funding(
idle_candidates,
portfolio_state,
macro_data,
)
else:
idle_candidates = []
live_non_core_allocator = self._non_core_allocator_v2_live_enabled() and not self._kill_switch_triggered
shadow_non_core_allocator = self._non_core_allocator_v2_shadow_enabled() and not self._kill_switch_triggered
if shadow_non_core_allocator:
shadow_portfolio_state = self._refresh_portfolio_state(date, drawdown_pct)
if self._form4_capture_enabled():
shadow_form4_payloads = self._select_form4_capture_candidates(date)
if self._ownership_capture_enabled():
shadow_ownership_payloads = self._select_ownership_capture_candidates(date)
idle_candidates = self._record_non_core_allocator_v2_shadow_day(
date=date,
portfolio_state=shadow_portfolio_state,
idle_candidates=idle_candidates,
form4_payloads=shadow_form4_payloads,
ownership_payloads=shadow_ownership_payloads,
)
if live_non_core_allocator:
if self._dividend_capture_enabled():
self._enter_dividend_capture_positions(date)
live_form4_payloads: list[dict[str, Any]] = []
live_ownership_payloads: list[dict[str, Any]] = []
if self._form4_capture_enabled():
live_form4_payloads = self._select_form4_capture_candidates(date)
if self._ownership_capture_enabled():
live_ownership_payloads = self._select_ownership_capture_candidates(date)
self._execute_non_core_allocator_v2_live_day(
date=date,
drawdown_pct=drawdown_pct,
idle_candidates=idle_candidates,
form4_payloads=live_form4_payloads,
ownership_payloads=live_ownership_payloads,
)
else:
if idle_candidates:
portfolio_state = self._refresh_portfolio_state(date, drawdown_pct)
macro_data = self.store.get_macro_for_date(date)
portfolio_state = self._execute_candidate_entries(
date=date,
candidates=idle_candidates,
portfolio_state=portfolio_state,
drawdown_pct=drawdown_pct,
macro_data=macro_data,
allow_same_day_cash_recycle=False,
allow_parking_cash_release=True,
)
# --- FORM 4 RESIDUAL-CASH SLEEVE ---
if self._dividend_capture_enabled():
self._enter_dividend_capture_positions(date)
if self._form4_capture_enabled():
self._enter_form4_capture_positions(date)
# --- OWNERSHIP 13D/13G RESIDUAL-CASH SLEEVE ---
if self._ownership_capture_enabled():
self._enter_ownership_capture_positions(date)
if self._risk_off_alpha_enabled():
self._enter_risk_off_alpha_positions(date)
self._finalize_non_core_allocator_v2_shadow_day(date)
# Detect whether any event-sleeve activity happened today (entries or exits).
# Affects parking timing: no activity → OPEN (pure idle day), activity → CLOSE (EOD leftover).
self._had_event_activity_today = (
len(self._open_positions) != self._day_start_open_positions_count
or len(self._closed_trades) != self._day_start_closed_trades_count
)
# --- 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"
defensive_symbol = self._get_parking_defensive_symbol()
# Resolve which symbol to park in
gate_p = self.config.risk.cash_parking_gate_sma_period
gate_mode = self.config.risk.cash_parking_gate_mode
spy_c = macro_data_eod.get("spy_close")
qqq_c = macro_data_eod.get("qqq_close")
def _is_up(prefix: str) -> bool:
"""Evaluate trend gate for a given symbol prefix."""
c = macro_data_eod.get(f"{prefix}_close")
if gate_mode == "dual_sma":
gate_long = self.config.risk.cash_parking_gate_sma_long
s_short = macro_data_eod.get(f"{prefix}_sma_{gate_p}")
s_long = macro_data_eod.get(f"{prefix}_sma_{gate_long}")
return bool(s_short and s_long and s_short > s_long)
elif gate_mode == "drawdown":
lb = self.config.risk.cash_parking_gate_drawdown_lookback
dd_pct = self.config.risk.cash_parking_gate_drawdown_pct
rh = macro_data_eod.get(f"{prefix}_high_{lb}")
return bool(c and rh and rh > 0 and (rh - c) / rh < dd_pct)
elif gate_mode == "momentum":
mom_days = self.config.risk.cash_parking_gate_momentum_days
mom = macro_data_eod.get(f"{prefix}_mom_{mom_days}")
return bool(mom is not None and mom > 0)
elif gate_mode == "pct_threshold":
sma = macro_data_eod.get(f"{prefix}_sma_{gate_p}")
thr = self.config.risk.cash_parking_gate_pct_threshold
return bool(c and sma and sma > 0 and (c - sma) / sma >= thr)
elif gate_mode == "combo":
votes = 0
sma = macro_data_eod.get(f"{prefix}_sma_{gate_p}")
if c and sma and c > sma:
votes += 1
mom_days = self.config.risk.cash_parking_gate_momentum_days
mom = macro_data_eod.get(f"{prefix}_mom_{mom_days}")
if mom is not None and mom > 0:
votes += 1
lb = self.config.risk.cash_parking_gate_drawdown_lookback
dd_pct = self.config.risk.cash_parking_gate_drawdown_pct
rh = macro_data_eod.get(f"{prefix}_high_{lb}")
if c and rh and rh > 0 and (rh - c) / rh < dd_pct:
votes += 1
return votes >= self.config.risk.cash_parking_gate_combo_require
elif gate_mode == "hysteresis":
# Asymmetric: enter QQQ at tight threshold, exit at wide threshold
lb = self.config.risk.cash_parking_gate_drawdown_lookback
rh = macro_data_eod.get(f"{prefix}_high_{lb}")
if not (c and rh and rh > 0):
return not self._parking_gate_in_sgov
dd = (rh - c) / rh
enter_pct = self.config.risk.cash_parking_gate_hyst_enter_pct
exit_pct = self.config.risk.cash_parking_gate_hyst_exit_pct
if self._parking_gate_in_sgov:
# Currently in SGOV — need strong recovery to re-enter
if dd < enter_pct:
self._parking_gate_in_sgov = False
return True
return False
else:
# Currently in QQQ — need big drop to exit
if dd >= exit_pct:
self._parking_gate_in_sgov = True
return False
return True
elif gate_mode == "slope":
# SMA slope: is the SMA itself rising?
sma_now = macro_data_eod.get(f"{prefix}_sma_{gate_p}")
# Use momentum of SMA as proxy for slope
# SMA rising = close N days ago had lower SMA
# Approximate: compare current SMA to SMA from slope_period ago
# We don't have lagged SMA, so use: SMA is rising if close > SMA and SMA > longer SMA
sma_long = macro_data_eod.get(f"{prefix}_sma_{self.config.risk.cash_parking_gate_sma_long}")
return bool(sma_now and sma_long and sma_now > sma_long)
elif gate_mode == "breakout":
# New N-day high → bullish
lb = self.config.risk.cash_parking_gate_breakout_lookback
rh = macro_data_eod.get(f"{prefix}_high_{lb}")
if not (c and rh and rh > 0):
return False
# Close within 1% of N-day high = breakout
return c >= rh * 0.99
elif gate_mode == "volatility":
# Low vol = calm = park in QQQ
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
vol = macro_data_eod.get(f"{prefix}_vol_{vol_lb}")
threshold = self.config.risk.cash_parking_gate_vol_threshold
if vol is None:
return True # no data → assume OK
return vol < threshold
elif gate_mode == "recovery":
# Like drawdown but with recovery confirmation to re-enter
lb = self.config.risk.cash_parking_gate_drawdown_lookback
dd_pct = self.config.risk.cash_parking_gate_drawdown_pct
rh = macro_data_eod.get(f"{prefix}_high_{lb}")
if not (c and rh and rh > 0):
return not self._parking_gate_in_sgov
dd = (rh - c) / rh
if self._parking_gate_in_sgov:
rec_days = self.config.risk.cash_parking_gate_recovery_days
rec_pct = self.config.risk.cash_parking_gate_recovery_pct
mom = macro_data_eod.get(f"{prefix}_mom_{rec_days}")
if mom is not None and mom >= rec_pct and dd < dd_pct:
self._parking_gate_in_sgov = False
return True
return False
else:
if dd >= dd_pct:
self._parking_gate_in_sgov = True
return False
return True
elif gate_mode == "vol_trend":
# Vol + trend confirmation: exit only if vol high AND below SMA
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
vol = macro_data_eod.get(f"{prefix}_vol_{vol_lb}")
threshold = self.config.risk.cash_parking_gate_vol_threshold
sma = macro_data_eod.get(f"{prefix}_sma_{gate_p}")
if vol is None:
return True
# High vol but above SMA → temporary spike, stay in QQQ
if vol >= threshold and c and sma and c < sma:
return False # vol high + downtrend → SGOV
if vol >= threshold:
return True # vol high but uptrend → stay QQQ
return True # vol low → QQQ
elif gate_mode == "vol_dd":
# Vol + drawdown safety net: vol OK AND drawdown OK → QQQ
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
vol = macro_data_eod.get(f"{prefix}_vol_{vol_lb}")
threshold = self.config.risk.cash_parking_gate_vol_threshold
lb = self.config.risk.cash_parking_gate_drawdown_lookback
dd_pct = self.config.risk.cash_parking_gate_drawdown_pct
rh = macro_data_eod.get(f"{prefix}_high_{lb}")
vol_ok = vol is None or vol < threshold
dd_ok = True
if c and rh and rh > 0:
dd_ok = (rh - c) / rh < dd_pct
return vol_ok and dd_ok
elif gate_mode == "vol_regime":
# 3-way: low vol → QQQ, mid vol → SPY, high vol → SGOV
# Returns True/False for the asked prefix, but dynamic mode handles switching
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
vol = macro_data_eod.get(f"{prefix}_vol_{vol_lb}")
threshold = self.config.risk.cash_parking_gate_vol_threshold
if vol is None:
return True
return vol < threshold
else:
# price_above (default)
sma = macro_data_eod.get(f"{prefix}_sma_{gate_p}")
return bool(c and sma and c > sma)
defensive_up = _is_up(self._get_parking_signal_prefix(defensive_symbol))
qqq_up = _is_up("qqq")
# Determine target parking symbol using centralized gate logic
# This ensures trend_sgov state is checked consistently
target_sym = self._evaluate_parking_target(date)
if target_sym is None and gate_mode in ("vol_proportional", "vol_tqqq"):
pass # handled separately below
elif target_sym is None:
target_sym = park_mode
elif target_sym == "sgov" and gate_mode == "volatility":
crisis_target = self._evaluate_crisis_relay_target(macro_data_eod)
if crisis_target is not None:
target_sym = crisis_target
else:
relay_target = self._evaluate_defensive_relay_target(date, macro_data_eod, park_mode)
if relay_target is not None:
target_sym = relay_target
# Override for stopped out state
if self._parking_stopped_out:
target_sym = "sgov"
# Dynamic mode uses _is_up() results (not _evaluate_parking_target)
if park_mode == "dynamic" and not self._parking_stopped_out and not self._parking_trend_sgov:
if defensive_up and qqq_up:
target_sym = "qqq"
elif defensive_up:
target_sym = defensive_symbol
elif qqq_up:
target_sym = "qqq"
else:
target_sym = "sgov"
# After freeing parking for event entries, keep residual cash idle until next session.
# This avoids meaningless sell-at-open / buy-at-close churn in the parking sleeve.
if self._parking_freed_for_cash_today:
target_sym = None
# Day trade prevention (margin accounts only — cash accounts exempt from PDT)
elif self._parking_sold_today and self.config.risk.cash_parking_account_type == "margin":
target_sym = None
# If gate signal changed (e.g., QQQ→SGOV), liquidate existing parking
if target_sym is not None and self._parking_shares > 0 and self._parking_current_symbol != target_sym:
self._liquidate_parking(date)
# Calculate investable from idle cash
existing_parking_value = 0.0
if self._parking_shares > 0:
p = macro_data_eod.get(f"{self._parking_current_symbol}_close", self._parking_avg_price)
existing_parking_value = self._parking_shares * p
existing_parking_value += self._mark_parallel_sgov_to_market(date, macro_data_eod)
mv_for_parking = self._compute_positions_market_value(date)
if self._fixed_capital_sizing:
equity_est = float(self.initial_equity)
else:
equity_est = self._cash + mv_for_parking + existing_parking_value
reserve = equity_est * self.config.risk.cash_parking_reserve_pct
investable = max(0.0, self._cash - reserve)
if self._fixed_capital_sizing:
investable = min(investable, max(0.0, equity_est - reserve))
if gate_mode == "vol_proportional" and investable > 0:
# Sell existing parking first, then redistribute
self._liquidate_parking(date)
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
vol = macro_data_eod.get(f"qqq_vol_{vol_lb}")
full_pct = self.config.risk.cash_parking_gate_vol_full_pct
zero_pct = self.config.risk.cash_parking_gate_vol_zero_pct
if vol is None or vol <= full_pct:
qqq_frac = 1.0
elif vol >= zero_pct:
qqq_frac = 0.0
else:
qqq_frac = (zero_pct - vol) / (zero_pct - full_pct)
# Recalculate investable after liquidation
if self._fixed_capital_sizing:
equity_est = float(self.initial_equity)
else:
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 self._fixed_capital_sizing:
investable = min(investable, max(0.0, equity_est - reserve))
qqq_amount = investable * qqq_frac
sgov_amount = investable * (1.0 - qqq_frac)
qqq_close = macro_data_eod.get("qqq_close")
if qqq_close and qqq_close > 0 and qqq_amount >= qqq_close:
shares = int(qqq_amount / qqq_close)
self._parking_shares = shares
self._parking_avg_price = qqq_close
self._parking_current_symbol = "qqq"
self._commit_parking_target("qqq")
self._cash -= shares * qqq_close
sgov_amount += qqq_amount - shares * qqq_close
else:
sgov_amount += qqq_amount
if sgov_amount > 0:
self._allocate_parallel_sgov(date=date, amount=sgov_amount, macro=macro_data_eod)
self._cash -= sgov_amount
elif gate_mode in ("science_blend", "relative_strength", "vt_blend", "vt_pair_blend") and investable > 0:
# Continuous regime sizing: park some capital in risk asset, rest in SGOV.
self._liquidate_parking(date)
if gate_mode == "science_blend":
target_sym, invested_fraction = self._evaluate_science_blend_plan(
macro_data_eod, park_mode
)
elif gate_mode == "vt_blend":
target_sym, invested_fraction = self._evaluate_vt_blend_plan(
macro_data_eod, park_mode
)
elif gate_mode == "vt_pair_blend":
target_sym, invested_fraction = self._evaluate_vt_pair_blend_plan(
macro_data_eod, park_mode
)
else:
target_sym, invested_fraction = self._evaluate_relative_strength_plan(
date, macro_data_eod, park_mode
)
if self._fixed_capital_sizing:
equity_est = float(self.initial_equity)
else:
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 self._fixed_capital_sizing:
investable = min(investable, max(0.0, equity_est - reserve))
risk_amount = investable * invested_fraction
sgov_amount = investable - risk_amount
if target_sym != "sgov":
_px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
park_close = macro_data_eod.get(f"{target_sym}_{_px_key}") or macro_data_eod.get(f"{target_sym}_close")
if park_close and park_close > 0 and risk_amount >= park_close:
shares = int(risk_amount / park_close)
used_amount = shares * park_close
if shares > 0:
self._parking_shares = shares
self._parking_avg_price = park_close
self._parking_current_symbol = target_sym
self._commit_parking_target(target_sym)
self._cash -= used_amount
sgov_amount += max(0.0, risk_amount - used_amount)
else:
sgov_amount += risk_amount
else:
sgov_amount += risk_amount
else:
sgov_amount = investable
if sgov_amount > 0:
self._allocate_parallel_sgov(date=date, amount=sgov_amount, macro=macro_data_eod)
self._cash -= sgov_amount
elif gate_mode == "vol_tqqq" and investable > 0:
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
vol = macro_data_eod.get(f"qqq_vol_{vol_lb}")
tqqq_pct = self.config.risk.cash_parking_gate_vol_tqqq_pct
vol_thr = self.config.risk.cash_parking_gate_vol_threshold
if vol is not None and vol < tqqq_pct:
target_sym = "tqqq"
elif vol is None or vol < vol_thr:
target_sym = "qqq"
else:
target_sym = "sgov"
# If symbol changed, already liquidated above
if self._parking_shares > 0 and self._parking_current_symbol != target_sym:
self._liquidate_parking(date)
if self._fixed_capital_sizing:
equity_est = float(self.initial_equity)
else:
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 self._fixed_capital_sizing:
investable = min(investable, max(0.0, equity_est - reserve))
if target_sym == "sgov":
_px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
park_close = macro_data_eod.get(f"sgov_{_px_key}") or macro_data_eod.get("sgov_close")
if park_close and park_close > 0 and investable >= park_close:
new_shares = int(investable / park_close)
if self._fixed_capital_sizing and new_shares > 0:
cur_val = self._parking_shares * park_close if self._parking_shares > 0 else 0.0
new_shares = min(new_shares, max(0, int((float(self.initial_equity) - cur_val) / park_close)))
if new_shares > 0:
if self._parking_shares > 0 and self._parking_current_symbol == "sgov":
total_cost = self._parking_shares * self._parking_avg_price + new_shares * park_close
self._parking_shares += new_shares
self._parking_avg_price = total_cost / self._parking_shares
else:
self._parking_shares = new_shares
self._parking_avg_price = park_close
self._parking_current_symbol = "sgov"
self._commit_parking_target("sgov")
self._cash -= new_shares * park_close
else:
_px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
park_close = macro_data_eod.get(f"{target_sym}_{_px_key}") or macro_data_eod.get(f"{target_sym}_close")
if park_close and park_close > 0 and investable >= park_close:
new_shares = int(investable / park_close)
if self._fixed_capital_sizing and new_shares > 0:
cur_val = self._parking_shares * park_close if self._parking_shares > 0 and self._parking_current_symbol == target_sym else 0.0
new_shares = min(new_shares, max(0, int((float(self.initial_equity) - cur_val) / park_close)))
allow_topup = True
min_gain_pct = float(self.config.risk.cash_parking_topup_min_gain_pct)
if (
self._parking_shares > 0
and self._parking_current_symbol == target_sym
and min_gain_pct > -99
and self._parking_avg_price > 0
):
allow_topup = park_close >= self._parking_avg_price * (1.0 + min_gain_pct)
if (
allow_topup
and self._parking_shares > 0
and self._parking_current_symbol == target_sym
and self._parking_entry_date is not None
):
min_days_held = max(0, int(self.config.risk.cash_parking_topup_min_days_held or 0))
if min_days_held > 0 and (date - self._parking_entry_date).days < min_days_held:
allow_topup = False
if (
allow_topup
and self._parking_shares > 0
and self._parking_current_symbol == target_sym
and self._parking_peak_price > 0
and park_close < self._parking_peak_price
):
topup_risk_score_max = float(self.config.risk.cash_parking_topup_risk_score_max or 0.0)
if topup_risk_score_max > 0:
risk_score = float(self._compute_parking_risk_score(macro_data_eod))
if risk_score > topup_risk_score_max:
allow_topup = False
if (
allow_topup
and self._parking_shares > 0
and self._parking_current_symbol == target_sym
and self._parking_peak_price > 0
):
max_peak_dd = float(self.config.risk.cash_parking_topup_max_peak_drawdown_pct)
if max_peak_dd < 999 and park_close < self._parking_peak_price * (1.0 - max_peak_dd):
allow_topup = False
if self._parking_shares > 0 and self._parking_current_symbol == target_sym and allow_topup:
old_cost = self._parking_shares * self._parking_avg_price
self._parking_shares += new_shares
self._parking_avg_price = (
old_cost + new_shares * park_close
) / self._parking_shares
self._commit_parking_target(target_sym)
elif new_shares > 0 and (self._parking_shares <= 0 or self._parking_current_symbol != target_sym):
self._parking_shares = new_shares
self._parking_avg_price = park_close
self._parking_current_symbol = target_sym
self._commit_parking_target(target_sym)
if allow_topup:
self._cash -= new_shares * park_close
elif (
new_shares > 0
and self._parking_shares > 0
and self._parking_current_symbol == target_sym
):
defensive_amount = new_shares * park_close
self._allocate_parallel_sgov(
date=date,
amount=defensive_amount,
macro=macro_data_eod,
)
self._cash -= defensive_amount
elif target_sym == "sgov":
_px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
park_close = macro_data_eod.get(f"sgov_{_px_key}") or macro_data_eod.get("sgov_close")
if park_close and park_close > 0 and investable >= park_close:
new_shares = int(investable / park_close)
if self._fixed_capital_sizing and new_shares > 0:
cur_val = self._parking_shares * park_close if self._parking_shares > 0 else 0.0
new_shares = min(new_shares, max(0, int((float(self.initial_equity) - cur_val) / park_close)))
if new_shares > 0:
if self._parking_shares > 0 and self._parking_current_symbol == "sgov":
# Add to existing SGOV position
total_cost = self._parking_shares * self._parking_avg_price + new_shares * park_close
self._parking_shares += new_shares
self._parking_avg_price = total_cost / self._parking_shares
else:
self._parking_shares = new_shares
self._parking_avg_price = park_close
self._parking_current_symbol = "sgov"
self._commit_parking_target("sgov")
self._cash -= new_shares * park_close
elif target_sym and investable > 0:
# Overlay blend mode: QQQM + TQQQ dual position (continuous leverage)
overlay_sym_cfg = (self.config.risk.cash_parking_low_vol_overlay_symbol or "").lower()
_blend_handled = False
if (
target_sym == overlay_sym_cfg
and self.config.risk.cash_parking_overlay_blend_enabled
and self._parking_current_symbol != "tqqq_blend"
):
w_qqqm, w_tqqq = self._compute_overlay_blend_weights(macro_data_eod)
_px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
tqqq_close = macro_data_eod.get(f"tqqq_{_px_key}") or macro_data_eod.get("tqqq_close")
qqqm_close = macro_data_eod.get(f"qqqm_{_px_key}") or macro_data_eod.get("qqqm_close")
if tqqq_close and tqqq_close > 0 and qqqm_close and qqqm_close > 0:
tqqq_amount = investable * w_tqqq
qqqm_amount = investable * w_qqqm
tqqq_shares = int(tqqq_amount / tqqq_close)
qqqm_shares = int(qqqm_amount / qqqm_close)
total_cost = tqqq_shares * tqqq_close + qqqm_shares * qqqm_close
if total_cost > 0:
self._parking_blend_tqqq_shares = tqqq_shares
self._parking_blend_tqqq_avg_price = tqqq_close
self._parking_blend_qqqm_shares = qqqm_shares
self._parking_blend_qqqm_avg_price = qqqm_close
self._parking_shares = 0
self._parking_current_symbol = "tqqq_blend"
self._commit_parking_target("tqqq_blend")
self._cash -= total_cost
self._parking_overlay_hold_days = 0
_blend_handled = True
if not _blend_handled:
bearish_sym = self.config.risk.cash_parking_bearish_symbol
bearish_alloc_pct = min(
1.0,
max(0.0, float(self.config.risk.cash_parking_bearish_alloc_pct or 0.0)),
)
defensive_sym = self._get_parking_defensive_symbol()
defensive_alloc_pct = min(
1.0,
max(0.0, float(self.config.risk.cash_parking_defensive_alloc_pct or 0.0)),
)
sgov_amount = 0.0
symbol_investable = investable
if (
defensive_sym
and target_sym == defensive_sym
and defensive_alloc_pct < 0.999
):
symbol_investable = investable * defensive_alloc_pct
sgov_amount = investable - symbol_investable
elif (
bearish_sym
and target_sym == bearish_sym
and bearish_alloc_pct < 0.999
):
symbol_investable = investable * bearish_alloc_pct
sgov_amount = investable - symbol_investable
_px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
park_close = macro_data_eod.get(f"{target_sym}_{_px_key}") or macro_data_eod.get(f"{target_sym}_close")
if park_close and park_close > 0 and symbol_investable >= park_close:
new_shares = int(symbol_investable / park_close)
if self._fixed_capital_sizing and new_shares > 0:
cur_val = self._parking_shares * park_close if self._parking_shares > 0 and self._parking_current_symbol == target_sym else 0.0
new_shares = min(new_shares, max(0, int((float(self.initial_equity) - cur_val) / park_close)))
allow_topup = True
min_gain_pct = float(self.config.risk.cash_parking_topup_min_gain_pct)
if (
self._parking_shares > 0
and self._parking_current_symbol == target_sym
and min_gain_pct > -99
and self._parking_avg_price > 0
):
allow_topup = park_close >= self._parking_avg_price * (1.0 + min_gain_pct)
if (
allow_topup
and self._parking_shares > 0
and self._parking_current_symbol == target_sym
and self._parking_entry_date is not None
):
min_days_held = max(0, int(self.config.risk.cash_parking_topup_min_days_held or 0))
if min_days_held > 0 and (date - self._parking_entry_date).days < min_days_held:
allow_topup = False
if (
allow_topup
and self._parking_shares > 0
and self._parking_current_symbol == target_sym
and self._parking_peak_price > 0
and park_close < self._parking_peak_price
):
topup_risk_score_max = float(self.config.risk.cash_parking_topup_risk_score_max or 0.0)
if topup_risk_score_max > 0:
risk_score = float(self._compute_parking_risk_score(macro_data_eod))
if risk_score > topup_risk_score_max:
allow_topup = False
if (
allow_topup
and self._parking_shares > 0
and self._parking_current_symbol == target_sym
and self._parking_peak_price > 0
):
max_peak_dd = float(self.config.risk.cash_parking_topup_max_peak_drawdown_pct)
if max_peak_dd < 999 and park_close < self._parking_peak_price * (1.0 - max_peak_dd):
allow_topup = False
if self._parking_shares > 0 and self._parking_current_symbol == target_sym and allow_topup:
# Add to existing position
old_cost = self._parking_shares * self._parking_avg_price
self._parking_shares += new_shares
self._parking_avg_price = (old_cost + new_shares * park_close) / self._parking_shares
self._commit_parking_target(target_sym)
elif new_shares > 0 and (self._parking_shares <= 0 or self._parking_current_symbol != target_sym):
self._parking_shares = new_shares
self._parking_avg_price = park_close
self._parking_current_symbol = target_sym
self._commit_parking_target(target_sym)
if allow_topup:
self._cash -= new_shares * park_close
elif (
new_shares > 0
and self._parking_shares > 0
and self._parking_current_symbol == target_sym
):
defensive_amount = new_shares * park_close
self._allocate_parallel_sgov(
date=date,
amount=defensive_amount,
macro=macro_data_eod,
)
self._cash -= defensive_amount
used_amount = new_shares * park_close if allow_topup else 0.0
if sgov_amount > 0:
sgov_amount += max(0.0, symbol_investable - used_amount)
elif sgov_amount > 0:
sgov_amount += symbol_investable
if sgov_amount > 0:
self._allocate_parallel_sgov(
date=date,
amount=sgov_amount,
macro=macro_data_eod,
)
self._cash -= sgov_amount
# Track parking entry date and peak price
has_parking = (
self._parking_shares > 0
or self._parking_sgov_value > 0
or self._parking_current_symbol == "tqqq_blend"
)
if has_parking and self._parking_entry_date is None:
self._parking_entry_date = date
self._parking_peak_price = self._parking_avg_price
# --- 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_current_symbol == "tqqq_blend":
parking_value = self._get_parking_value(date)
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
)
parking_value += self._mark_parallel_sgov_to_market(date, macro_for_eq)
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)
ia_exposure_val = 0.0
for _pos in self._open_positions:
if self._is_post_allocation_idle_engine_id(_pos.plan.engine_id):
_close = self._resolve_close_price(
_pos.plan.candidate.symbol, date, _pos.entry_price,
)
ia_exposure_val += _close * _pos.shares_open
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,
raw_cash=self._cash,
parking_value=parking_value,
idle_alpha_exposure=ia_exposure_val,
primary_exposure=max(0.0, gross_exposure - ia_exposure_val),
)
)
def _get_simulation_dates(self) -> list[dt.date]:
"""Return the full trading-day simulation range for the configured engines."""
requested_start = getattr(self.store, "_requested_start_date", None)
requested_end = getattr(self.store, "_requested_end_date", None)
if isinstance(requested_start, dt.date) and isinstance(requested_end, dt.date):
from libs.backtest.calendar import get_trading_days
if requested_start <= requested_end:
return get_trading_days(requested_start, requested_end)
# requested_start > requested_end: parking cap pushed end before start
# (e.g. user requested start=2026-04-27 but data only covers to 2026-04-24).
# Do NOT fall through to all_trading_days() — that silently runs on the
# lookback-extended store (e.g. March 17April 24 due to lookback_entry_enabled).
logger.warning(
"backtest_start_beyond_available_data",
requested_start=requested_start.isoformat(),
effective_end=requested_end.isoformat(),
)
return []
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 _collect_lookback_candidates(self, first_sim_date: dt.date) -> list[Candidate]:
"""Return candidates from before first_sim_date that are still within their holding window.
Used on the first day of a bounded backtest so that events which fired before
the requested start date — but whose max_holding_days has not yet expired —
can still be entered at today's open price.
"""
from libs.backtest.calendar import get_trading_days
# Compute the widest possible holding window across all engines / event profiles
max_mhd = self.config.execution.max_holding_days
if self.config.execution.dynamic_hold_enabled:
max_mhd = max(max_mhd, self.config.execution.dynamic_hold_extend_to)
for engine in self.config.get_strategy_engines():
if engine.max_holding_days is not None:
engine_mhd = engine.max_holding_days
if engine.dynamic_hold_extend_to_override is not None:
engine_mhd = max(engine_mhd, engine.dynamic_hold_extend_to_override)
max_mhd = max(max_mhd, engine_mhd)
for profile in (self.config.event_type_profiles or {}).values():
if profile.max_holding_days_override is not None:
max_mhd = max(max_mhd, profile.max_holding_days_override)
# Collect raw rows for all execution dates before first_sim_date
# Use a calendar-day buffer of 2x to cover weekends/holidays
lookback_rows: list[dict[str, Any]] = []
for exec_date in sorted(self.store._candidates.keys()):
if exec_date >= first_sim_date:
break
lookback_rows.extend(self.store._candidates[exec_date])
if not lookback_rows:
return []
# Cache trading-day counts per execution_date for efficiency
_elapsed_cache: dict[dt.date, int] = {}
def _trading_days_elapsed(exec_date: dt.date) -> int:
if exec_date not in _elapsed_cache:
tdays = get_trading_days(exec_date, first_sim_date)
# Inclusive on both ends; elapsed = days the position has been "in play"
# (exec_date is day 0; first_sim_date adds another day beyond that)
_elapsed_cache[exec_date] = max(0, len(tdays) - 1)
return _elapsed_cache[exec_date]
# Run through the same selection pipeline as normal candidates
all_lookback: list[Candidate] = []
engine_list = list(self._primary_strategy_engines)
if not engine_list:
# No-engine (legacy single-engine) mode
selected = select_candidates(
lookback_rows,
self.config.universe,
self.config.signal,
event_type_profiles=self.config.event_type_profiles or None,
)
all_lookback.extend(selected)
else:
seen_event_ids: set[str] = set()
for engine in engine_list:
if not self._engine_allowed_for_date(engine, first_sim_date):
continue
if not self._engine_uses_snapshot_candidates(engine):
continue
effective_engine = self._effective_engine_for_date(engine, first_sim_date)
selected = select_candidates(
lookback_rows,
self.config.universe,
self.config.signal,
event_type_profiles=self.config.event_type_profiles or None,
strategy_engine=effective_engine,
engine_lookup=self._strategy_engine_lookup,
excluded_event_ids=seen_event_ids,
)
all_lookback.extend(selected)
seen_event_ids.update(cand.event_id for cand in selected)
# Filter by elapsed holding days and annotate surviving candidates
result: list[Candidate] = []
for candidate in all_lookback:
elapsed = _trading_days_elapsed(candidate.execution_date)
# Get the effective mhd for this specific candidate
eff_exec = self._build_effective_execution_config(candidate)
candidate_mhd = eff_exec.max_holding_days
if eff_exec.dynamic_hold_enabled:
candidate_mhd = max(candidate_mhd, eff_exec.dynamic_hold_extend_to)
if elapsed >= candidate_mhd:
continue # would have timed out by now
remaining = candidate_mhd - elapsed
min_remaining = self.config.execution.lookback_min_remaining_days
if min_remaining is not None and remaining < min_remaining:
continue # too little holding time left to be worthwhile
candidate.features["is_lookback_entry"] = True
candidate.features["lookback_days_elapsed"] = elapsed
candidate.features["lookback_original_execution_date"] = (
candidate.execution_date.isoformat()
)
result.append(candidate)
logger.info(
"lookback_entry_candidates",
first_sim_date=str(first_sim_date),
max_mhd=max_mhd,
rows_scanned=len(lookback_rows),
candidates_selected=len(result),
)
return result
def _select_candidates_for_date(
self,
date: dt.date,
*,
engines: list[Any] | None = None,
include_scheduled_add_ons: bool = True,
) -> list[Candidate]:
"""Select daily candidates for single-engine or multi-engine mode."""
if engines is None and not self.config.get_strategy_engines():
raw_rows = 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,
)
self._annotate_candidate_slate_features(selected)
return selected
engine_list = list(engines) if engines is not None else list(self._primary_strategy_engines)
if not engine_list:
return []
engine_queues: dict[str, list[Candidate]] = {}
reserved_event_ids: set[str] = set()
reserved_symbols: set[str] = set()
for engine in engine_list:
if not self._engine_allowed_for_date(engine, date):
continue
if not self._engine_uses_snapshot_candidates(engine):
continue
effective_engine = self._effective_engine_for_date(engine, date)
prelimit = self.config.signal.max_candidates_per_day
if self._engine_requires_attention(effective_engine):
prelimit = max(prelimit * 5, prelimit)
raw_rows = (
self.store.get_candidates_for_reaction_date(date)
if effective_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=effective_engine,
engine_lookup=self._strategy_engine_lookup,
truncate_to=prelimit,
excluded_event_ids=reserved_event_ids,
excluded_symbols=reserved_symbols,
)
selected = self._apply_attention_filters(selected, effective_engine)
if selected:
engine_queues[effective_engine.engine_id] = selected
if effective_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 include_scheduled_add_ons else []
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))
self._annotate_candidate_slate_features(
[candidate for candidates in engine_queues.values() for candidate in 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."""
shadow_engines = [
engine for engine in self._shadow_strategy_engines
if not getattr(engine, "post_allocation_idle_only", False)
]
if not shadow_engines:
return []
selected_shadow: list[Candidate] = []
reserved_event_ids: set[str] = set()
reserved_symbols: set[str] = set()
for engine in shadow_engines:
if not self._engine_allowed_for_date(engine, date):
continue
if not self._engine_uses_snapshot_candidates(engine):
continue
effective_engine = self._effective_engine_for_date(engine, date)
prelimit = self.config.signal.max_candidates_per_day
if self._engine_requires_attention(effective_engine):
prelimit = max(prelimit * 5, prelimit)
raw_rows = (
self.store.get_candidates_for_reaction_date(date)
if effective_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=effective_engine,
engine_lookup=self._strategy_engine_lookup,
truncate_to=prelimit,
excluded_event_ids=reserved_event_ids,
excluded_symbols=reserved_symbols,
)
selected = self._apply_attention_filters(selected, effective_engine)
if selected:
selected_shadow.extend(selected)
if effective_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)
self._annotate_candidate_slate_features(selected_shadow)
return selected_shadow
def _select_post_allocation_idle_candidates_for_date(self, date: dt.date) -> list[Candidate]:
"""Select idle-alpha candidates only after primary engines have finished allocating capital."""
candidates = self._select_candidates_for_date(
date,
engines=self._post_allocation_idle_engines,
include_scheduled_add_ons=False,
)
return self._tag_post_allocation_idle_candidates(candidates)
def _tag_post_allocation_idle_candidates(self, candidates: list[Candidate]) -> list[Candidate]:
"""Annotate candidates that belong to the idle-alpha sleeve for downstream UI/export."""
if not candidates:
return candidates
tagged: list[Candidate] = []
for candidate in candidates:
features = dict(candidate.features)
features["trade_sleeve"] = "idle_alpha"
tagged.append(candidate.model_copy(update={"features": features}))
return tagged
def _is_post_allocation_idle_engine_id(self, engine_id: str) -> bool:
engine = self._strategy_engine_lookup.get(engine_id)
if engine is None:
return False
return bool(getattr(engine, "post_allocation_idle_only", False))
def _refresh_portfolio_state(
self,
date: dt.date,
drawdown_pct: float,
) -> DailyPortfolioState:
mv = self._compute_positions_market_value(date)
self._equity = self._cash + mv + self._get_parking_value(date)
unrealized = mv - sum(p.entry_price * p.shares_open for p in self._open_positions)
return self._build_portfolio_state(date, drawdown_pct, unrealized)
def _apply_idle_alpha_meta_allocator(
self,
*,
date: dt.date,
candidates: list[Candidate],
portfolio_state: DailyPortfolioState,
) -> list[Candidate]:
cfg = self.config.idle_alpha
if not cfg.dynamic_allocator_enabled or not candidates:
return candidates
equity = max(float(self._sizing_equity if self._fixed_capital_sizing else portfolio_state.equity), 1.0)
cash_ratio = max(float(portfolio_state.cash_available), 0.0) / equity
cash_low = max(float(cfg.dynamic_allocator_cash_ratio_low), 0.0)
cash_high = max(float(cfg.dynamic_allocator_cash_ratio_high), cash_low)
low_scale = float(cfg.dynamic_allocator_cash_scale_low)
high_scale = float(cfg.dynamic_allocator_cash_scale_high)
if cash_high <= cash_low:
cash_scale = high_scale if cash_ratio >= cash_high else low_scale
elif cash_ratio <= cash_low:
cash_scale = low_scale
elif cash_ratio >= cash_high:
cash_scale = high_scale
else:
progress = (cash_ratio - cash_low) / (cash_high - cash_low)
cash_scale = low_scale + progress * (high_scale - low_scale)
primary_stats = self._primary_candidate_slate_stats.get(
date,
{"candidate_count": 0, "unique_sector_count": 0},
)
crowded = False
candidate_threshold = cfg.dynamic_allocator_crowded_primary_candidate_count
if candidate_threshold is not None and primary_stats["candidate_count"] >= candidate_threshold:
crowded = True
sector_threshold = cfg.dynamic_allocator_crowded_primary_unique_sector_count
if sector_threshold is not None and primary_stats["unique_sector_count"] >= sector_threshold:
crowded = True
scale = cash_scale
if crowded:
scale *= float(cfg.dynamic_allocator_crowded_scale)
scale = max(float(cfg.dynamic_allocator_min_scale), min(float(cfg.dynamic_allocator_max_scale), scale))
if abs(scale - 1.0) < 1e-9:
return candidates
adjusted: list[Candidate] = []
for candidate in candidates:
engine = self._strategy_engine_lookup.get(candidate.engine_id)
if self._idle_alpha_candidate_in_reentry_cooldown(
date=date,
candidate=candidate,
engine=engine,
):
continue
class_scale_multiplier = (
float(cfg.dynamic_allocator_synthetic_scale_multiplier)
if bool(getattr(engine, "synthetic_only", False))
else float(cfg.dynamic_allocator_snapshot_scale_multiplier)
)
candidate_scale = max(
float(cfg.dynamic_allocator_min_scale),
min(float(cfg.dynamic_allocator_max_scale), scale * class_scale_multiplier),
)
updates: dict[str, Any] = {
"engine_risk_budget_pct": candidate.engine_risk_budget_pct * candidate_scale,
}
if candidate.engine_per_trade_risk_pct is not None:
updates["engine_per_trade_risk_pct"] = candidate.engine_per_trade_risk_pct * candidate_scale
features = dict(candidate.features)
features["idle_alpha_meta_scale"] = round(candidate_scale, 6)
features["idle_alpha_meta_base_scale"] = round(scale, 6)
features["idle_alpha_meta_class_scale_multiplier"] = round(class_scale_multiplier, 6)
features["idle_alpha_meta_candidate_class"] = (
"synthetic" if bool(getattr(engine, "synthetic_only", False)) else "snapshot"
)
features["idle_alpha_meta_cash_ratio"] = round(cash_ratio, 6)
features["idle_alpha_meta_primary_candidate_count"] = primary_stats["candidate_count"]
features["idle_alpha_meta_primary_unique_sector_count"] = primary_stats["unique_sector_count"]
features["idle_alpha_meta_crowded"] = crowded
updates["features"] = features
adjusted.append(candidate.model_copy(update=updates))
return adjusted
def _idle_alpha_candidate_in_reentry_cooldown(
self,
*,
date: dt.date,
candidate: Candidate,
engine: StrategyEngineConfig | None,
) -> bool:
cfg = self.config.idle_alpha
cooldown_days = (
int(cfg.dynamic_allocator_synthetic_reentry_cooldown_days)
if bool(getattr(engine, "synthetic_only", False))
else 0
)
if cooldown_days <= 0:
return False
current_idx = self._simulation_date_index.get(date)
if current_idx is None:
return False
candidate_symbol = str(candidate.symbol).upper()
for trade in reversed(self._closed_trades):
if str(trade.symbol).upper() != candidate_symbol:
continue
prior_candidate = self._candidate_map.get(trade.trade_id)
if prior_candidate is None:
continue
if prior_candidate.engine_id != candidate.engine_id:
continue
if prior_candidate.features.get("trade_sleeve") != "idle_alpha":
continue
exit_idx = self._simulation_date_index.get(trade.exit_date)
if exit_idx is None:
return False
return (current_idx - exit_idx) <= cooldown_days
return False
def _non_core_allocator_v2_shadow_enabled(self) -> bool:
cfg = self.config.non_core_allocator_v2
return (
bool(cfg.enabled)
and str(cfg.mode).strip().lower() == "shadow"
and str(cfg.scope).strip().lower() == "non_core"
)
def _non_core_allocator_v2_live_enabled(self) -> bool:
cfg = self.config.non_core_allocator_v2
return (
bool(cfg.enabled)
and str(cfg.mode).strip().lower() == "live"
and str(cfg.scope).strip().lower() == "non_core"
)
def _non_core_allocator_v2_candidate_key(self, candidate: Candidate) -> str:
return "|".join(
[
str(candidate.engine_id or "").strip(),
str(candidate.event_id or "").strip(),
str(candidate.symbol or "").strip().upper(),
]
)
def _non_core_allocator_v2_current_parking_symbol(self, date: dt.date) -> str:
target = self._preview_effective_parking_target(date)
if target:
return str(target).strip().lower()
if self._parking_current_symbol:
return str(self._parking_current_symbol).strip().lower()
if self._parking_committed_target:
return str(self._parking_committed_target).strip().lower()
symbol = str(self.config.risk.cash_parking_symbol or "").strip().lower()
return symbol or "sgov"
def _non_core_allocator_v2_idle_requested_cash_est(
self,
*,
date: dt.date,
candidate: Candidate,
portfolio_state: DailyPortfolioState,
active_bucket_ids: set[str],
macro_data: dict[str, Any] | None,
) -> tuple[float, bool]:
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
date=date,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
)
plan = build_planned_order(
candidate=candidate,
portfolio_state=candidate_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:
return 0.0, False
return max(float(plan.shares * candidate.entry_price_est), 0.0), True
def _build_non_core_allocator_v2_day_context(
self,
*,
date: dt.date,
portfolio_state: DailyPortfolioState,
idle_candidates: list[Candidate],
form4_payloads: list[dict[str, Any]],
ownership_payloads: list[dict[str, Any]],
) -> tuple[list[Candidate], list[dict[str, Any]], dict[str, Candidate], float]:
cfg = self.config.non_core_allocator_v2
weights = cfg.weights
parking_symbol = self._non_core_allocator_v2_current_parking_symbol(date)
parking_class = classify_parking_class(parking_symbol)
macro = self.store.get_macro_for_date(date) or {}
parking_prefix = self._get_parking_signal_prefix(parking_symbol)
parking_momentum_20 = float(macro.get(f"{parking_prefix}_mom_20") or 0.0)
virtual_cash = max(0.0, float(portfolio_state.cash_available) + float(self._get_parking_value(date)))
active_bucket_ids = self._active_capital_bucket_ids_for_candidates(idle_candidates)
rows: list[dict[str, Any]] = []
annotated_idle_candidates: list[Candidate] = []
candidate_by_key: dict[str, Candidate] = {}
current_index = 0
def _append_row(
*,
family: str,
candidate: Candidate,
rank_index: int,
total_count: int,
requested_cash_est: float,
complete: bool,
family_cap: int | None,
) -> Candidate:
nonlocal current_index
if family == "idle_alpha":
hold_days_est = (
candidate.engine_max_holding_days
or self._build_effective_execution_config(candidate).max_holding_days
)
elif family == "form4":
hold_days_est = int(self.config.form4_capture.hold_days)
elif family == "ownership":
hold_days_est = int(self.config.ownership_capture.hold_days)
else:
hold_days_est = int(self.config.risk_off_alpha.max_holding_days or 0)
native_rank_pct = compute_native_rank_pct(rank_index, total_count)
hold_norm = normalize_hold_days_est(hold_days_est)
liquidity_penalty_norm = normalize_liquidity_penalty(
requested_cash_est=requested_cash_est,
avg_dollar_volume=candidate.avg_dollar_volume,
)
overlap_class = classify_non_core_overlap_class(
family,
trade_symbol_mode=candidate.trade_symbol_mode,
engine_id=candidate.engine_id,
symbol=candidate.symbol,
)
overlap_penalty_norm = compute_overlap_penalty(overlap_class, parking_class)
parking_proxy_norm = normalize_parking_proxy(
parking_symbol,
parking_momentum_20=parking_momentum_20,
hold_days_est=hold_days_est,
sgov_annual_rate=self.config.risk.cash_parking_sgov_annual_rate,
)
marginal_score = compute_marginal_score(
native_rank_pct=native_rank_pct,
hold_norm=hold_norm,
liquidity_penalty_norm=liquidity_penalty_norm,
overlap_penalty_norm=overlap_penalty_norm,
parking_proxy_norm=parking_proxy_norm,
native_strength=weights.native_strength,
hold_penalty=weights.hold_penalty,
liquidity_penalty=weights.liquidity_penalty,
overlap_penalty=weights.overlap_penalty,
parking_opportunity_penalty=weights.parking_opportunity_penalty,
)
allocator_features = {
"allocator_v2_family": family,
"allocator_v2_native_rank_pct": round(native_rank_pct, 6),
"allocator_v2_hold_days_est": int(hold_days_est),
"allocator_v2_requested_cash_est": round(float(requested_cash_est), 6),
"allocator_v2_avg_dollar_volume": round(float(candidate.avg_dollar_volume), 6),
"allocator_v2_overlap_class": overlap_class,
"allocator_v2_parking_symbol": parking_symbol,
"allocator_v2_parking_class": parking_class,
"allocator_v2_parking_proxy_norm": round(parking_proxy_norm, 6),
"allocator_v2_hold_norm": round(hold_norm, 6),
"allocator_v2_liquidity_penalty_norm": round(liquidity_penalty_norm, 6),
"allocator_v2_overlap_penalty_norm": round(overlap_penalty_norm, 6),
"allocator_v2_marginal_score": round(marginal_score, 6),
"allocator_v2_complete": bool(complete),
}
features = dict(candidate.features)
features.update(allocator_features)
annotated_candidate = candidate.model_copy(update={"features": features})
candidate_key = self._non_core_allocator_v2_candidate_key(annotated_candidate)
candidate_by_key[candidate_key] = annotated_candidate
rows.append(
{
"date": date.isoformat(),
"engine_id": annotated_candidate.engine_id,
"event_id": annotated_candidate.event_id,
"symbol": annotated_candidate.symbol,
"source_symbol": annotated_candidate.source_symbol,
"trade_sleeve": family,
"allocator_v2_family": family,
"allocator_v2_candidate_key": candidate_key,
"allocator_v2_rank_index": int(rank_index),
"allocator_v2_total_count": int(total_count),
"allocator_v2_family_cap": family_cap,
"allocator_v2_native_rank_pct": round(native_rank_pct, 6),
"allocator_v2_hold_days_est": int(hold_days_est),
"allocator_v2_requested_cash_est": round(float(requested_cash_est), 6),
"allocator_v2_avg_dollar_volume": round(float(annotated_candidate.avg_dollar_volume), 6),
"allocator_v2_overlap_class": overlap_class,
"allocator_v2_parking_symbol": parking_symbol,
"allocator_v2_parking_class": parking_class,
"allocator_v2_parking_proxy_norm": round(parking_proxy_norm, 6),
"allocator_v2_hold_norm": round(hold_norm, 6),
"allocator_v2_liquidity_penalty_norm": round(liquidity_penalty_norm, 6),
"allocator_v2_overlap_penalty_norm": round(overlap_penalty_norm, 6),
"allocator_v2_marginal_score": round(marginal_score, 6),
"allocator_v2_complete": bool(complete),
"allocator_v2_virtual_cash_start": round(virtual_cash, 6),
"allocator_v2_shadow_decision": "blocked_by_budget",
"allocator_v2_shadow_selected": False,
"allocator_v2_live_selected": False,
"allocator_v2_live_vs_shadow_disagree": False,
"_shadow_order_index": current_index,
}
)
current_index += 1
return annotated_candidate
idle_total = len(idle_candidates)
for rank_index, candidate in enumerate(idle_candidates):
requested_cash_est, complete = self._non_core_allocator_v2_idle_requested_cash_est(
date=date,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
macro_data=macro,
)
annotated_idle_candidates.append(
_append_row(
family="idle_alpha",
candidate=candidate,
rank_index=rank_index,
total_count=idle_total,
requested_cash_est=requested_cash_est,
complete=complete,
family_cap=None,
)
)
form4_positions = sum(
1 for position in self._open_positions
if position.plan.engine_id == _FORM4_CAPTURE_ENGINE_ID
)
form4_cap = max(
0,
min(
int(self.config.form4_capture.max_new_per_day),
max(0, int(self.config.form4_capture.max_positions) - form4_positions),
),
)
form4_divisor = max(1, min(len(form4_payloads), form4_cap if form4_cap > 0 else len(form4_payloads)))
form4_budget_total = max(0.0, float(portfolio_state.equity) * float(self.config.form4_capture.reserve_pct))
form4_requested_cash_est = form4_budget_total / form4_divisor if form4_divisor > 0 else 0.0
for rank_index, payload in enumerate(form4_payloads):
candidate = self._build_form4_capture_candidate(
symbol=str(payload["symbol"]),
date=date,
filing_date=payload["filing_date"],
avg_dollar_volume=float(payload["avg_dollar_volume"]),
owner_count=int(payload["owner_count"]),
transaction_count=int(payload["transaction_count"]),
event_day_count=int(payload["event_day_count"]),
total_value=float(payload["total_value"]),
weighted_purchase_pct=float(payload["weighted_purchase_pct"]),
max_lag_days=payload["max_lag_days"],
min_lag_days=payload.get("min_lag_days"),
transaction_span_days=int(payload.get("transaction_span_days") or 0),
officer_count=int(payload.get("officer_count") or 0),
director_count=int(payload.get("director_count") or 0),
ten_percent_owner_count=int(payload.get("ten_percent_owner_count") or 0),
ceo_count=int(payload.get("ceo_count") or 0),
cfo_count=int(payload.get("cfo_count") or 0),
c_suite_count=int(payload.get("c_suite_count") or 0),
role_weight_score=float(payload.get("role_weight_score") or 0.0),
has_officer_or_director=bool(payload["has_officer_or_director"]),
)
_append_row(
family="form4",
candidate=candidate,
rank_index=rank_index,
total_count=len(form4_payloads),
requested_cash_est=form4_requested_cash_est,
complete=form4_requested_cash_est > 0,
family_cap=form4_cap,
)
ownership_positions = sum(
1 for position in self._open_positions
if position.plan.engine_id == _OWNERSHIP_CAPTURE_ENGINE_ID
)
ownership_cap = max(
0,
min(
int(self.config.ownership_capture.max_new_per_day),
max(0, int(self.config.ownership_capture.max_positions) - ownership_positions),
),
)
ownership_divisor = max(
1,
min(len(ownership_payloads), ownership_cap if ownership_cap > 0 else len(ownership_payloads)),
)
ownership_budget_total = 0.0
ownership_cfg = self.config.ownership_capture
if float(ownership_cfg.reserve_pct) > 0:
ownership_budget_total = float(portfolio_state.equity) * float(ownership_cfg.reserve_pct)
extra_idle_deploy_pct = min(1.0, max(0.0, float(ownership_cfg.extra_idle_deploy_pct_above_reserve or 0.0)))
if extra_idle_deploy_pct > 0 and float(portfolio_state.equity) > 0:
virtual_cash_ratio = virtual_cash / float(portfolio_state.equity)
if virtual_cash_ratio >= float(ownership_cfg.min_cash_ratio_for_overlay):
ownership_budget_total += max(0.0, virtual_cash - ownership_budget_total) * extra_idle_deploy_pct
elif float(portfolio_state.equity) > 0:
virtual_cash_ratio = virtual_cash / float(portfolio_state.equity)
if virtual_cash_ratio >= float(ownership_cfg.min_cash_ratio_for_overlay):
ownership_budget_total = virtual_cash * min(1.0, max(0.0, float(ownership_cfg.max_idle_deploy_pct)))
ownership_requested_cash_est = ownership_budget_total / ownership_divisor if ownership_divisor > 0 else 0.0
for rank_index, payload in enumerate(ownership_payloads):
candidate = self._build_ownership_capture_candidate(
symbol=str(payload["symbol"]),
date=date,
filing_date=payload["filing_date"],
form_type=str(payload["form_type"]),
percent_owned=float(payload["percent_owned"]),
aggregate_shares=float(payload["aggregate_shares"]),
activist_flag=bool(payload["activist_flag"]),
is_amendment=bool(payload["is_amendment"]),
prior_percent_owned=payload["prior_percent_owned"],
percent_delta_points=payload["percent_delta_points"],
prior_form_group=payload["prior_form_group"],
is_initial_for_owner=bool(payload["is_initial_for_owner"]),
is_13g_to_13d_transition=bool(payload["is_13g_to_13d_transition"]),
avg_dollar_volume=float(payload["avg_dollar_volume"]),
owner_name=payload.get("owner_name"),
owner_key=payload.get("owner_key"),
purpose_text=payload.get("purpose_text"),
purpose_housekeeping_flag=bool(payload.get("purpose_housekeeping_flag")),
ownership_strength_score=int(payload.get("ownership_strength_score") or 0),
)
_append_row(
family="ownership",
candidate=candidate,
rank_index=rank_index,
total_count=len(ownership_payloads),
requested_cash_est=ownership_requested_cash_est,
complete=ownership_requested_cash_est > 0,
family_cap=ownership_cap,
)
signal_date = self._previous_simulation_date(date)
if signal_date is not None:
desired_symbol = self._select_risk_off_alpha_symbol(signal_date)
existing_risk_off_positions = [
position for position in self._open_positions
if position.plan.engine_id == _RISK_OFF_ALPHA_ENGINE_ID
]
if desired_symbol and not existing_risk_off_positions:
risk_off_budget_total = 0.0
risk_off_cfg = self.config.risk_off_alpha
effective_reserve_pct = self._effective_risk_off_alpha_reserve_pct(signal_date)
if effective_reserve_pct > 0:
risk_off_budget_total = float(portfolio_state.equity) * effective_reserve_pct
elif float(portfolio_state.equity) > 0:
virtual_cash_ratio = virtual_cash / float(portfolio_state.equity)
if virtual_cash_ratio >= float(risk_off_cfg.min_cash_ratio_for_overlay):
risk_off_budget_total = virtual_cash * min(1.0, max(0.0, float(risk_off_cfg.max_idle_deploy_pct)))
symbol = str(desired_symbol).upper()
avg_dollar_volume = float(self.store.get_market_features(symbol, date).get("avg_dollar_volume_20d") or 0.0)
signal_momentum = float(
macro.get(f"{symbol.lower()}_mom_{max(1, int(risk_off_cfg.momentum_lookback_days or 20))}") or 0.0
)
candidate = self._build_risk_off_alpha_candidate(
symbol=symbol,
date=date,
signal_date=signal_date,
signal_momentum=signal_momentum,
sgov_streak=self._risk_off_alpha_sgov_streak(signal_date),
avg_dollar_volume=avg_dollar_volume,
)
_append_row(
family="risk_off_alpha",
candidate=candidate,
rank_index=0,
total_count=1,
requested_cash_est=risk_off_budget_total,
complete=risk_off_budget_total > 0,
family_cap=1,
)
return annotated_idle_candidates, rows, candidate_by_key, virtual_cash
def _select_non_core_allocator_v2_rows(
self,
*,
rows: list[dict[str, Any]],
virtual_cash: float,
) -> list[dict[str, Any]]:
selected_counts: dict[str, int] = defaultdict(int)
selected_any = False
virtual_cash_remaining = float(virtual_cash)
ordered_indices = sorted(
range(len(rows)),
key=lambda idx: (
-float(rows[idx]["allocator_v2_marginal_score"]),
-float(rows[idx]["allocator_v2_native_rank_pct"]),
int(rows[idx]["_shadow_order_index"]),
),
)
for idx in ordered_indices:
row = rows[idx]
family = str(row["allocator_v2_family"])
family_cap = row["allocator_v2_family_cap"]
requested_cash_est = float(row["allocator_v2_requested_cash_est"] or 0.0)
if family_cap is not None and int(family_cap) >= 0 and selected_counts[family] >= int(family_cap):
row["allocator_v2_shadow_decision"] = "blocked_by_sleeve_cap"
continue
if requested_cash_est <= 0 or virtual_cash_remaining + 1e-9 < requested_cash_est:
row["allocator_v2_shadow_decision"] = (
"blocked_by_better_opportunity" if selected_any else "blocked_by_budget"
)
continue
row["allocator_v2_shadow_decision"] = "selected_shadow_v2"
row["allocator_v2_shadow_selected"] = True
virtual_cash_remaining = max(0.0, virtual_cash_remaining - requested_cash_est)
selected_counts[family] += 1
selected_any = True
return [rows[idx] for idx in ordered_indices]
def _record_non_core_allocator_v2_shadow_day(
self,
*,
date: dt.date,
portfolio_state: DailyPortfolioState,
idle_candidates: list[Candidate],
form4_payloads: list[dict[str, Any]],
ownership_payloads: list[dict[str, Any]],
) -> list[Candidate]:
if not self._non_core_allocator_v2_shadow_enabled():
return idle_candidates
annotated_idle_candidates, rows, _candidate_by_key, virtual_cash = self._build_non_core_allocator_v2_day_context(
date=date,
portfolio_state=portfolio_state,
idle_candidates=idle_candidates,
form4_payloads=form4_payloads,
ownership_payloads=ownership_payloads,
)
self._select_non_core_allocator_v2_rows(rows=rows, virtual_cash=virtual_cash)
date_indices: list[int] = []
for row in rows:
row.pop("_shadow_order_index", None)
self._non_core_allocator_shadow_rows.append(row)
date_indices.append(len(self._non_core_allocator_shadow_rows) - 1)
self._non_core_allocator_shadow_row_indices_by_date[date] = date_indices
return annotated_idle_candidates
def _execute_fixed_budget_non_core_candidate(
self,
*,
date: dt.date,
candidate: Candidate,
budget: float,
allow_parking_cash_release: bool,
max_parking_release: float | None = None,
) -> bool:
budget = max(0.0, float(budget))
if budget <= 0:
return False
symbol = str(candidate.symbol).upper()
open_symbols = {str(position.plan.candidate.symbol).upper() for position in self._open_positions}
if symbol in open_symbols:
return False
entry_bar = self.store.get_bar(symbol, date)
if entry_bar is None or entry_bar.get("open") is None:
return False
if self._cash + 1e-9 < budget and allow_parking_cash_release and self._get_parking_value(date) > 0:
shortfall = budget - self._cash
release_amount = shortfall
if max_parking_release is not None:
release_amount = min(release_amount, max(0.0, float(max_parking_release)))
if release_amount > 0:
self._liquidate_parking_for_cash(date, release_amount)
effective_budget = min(budget, self._cash)
if effective_budget <= 0:
return False
effective_exec = self._build_effective_execution_config(candidate)
estimated_fill = float(entry_bar["open"]) * (1.0 + effective_exec.slippage_bps_base / 10_000.0)
shares = int(effective_budget / estimated_fill) if estimated_fill > 0 else 0
if shares <= 0:
return False
event_date = candidate.event_date if candidate.event_date is not None else date
plan = PlannedOrder(
candidate=candidate,
shares=shares,
entry_price_limit=float(entry_bar["open"]),
stop_price=0.01,
target_price=float(entry_bar["open"]) * 100.0,
risk_dollars=0.0,
event_date=event_date,
timing_class=str(candidate.timing_class or "unknown"),
engine_id=str(candidate.engine_id or ""),
entry_timing_policy="next_open",
)
position = simulate_entry(plan, entry_bar, effective_exec)
if position is None:
return False
trade_cost = position.entry_price * position.shares_open
if trade_cost <= 0 or trade_cost > self._cash + 1e-9:
return False
self._cash -= trade_cost
self._open_positions.append(position)
return True
def _execute_non_core_allocator_v2_live_day(
self,
*,
date: dt.date,
drawdown_pct: float,
idle_candidates: list[Candidate],
form4_payloads: list[dict[str, Any]],
ownership_payloads: list[dict[str, Any]],
) -> None:
if not self._non_core_allocator_v2_live_enabled():
return
portfolio_state = self._refresh_portfolio_state(date, drawdown_pct)
annotated_idle_candidates, rows, candidate_by_key, virtual_cash = self._build_non_core_allocator_v2_day_context(
date=date,
portfolio_state=portfolio_state,
idle_candidates=idle_candidates,
form4_payloads=form4_payloads,
ownership_payloads=ownership_payloads,
)
ordered_rows = self._select_non_core_allocator_v2_rows(rows=rows, virtual_cash=virtual_cash)
idle_candidate_by_key = {
self._non_core_allocator_v2_candidate_key(candidate): candidate
for candidate in annotated_idle_candidates
}
for row in ordered_rows:
if not bool(row.get("allocator_v2_shadow_selected")):
continue
candidate_key = str(row.get("allocator_v2_candidate_key") or "")
family = str(row.get("allocator_v2_family") or "")
requested_cash_est = float(row.get("allocator_v2_requested_cash_est") or 0.0)
if family == "idle_alpha":
candidate = idle_candidate_by_key.get(candidate_key)
if candidate is None:
continue
current_state = self._refresh_portfolio_state(date, drawdown_pct)
macro_data = self.store.get_macro_for_date(date)
self._execute_candidate_entries(
date=date,
candidates=[candidate],
portfolio_state=current_state,
drawdown_pct=drawdown_pct,
macro_data=macro_data,
allow_same_day_cash_recycle=False,
allow_parking_cash_release=True,
)
continue
candidate = candidate_by_key.get(candidate_key)
if candidate is None:
continue
self._execute_fixed_budget_non_core_candidate(
date=date,
candidate=candidate,
budget=requested_cash_est,
allow_parking_cash_release=True,
max_parking_release=(requested_cash_est * 0.05 if family == "form4" else None),
)
def _finalize_non_core_allocator_v2_shadow_day(self, date: dt.date) -> None:
if not self._non_core_allocator_v2_shadow_enabled():
return
row_indices = self._non_core_allocator_shadow_row_indices_by_date.get(date, [])
if not row_indices:
return
live_candidate_keys: set[str] = set()
for position in self._open_positions:
if position.entry_date != date:
continue
candidate = position.plan.candidate
sleeve = str((candidate.features or {}).get("trade_sleeve") or "").strip().lower()
if sleeve in {"idle_alpha", "form4", "ownership", "risk_off_alpha"}:
live_candidate_keys.add(self._non_core_allocator_v2_candidate_key(candidate))
for trade in self._closed_trades:
if trade.entry_date != date:
continue
candidate = self._candidate_map.get(trade.trade_id)
if candidate is None:
continue
sleeve = str((candidate.features or {}).get("trade_sleeve") or "").strip().lower()
if sleeve in {"idle_alpha", "form4", "ownership", "risk_off_alpha"}:
live_candidate_keys.add(self._non_core_allocator_v2_candidate_key(candidate))
for idx in row_indices:
row = self._non_core_allocator_shadow_rows[idx]
live_selected = str(row.get("allocator_v2_candidate_key") or "") in live_candidate_keys
row["allocator_v2_live_selected"] = live_selected
row["allocator_v2_live_vs_shadow_disagree"] = bool(row.get("allocator_v2_shadow_selected")) != live_selected
def _execute_candidate_entries(
self,
*,
date: dt.date,
candidates: list[Candidate],
portfolio_state: DailyPortfolioState,
drawdown_pct: float,
macro_data: dict[str, Any] | None,
allow_same_day_cash_recycle: bool,
allow_parking_cash_release: bool,
) -> DailyPortfolioState:
active_bucket_ids = self._active_capital_bucket_ids_for_candidates(candidates)
for candidate in candidates:
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
date=date,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
)
plan = build_planned_order(
candidate=candidate,
portfolio_state=candidate_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 (
allow_same_day_cash_recycle
and plan.skip_reason == "insufficient_cash"
and self._attempt_same_day_cash_recycle(
date=date,
candidate=candidate,
portfolio_state=candidate_portfolio_state,
)
):
portfolio_state = self._refresh_portfolio_state(date, drawdown_pct)
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
date=date,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
)
plan = build_planned_order(
candidate=candidate,
portfolio_state=candidate_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],
)
shortfall = self._estimate_cash_shortfall(
candidate,
candidate_portfolio_state,
)
if (
allow_parking_cash_release
and plan.skip_reason == "insufficient_cash"
and self._liquidate_parking_for_cash(date, shortfall)
):
portfolio_state = self._refresh_portfolio_state(date, drawdown_pct)
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
date=date,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
)
plan = build_planned_order(
candidate=candidate,
portfolio_state=candidate_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
is_lookback = bool(candidate.features.get("is_lookback_entry", False))
bar = self.store.get_bar(candidate.symbol, date if is_lookback else candidate.execution_date)
if not is_lookback:
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 None:
self._release_add_on_reservation(candidate)
continue
pos.parent_position_id = candidate.parent_position_id
pos.is_add_on = candidate.is_add_on
if is_lookback:
pos.days_held = int(candidate.features.get("lookback_days_elapsed", 0))
self._open_positions.append(pos)
trade_cost = pos.entry_price * pos.shares_total
if allow_parking_cash_release and self._cash < trade_cost and self._get_parking_value(date) > 0:
shortfall = trade_cost - self._cash
if self._liquidate_parking_for_cash(date, shortfall):
portfolio_state = self._refresh_portfolio_state(date, drawdown_pct)
self._cash -= trade_cost
self._daily_new_risk_used += plan.risk_dollars
self._engine_daily_new_risk_used[candidate.engine_id] += plan.risk_dollars
portfolio_state = self._refresh_portfolio_state(date, drawdown_pct)
return portfolio_state
def _annotate_candidate_slate_features(self, candidates: list[Candidate]) -> None:
"""Attach same-day breadth/crowding metadata used by allocator scalers."""
if not candidates:
return
sector_counts = Counter(candidate.sector for candidate in candidates)
engine_counts = Counter(candidate.engine_id for candidate in candidates)
total_count = len(candidates)
unique_sector_count = len(sector_counts)
for candidate in candidates:
candidate.features["daily_candidate_count_selected"] = total_count
candidate.features["daily_unique_sector_count_selected"] = unique_sector_count
candidate.features["daily_sector_candidate_count_selected"] = sector_counts.get(candidate.sector, 0)
candidate.features["daily_engine_candidate_count_selected"] = engine_counts.get(candidate.engine_id, 0)
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:
if getattr(engine, "volatility_crush_only", False) and not self._volatility_crush_condition_met(engine, date):
return False
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 _previous_simulation_date(self, date: dt.date) -> dt.date | None:
idx = self._simulation_date_index.get(date)
if idx is None or idx <= 0:
return None
return self._simulation_dates[idx - 1]
def _volatility_crush_state_for_date(self, date: dt.date) -> dict[str, float] | None:
prev_date = self._previous_simulation_date(date)
if prev_date is None:
return None
prev_macro = self.store.get_macro_for_date(prev_date) or {}
curr_macro = self.store.get_macro_for_date(date) or {}
prev_vix = prev_macro.get("VIXCLS")
curr_vix = curr_macro.get("VIXCLS")
prev_spy = prev_macro.get("spy_close")
curr_spy = curr_macro.get("spy_close")
if None in (prev_vix, curr_vix, prev_spy, curr_spy):
return None
prev_vix_f = float(prev_vix)
curr_vix_f = float(curr_vix)
prev_spy_f = float(prev_spy)
curr_spy_f = float(curr_spy)
if prev_vix_f <= 0 or prev_spy_f <= 0:
return None
return {
"vix_drop_pct": (prev_vix_f - curr_vix_f) / prev_vix_f,
"spy_return": (curr_spy_f / prev_spy_f) - 1.0,
"prev_vix": prev_vix_f,
"curr_vix": curr_vix_f,
"prev_spy": prev_spy_f,
"curr_spy": curr_spy_f,
}
def _effective_engine_for_date(self, engine: Any, date: dt.date) -> Any:
has_crush_override = any(
getattr(engine, field_name, None) is not None
for field_name in (
"volatility_crush_score_threshold_override",
"volatility_crush_per_trade_risk_pct_override",
"volatility_crush_engine_risk_budget_pct_override",
"volatility_crush_macro_vix_max_override",
)
)
if not has_crush_override:
return engine
if getattr(engine, "volatility_crush_vix_drop_pct_min", None) is None:
return engine
crush_state = self._volatility_crush_state_for_date(date)
if not self._volatility_crush_condition_met(engine, date, crush_state=crush_state):
return engine
updates: dict[str, Any] = {}
if engine.volatility_crush_score_threshold_override is not None:
updates["score_threshold_override"] = engine.volatility_crush_score_threshold_override
if engine.volatility_crush_per_trade_risk_pct_override is not None:
updates["per_trade_risk_pct_override"] = engine.volatility_crush_per_trade_risk_pct_override
if engine.volatility_crush_engine_risk_budget_pct_override is not None:
updates["engine_risk_budget_pct"] = engine.volatility_crush_engine_risk_budget_pct_override
if engine.volatility_crush_macro_vix_max_override is not None:
updates["macro_vix_max"] = engine.volatility_crush_macro_vix_max_override
if not updates:
return engine
logger.debug(
"volatility_crush_engine_override",
date=date.isoformat(),
engine_id=getattr(engine, "engine_id", "unknown"),
vix_drop_pct=round(crush_state["vix_drop_pct"], 4),
spy_return=round(crush_state["spy_return"], 4),
updates=updates,
)
return engine.model_copy(update=updates)
def _volatility_crush_condition_met(
self,
engine: Any,
date: dt.date,
*,
crush_state: dict[str, float] | None = None,
) -> bool:
crush_min = getattr(engine, "volatility_crush_vix_drop_pct_min", None)
spy_min = getattr(engine, "volatility_crush_spy_return_min", None)
if crush_min is None:
return False
state = crush_state if crush_state is not None else self._volatility_crush_state_for_date(date)
if state is None:
return False
if state["vix_drop_pct"] < float(crush_min):
return False
if spy_min is not None and state["spy_return"] < float(spy_min):
return False
return True
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
attention_symbol = candidate.source_symbol or candidate.symbol
cache_key = (attention_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/{attention_symbol}",
params={"event_date": event_date.isoformat()},
timeout=30,
)
if response.status_code >= 400:
logger.debug(
"attention_fetch_failed",
symbol=attention_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=attention_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
active_bucket_ids = self._active_capital_bucket_ids_for_candidates(candidates)
ranked: list[tuple[float, float, int, Candidate]] = []
skipped: list[tuple[int, Candidate]] = []
for idx, candidate in enumerate(candidates):
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
date=portfolio_state.date,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
)
plan = build_planned_order(
candidate=candidate,
portfolio_state=candidate_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 _dividend_capture_enabled(self) -> bool:
cfg = self.config.dividend_capture
return bool(
cfg.enabled
and cfg.reserve_pct > 0
and cfg.max_positions > 0
and self._pit_dividend_calendar is not None
)
def _form4_capture_enabled(self) -> bool:
cfg = self.config.form4_capture
return bool(
cfg.enabled
and cfg.reserve_pct > 0
and cfg.max_positions > 0
and cfg.max_new_per_day > 0
and cfg.hold_days > 0
and self._pit_form4_calendar is not None
)
def _ownership_capture_enabled(self) -> bool:
cfg = self.config.ownership_capture
return bool(
cfg.enabled
and cfg.max_positions > 0
and cfg.max_new_per_day > 0
and cfg.hold_days > 0
and (cfg.max_idle_deploy_pct > 0 or cfg.reserve_pct > 0)
and self._pit_ownership_calendar is not None
)
def _risk_off_alpha_enabled(self) -> bool:
cfg = self.config.risk_off_alpha
return bool(
cfg.enabled
and len(cfg.symbols) > 0
and (float(cfg.reserve_pct) > 0 or float(cfg.max_idle_deploy_pct) > 0)
)
def _preview_effective_parking_target(self, date: dt.date) -> str | None:
macro = self.store.get_macro_for_date(date) or {}
if not macro:
return None
trend_state_before = self._parking_trend_sgov
gate_state_before = self._parking_gate_in_sgov
raw_target = self._compute_parking_target(date)
self._parking_trend_sgov = trend_state_before
self._parking_gate_in_sgov = gate_state_before
target = raw_target
if target == "sgov" and self.config.risk.cash_parking_gate_mode == "volatility":
crisis_target = self._evaluate_crisis_relay_target(macro)
if crisis_target is not None:
target = crisis_target
else:
relay_target = self._evaluate_defensive_relay_target(
date,
macro,
self.config.risk.cash_parking_symbol,
)
if relay_target is not None:
target = relay_target
bearish_sym = self.config.risk.cash_parking_bearish_symbol
if bearish_sym and target == "sgov":
risk_score = self._compute_parking_risk_score(macro)
if risk_score >= self.config.risk.cash_parking_bearish_threshold:
target = bearish_sym
return target
def _risk_off_alpha_sgov_streak(self, signal_date: dt.date) -> int:
streak = 0
cursor = signal_date
while cursor is not None:
if self._preview_effective_parking_target(cursor) != "sgov":
break
streak += 1
cursor = self._previous_simulation_date(cursor)
return streak
def _select_risk_off_alpha_symbol(
self,
signal_date: dt.date,
*,
current_symbol: str | None = None,
) -> str | None:
if not self._risk_off_alpha_enabled():
return None
if self._preview_effective_parking_target(signal_date) != "sgov":
return None
cfg = self.config.risk_off_alpha
if self._risk_off_alpha_sgov_streak(signal_date) < int(cfg.min_consecutive_sgov_days):
return None
macro = self.store.get_macro_for_date(signal_date) or {}
if not macro:
return None
if float(cfg.min_parking_risk_score or 0.0) > 0:
risk_score = float(self._compute_parking_risk_score(macro))
if risk_score < float(cfg.min_parking_risk_score):
return None
lookback_days = max(1, int(cfg.momentum_lookback_days or 20))
min_mom = float(cfg.min_symbol_momentum or 0.0)
candidates: list[tuple[str, float]] = []
for symbol in cfg.symbols:
normalized = str(symbol).lower().strip()
if not normalized:
continue
prefix = self._get_parking_signal_prefix(normalized)
momentum = macro.get(f"{prefix}_mom_{lookback_days}")
if momentum is None or float(momentum) < min_mom:
continue
candidates.append((normalized, float(momentum)))
if not candidates:
return None
candidates.sort(key=lambda item: item[1], reverse=True)
best_symbol, best_score = candidates[0]
if current_symbol:
current_normalized = str(current_symbol).lower()
current_score = next((score for symbol, score in candidates if symbol == current_normalized), None)
gap = float(cfg.rotation_momentum_gap or 0.0)
if current_score is not None and best_symbol != current_normalized and best_score <= current_score + gap:
return current_normalized
return best_symbol
def _effective_risk_off_alpha_reserve_pct(self, signal_date: dt.date) -> float:
cfg = self.config.risk_off_alpha
reserve_pct = float(cfg.reserve_pct or 0.0)
if not bool(cfg.adaptive_reserve_enabled):
return reserve_pct
macro = self.store.get_macro_for_date(signal_date) or {}
if not macro:
return reserve_pct
risk_score = float(self._compute_parking_risk_score(macro))
high_threshold = float(cfg.adaptive_reserve_score_high or 0.0)
mid_threshold = float(cfg.adaptive_reserve_score_mid or 0.0)
if high_threshold > 0 and risk_score >= high_threshold:
return float(cfg.adaptive_reserve_pct_high or reserve_pct)
if mid_threshold > 0 and risk_score >= mid_threshold:
return float(cfg.adaptive_reserve_pct_mid or reserve_pct)
return float(cfg.adaptive_reserve_pct_low or reserve_pct)
def _build_risk_off_alpha_candidate(
self,
*,
symbol: str,
date: dt.date,
signal_date: dt.date,
signal_momentum: float,
sgov_streak: int,
avg_dollar_volume: float,
) -> Candidate:
event_timestamp = dt.datetime.combine(signal_date, dt.time(0, 0), tzinfo=dt.timezone.utc)
return Candidate(
event_id=f"{_RISK_OFF_ALPHA_EVENT_TYPE}:{symbol}:{signal_date.isoformat()}",
symbol=symbol.upper(),
source_symbol=symbol.upper(),
score=float(signal_momentum) * 1_000_000.0 + float(sgov_streak),
sector="MACRO",
event_type=_RISK_OFF_ALPHA_EVENT_TYPE,
event_timestamp=event_timestamp,
event_date=signal_date,
filing_time_bucket="unknown",
timing_class="unknown",
reaction_date=date,
execution_date=date,
entry_price_est=float((self.store.get_bar(symbol.upper(), date) or {}).get("open") or 0.0),
avg_dollar_volume=avg_dollar_volume,
score_bucket="idle_risk_off_alpha",
engine_id=_RISK_OFF_ALPHA_ENGINE_ID,
entry_timing_policy="next_open",
engine_max_holding_days=int(self.config.risk_off_alpha.max_holding_days or 0) or None,
features={
"trade_sleeve": "risk_off_alpha",
"risk_off_alpha_signal_date": signal_date.isoformat(),
"risk_off_alpha_symbol": symbol.lower(),
"risk_off_alpha_signal_momentum": signal_momentum,
"risk_off_alpha_sgov_streak": sgov_streak,
},
)
def _process_risk_off_alpha_open_exits(self, date: dt.date) -> None:
if not self._open_positions:
return
signal_date = self._previous_simulation_date(date)
if signal_date is None:
return
remaining_positions: list[OpenPosition] = []
max_hold_days = max(0, int(self.config.risk_off_alpha.max_holding_days or 0))
for position in self._open_positions:
if position.plan.engine_id != _RISK_OFF_ALPHA_ENGINE_ID:
remaining_positions.append(position)
continue
symbol = str(position.plan.candidate.symbol).upper()
desired_symbol = self._select_risk_off_alpha_symbol(signal_date, current_symbol=symbol)
exit_reason: str | None = None
if desired_symbol is None:
exit_reason = "RISK_OFF_ALPHA_OFF"
elif str(desired_symbol).upper() != symbol:
exit_reason = "RISK_OFF_ALPHA_ROTATE"
elif max_hold_days > 0 and position.days_held >= max_hold_days:
exit_reason = "RISK_OFF_ALPHA_MAX_HOLD"
if exit_reason is None:
remaining_positions.append(position)
continue
bar = self.store.get_bar(symbol, date)
if bar is None or bar.get("open") 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=exit_reason,
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
self._open_positions = remaining_positions
def _enter_risk_off_alpha_positions(self, date: dt.date) -> None:
signal_date = self._previous_simulation_date(date)
if signal_date is None:
return
desired_symbol = self._select_risk_off_alpha_symbol(signal_date)
if desired_symbol is None:
return
existing_positions = [
position for position in self._open_positions
if position.plan.engine_id == _RISK_OFF_ALPHA_ENGINE_ID
]
if any(str(position.plan.candidate.symbol).upper() == str(desired_symbol).upper() for position in existing_positions):
return
if existing_positions:
return
cfg = self.config.risk_off_alpha
market_value = self._compute_positions_market_value(date)
equity_est = self._sleeve_equity_est(date)
if equity_est <= 0:
return
effective_reserve_pct = self._effective_risk_off_alpha_reserve_pct(signal_date)
if effective_reserve_pct > 0:
reserve_budget = equity_est * effective_reserve_pct
if reserve_budget <= 0:
return
if self._cash + 1e-9 < reserve_budget and self._get_parking_value(date) > 0:
self._liquidate_parking_for_cash(date, reserve_budget - self._cash)
total_budget = min(reserve_budget, self._cash)
else:
cash_ratio = self._cash / equity_est
if cash_ratio < float(cfg.min_cash_ratio_for_overlay):
return
total_budget = self._cash * min(1.0, max(0.0, float(cfg.max_idle_deploy_pct)))
if total_budget <= 0:
return
symbol = str(desired_symbol).upper()
entry_bar = self.store.get_bar(symbol, date)
if entry_bar is None or entry_bar.get("open") is None:
return
avg_dollar_volume = float(self.store.get_market_features(symbol, date).get("avg_dollar_volume_20d") or 0.0)
signal_macro = self.store.get_macro_for_date(signal_date) or {}
signal_momentum = float(signal_macro.get(f"{symbol.lower()}_mom_{max(1, int(cfg.momentum_lookback_days or 20))}") or 0.0)
candidate = self._build_risk_off_alpha_candidate(
symbol=symbol,
date=date,
signal_date=signal_date,
signal_momentum=signal_momentum,
sgov_streak=self._risk_off_alpha_sgov_streak(signal_date),
avg_dollar_volume=avg_dollar_volume,
)
candidate.features["risk_off_alpha_reserve_pct"] = effective_reserve_pct
effective_exec = self._build_effective_execution_config(candidate)
estimated_fill = float(entry_bar["open"]) * (1.0 + effective_exec.slippage_bps_base / 10_000.0)
shares = int(total_budget / estimated_fill) if estimated_fill > 0 else 0
if shares <= 0:
return
plan = PlannedOrder(
candidate=candidate,
shares=shares,
entry_price_limit=float(entry_bar["open"]),
stop_price=0.01,
target_price=float(entry_bar["open"]) * 100.0,
risk_dollars=0.0,
event_date=signal_date,
timing_class="unknown",
engine_id=_RISK_OFF_ALPHA_ENGINE_ID,
entry_timing_policy="next_open",
)
position = simulate_entry(plan, entry_bar, effective_exec)
if position is None:
return
trade_cost = position.entry_price * position.shares_open
if trade_cost <= 0 or trade_cost > self._cash + 1e-9:
return
self._cash -= trade_cost
self._open_positions.append(position)
def _process_dividend_capture_open_exits(self, date: dt.date) -> None:
if not self._open_positions:
return
remaining_positions: list[OpenPosition] = []
for position in self._open_positions:
if position.plan.engine_id != _DIVIDEND_CAPTURE_ENGINE_ID:
remaining_positions.append(position)
continue
bar = self.store.get_bar(position.plan.candidate.symbol, date)
if bar is None or bar.get("open") is None or float(bar.get("open") or 0.0) <= 0:
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="DIVIDEND_CAPTURE",
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
self._open_positions = remaining_positions
def _select_dividend_capture_candidates(
self,
date: dt.date,
next_date: dt.date,
) -> list[dict[str, Any]]:
if not self._dividend_capture_enabled():
return []
cfg = self.config.dividend_capture
known_entries = self._pit_dividend_calendar.get_known_upcoming_ex_dividends(
as_of_date=date,
allowed_ex_dates=[next_date],
symbols=self.store._bars.keys(),
)
if not known_entries:
return []
open_symbols = {
str(position.plan.candidate.symbol).upper()
for position in self._open_positions
}
if self._parking_current_symbol:
open_symbols.add(str(self._parking_current_symbol).upper())
candidates: list[dict[str, Any]] = []
for symbol, entry in known_entries.items():
normalized_symbol = str(symbol).upper()
if normalized_symbol in open_symbols:
continue
entry_bar = self.store.get_bar(normalized_symbol, date)
exit_bar = self.store.get_bar(normalized_symbol, next_date)
if (
entry_bar is None
or exit_bar is None
or entry_bar.get("close") is None
or exit_bar.get("open") is None
):
continue
entry_close = float(entry_bar.get("close") or 0.0)
exit_open = float(exit_bar.get("open") or 0.0)
if entry_close <= 0 or exit_open <= 0:
continue
dividend_yield_pct = float(entry.amount) / entry_close
if dividend_yield_pct < float(cfg.min_dividend_yield_pct):
continue
max_yield_pct = cfg.max_dividend_yield_pct
if max_yield_pct is not None and dividend_yield_pct > float(max_yield_pct):
continue
features = self.store.get_market_features(normalized_symbol, date)
avg_dollar_volume = float(features.get("avg_dollar_volume_20d") or 0.0)
if avg_dollar_volume < float(cfg.min_avg_dollar_volume):
continue
candidates.append(
{
"symbol": normalized_symbol,
"entry_close": entry_close,
"exit_open": exit_open,
"avg_dollar_volume": avg_dollar_volume,
"dividend_amount": float(entry.amount),
"dividend_yield_pct": dividend_yield_pct,
"entry": entry,
}
)
candidates.sort(
key=lambda item: (
float(item["dividend_yield_pct"]),
float(item["avg_dollar_volume"]),
),
reverse=True,
)
return candidates[: max(0, int(cfg.max_positions))]
def _build_dividend_capture_candidate(
self,
*,
symbol: str,
date: dt.date,
next_date: dt.date,
entry_close: float,
avg_dollar_volume: float,
dividend_amount: float,
dividend_yield_pct: float,
) -> Candidate:
event_timestamp = dt.datetime.combine(next_date, dt.time(0, 0), tzinfo=dt.timezone.utc)
return Candidate(
event_id=f"{_DIVIDEND_CAPTURE_EVENT_TYPE}:{symbol}:{date.isoformat()}",
symbol=symbol,
source_symbol=symbol,
score=float(dividend_yield_pct) * 100.0,
sector="DIVIDEND",
event_type=_DIVIDEND_CAPTURE_EVENT_TYPE,
event_timestamp=event_timestamp,
event_date=next_date,
filing_time_bucket="post_market",
timing_class="after_close",
reaction_date=date,
execution_date=date,
entry_price_est=entry_close,
avg_dollar_volume=avg_dollar_volume,
score_bucket="idle_dividend",
engine_id=_DIVIDEND_CAPTURE_ENGINE_ID,
entry_timing_policy="reaction_close",
engine_max_holding_days=3,
features={
"dividend_amount": dividend_amount,
"dividend_yield_pct": dividend_yield_pct,
"ex_dividend_date": next_date.isoformat(),
},
)
def _enter_dividend_capture_positions(self, date: dt.date) -> None:
next_date = self._next_trading_day.get(date)
if next_date is None:
return
selected = self._select_dividend_capture_candidates(date, next_date)
if not selected:
return
cfg = self.config.dividend_capture
market_value = self._compute_positions_market_value(date)
equity_est = self._sleeve_equity_est(date)
reserve_budget = equity_est * float(cfg.reserve_pct)
if reserve_budget <= 0:
return
if self._cash + 1e-9 < reserve_budget and self._get_parking_value(date) > 0:
self._liquidate_parking_for_cash(date, reserve_budget - self._cash)
total_budget = min(reserve_budget, self._cash)
if total_budget <= 0:
return
per_position_budget = total_budget / max(1, len(selected))
for payload in selected:
candidate = self._build_dividend_capture_candidate(
symbol=str(payload["symbol"]),
date=date,
next_date=next_date,
entry_close=float(payload["entry_close"]),
avg_dollar_volume=float(payload["avg_dollar_volume"]),
dividend_amount=float(payload["dividend_amount"]),
dividend_yield_pct=float(payload["dividend_yield_pct"]),
)
effective_exec = self._build_effective_execution_config(candidate)
estimated_fill = float(payload["entry_close"]) * (1.0 + effective_exec.slippage_bps_base / 10_000.0)
shares = int(per_position_budget / estimated_fill) if estimated_fill > 0 else 0
if shares <= 0:
continue
plan = PlannedOrder(
candidate=candidate,
shares=shares,
entry_price_limit=float(payload["entry_close"]),
stop_price=max(0.01, float(payload["entry_close"]) * 0.5),
target_price=float(payload["entry_close"]) * 2.0,
risk_dollars=0.0,
event_date=next_date,
timing_class="after_close",
engine_id=_DIVIDEND_CAPTURE_ENGINE_ID,
entry_timing_policy="reaction_close",
)
entry_bar = self.store.get_bar(candidate.symbol, date)
position = simulate_entry(plan, entry_bar, effective_exec)
if position is None:
continue
trade_cost = position.entry_price * position.shares_open
if trade_cost <= 0 or trade_cost > self._cash + 1e-9:
continue
self._cash -= trade_cost
self._open_positions.append(position)
def _first_trading_day_after(self, target_date: dt.date) -> dt.date | None:
idx = bisect_right(self._simulation_dates, target_date)
if idx >= len(self._simulation_dates):
return None
return self._simulation_dates[idx]
def _select_form4_capture_candidates(self, date: dt.date) -> list[dict[str, Any]]:
if not self._form4_capture_enabled():
return []
date_index = self._simulation_date_index.get(date)
if date_index is None or date_index <= 0:
return []
prev_trading_date = self._simulation_dates[date_index - 1]
start_filing_date = prev_trading_date
end_filing_date = date - dt.timedelta(days=1)
if end_filing_date < start_filing_date:
return []
cfg = self.config.form4_capture
filing_events = self._pit_form4_calendar.get_events_between(
start_filing_date=start_filing_date,
end_filing_date=end_filing_date,
symbols=self.store._bars.keys(),
)
if not filing_events:
return []
open_symbols = {
str(position.plan.candidate.symbol).upper()
for position in self._open_positions
}
if self._parking_current_symbol:
open_symbols.add(str(self._parking_current_symbol).upper())
loss_cooldown_days = max(0, int(cfg.symbol_cooldown_days_after_loss or 0))
symbol_max_entries = (
int(cfg.symbol_max_entries_in_lookback)
if cfg.symbol_max_entries_in_lookback is not None
else 0
)
symbol_entry_lookback_days = max(1, int(cfg.symbol_entry_lookback_days or 365))
rows: list[dict[str, Any]] = []
for event in filing_events:
if self._first_trading_day_after(event.filing_date) != date:
continue
if event.owner_count < int(cfg.min_owner_count):
continue
if int(event.transaction_count) < int(cfg.min_transaction_count):
continue
if int(getattr(event, "c_suite_count", 0) or 0) < int(cfg.min_c_suite_count):
continue
if int(getattr(event, "cfo_count", 0) or 0) < int(cfg.min_cfo_count):
continue
if float(getattr(event, "role_weight_score", 0.0) or 0.0) < float(cfg.min_role_weight_score):
continue
if event.total_value < float(cfg.min_total_value):
continue
if cfg.max_total_value is not None and event.total_value > float(cfg.max_total_value):
continue
if event.weighted_purchase_pct < float(cfg.min_purchase_pct):
continue
if int(event.event_day_count) < int(cfg.min_event_day_count):
continue
if cfg.max_lag_days is not None and event.max_lag_days is not None and event.max_lag_days > int(cfg.max_lag_days):
continue
if cfg.max_min_lag_days is not None and event.min_lag_days is not None and event.min_lag_days > int(cfg.max_min_lag_days):
continue
if (
cfg.max_transaction_span_days is not None
and int(getattr(event, "transaction_span_days", 0) or 0) > int(cfg.max_transaction_span_days)
):
continue
if bool(cfg.require_officer_or_director) and not bool(event.has_officer_or_director):
continue
symbol = str(event.symbol).upper()
if symbol in open_symbols:
continue
if loss_cooldown_days > 0:
recent_loss_found = False
for trade in self._closed_trades:
candidate = self._candidate_map.get(trade.trade_id)
if candidate is None or candidate.engine_id != _FORM4_CAPTURE_ENGINE_ID:
continue
if str(candidate.symbol).upper() != symbol:
continue
if float(trade.net_pnl) >= 0:
continue
if (date - trade.exit_date).days <= loss_cooldown_days:
recent_loss_found = True
break
if recent_loss_found:
continue
if symbol_max_entries > 0:
recent_entry_count = 0
for trade in self._closed_trades:
candidate = self._candidate_map.get(trade.trade_id)
if candidate is None or candidate.engine_id != _FORM4_CAPTURE_ENGINE_ID:
continue
if str(candidate.symbol).upper() != symbol:
continue
if (date - trade.entry_date).days > symbol_entry_lookback_days:
continue
recent_entry_count += 1
if recent_entry_count >= symbol_max_entries:
break
if recent_entry_count >= symbol_max_entries:
continue
bar = self.store.get_bar(symbol, date)
if bar is None or bar.get("open") is None:
continue
features = self.store.get_market_features(symbol, date)
avg_dollar_volume = float(features.get("avg_dollar_volume_20d") or 0.0)
rows.append(
{
"symbol": symbol,
"filing_date": event.filing_date,
"owner_count": int(event.owner_count),
"transaction_count": int(event.transaction_count),
"event_day_count": int(event.event_day_count),
"total_value": float(event.total_value),
"weighted_purchase_pct": float(event.weighted_purchase_pct),
"max_lag_days": event.max_lag_days,
"min_lag_days": event.min_lag_days,
"transaction_span_days": int(getattr(event, "transaction_span_days", 0) or 0),
"officer_count": int(getattr(event, "officer_count", 0) or 0),
"director_count": int(getattr(event, "director_count", 0) or 0),
"ten_percent_owner_count": int(getattr(event, "ten_percent_owner_count", 0) or 0),
"ceo_count": int(getattr(event, "ceo_count", 0) or 0),
"cfo_count": int(getattr(event, "cfo_count", 0) or 0),
"c_suite_count": int(getattr(event, "c_suite_count", 0) or 0),
"role_weight_score": float(getattr(event, "role_weight_score", 0.0) or 0.0),
"has_officer_or_director": bool(event.has_officer_or_director),
"avg_dollar_volume": avg_dollar_volume,
}
)
rows.sort(
key=lambda item: (
-float(item.get("role_weight_score") or 0.0),
-int(item.get("c_suite_count") or 0),
-int(item.get("cfo_count") or 0),
-int(item["transaction_count"]),
-int(item["owner_count"]),
int(item.get("transaction_span_days") or 0),
999999 if item["min_lag_days"] is None else int(item["min_lag_days"]),
-float(item["weighted_purchase_pct"]),
-float(item["total_value"]),
),
)
return rows
def _build_form4_capture_candidate(
self,
*,
symbol: str,
date: dt.date,
filing_date: dt.date,
avg_dollar_volume: float,
owner_count: int,
transaction_count: int,
event_day_count: int,
total_value: float,
weighted_purchase_pct: float,
max_lag_days: int | None,
min_lag_days: int | None,
transaction_span_days: int,
officer_count: int,
director_count: int,
ten_percent_owner_count: int,
ceo_count: int,
cfo_count: int,
c_suite_count: int,
role_weight_score: float,
has_officer_or_director: bool,
) -> Candidate:
event_timestamp = dt.datetime.combine(filing_date, dt.time(0, 0), tzinfo=dt.timezone.utc)
return Candidate(
event_id=f"{_FORM4_CAPTURE_EVENT_TYPE}:{symbol}:{filing_date.isoformat()}",
symbol=symbol,
source_symbol=symbol,
score=float(role_weight_score) * 1_000_000_000.0 + float(owner_count) * 100_000_000.0 + float(total_value),
sector="INSIDER",
event_type=_FORM4_CAPTURE_EVENT_TYPE,
event_timestamp=event_timestamp,
event_date=filing_date,
filing_time_bucket="unknown",
timing_class="unknown",
reaction_date=date,
execution_date=date,
entry_price_est=float((self.store.get_bar(symbol, date) or {}).get("open") or 0.0),
avg_dollar_volume=avg_dollar_volume,
score_bucket="idle_form4",
engine_id=_FORM4_CAPTURE_ENGINE_ID,
entry_timing_policy="next_open",
engine_early_failure_close_below_entry_and_reaction_close=(
not bool(self.config.form4_capture.disable_day1_early_failure)
),
engine_early_failure_no_progress_days=self.config.form4_capture.no_progress_days_override,
engine_early_failure_no_progress_r=self.config.form4_capture.no_progress_r_override,
engine_early_failure_no_progress_fraction=self.config.form4_capture.no_progress_fraction_override,
engine_max_holding_days=int(self.config.form4_capture.hold_days),
features={
"trade_sleeve": "form4",
"form4_filing_date": filing_date.isoformat(),
"form4_owner_count": owner_count,
"form4_transaction_count": transaction_count,
"form4_event_day_count": event_day_count,
"form4_total_value": total_value,
"form4_weighted_purchase_pct": weighted_purchase_pct,
"form4_max_lag_days": max_lag_days,
"form4_min_lag_days": min_lag_days,
"form4_transaction_span_days": transaction_span_days,
"form4_officer_count": officer_count,
"form4_director_count": director_count,
"form4_ten_percent_owner_count": ten_percent_owner_count,
"form4_ceo_count": ceo_count,
"form4_cfo_count": cfo_count,
"form4_c_suite_count": c_suite_count,
"form4_role_weight_score": role_weight_score,
"form4_has_officer_or_director": has_officer_or_director,
},
)
def _enter_form4_capture_positions(self, date: dt.date) -> None:
selected = self._select_form4_capture_candidates(date)
if not selected:
return
cfg = self.config.form4_capture
existing_form4_positions = sum(
1 for position in self._open_positions
if position.plan.engine_id == _FORM4_CAPTURE_ENGINE_ID
)
available_slots = max(0, int(cfg.max_positions) - existing_form4_positions)
if available_slots <= 0:
return
selected = selected[: min(int(cfg.max_new_per_day), available_slots)]
if not selected:
return
market_value = self._compute_positions_market_value(date)
equity_est = self._sleeve_equity_est(date)
reserve_budget = equity_est * float(cfg.reserve_pct)
total_budget = min(reserve_budget, self._cash)
if total_budget <= 0:
return
per_position_budget = total_budget / len(selected)
for payload in selected:
symbol = str(payload["symbol"])
candidate = self._build_form4_capture_candidate(
symbol=symbol,
date=date,
filing_date=payload["filing_date"],
avg_dollar_volume=float(payload["avg_dollar_volume"]),
owner_count=int(payload["owner_count"]),
transaction_count=int(payload["transaction_count"]),
event_day_count=int(payload["event_day_count"]),
total_value=float(payload["total_value"]),
weighted_purchase_pct=float(payload["weighted_purchase_pct"]),
max_lag_days=payload["max_lag_days"],
min_lag_days=payload.get("min_lag_days"),
transaction_span_days=int(payload.get("transaction_span_days") or 0),
officer_count=int(payload.get("officer_count") or 0),
director_count=int(payload.get("director_count") or 0),
ten_percent_owner_count=int(payload.get("ten_percent_owner_count") or 0),
ceo_count=int(payload.get("ceo_count") or 0),
cfo_count=int(payload.get("cfo_count") or 0),
c_suite_count=int(payload.get("c_suite_count") or 0),
role_weight_score=float(payload.get("role_weight_score") or 0.0),
has_officer_or_director=bool(payload["has_officer_or_director"]),
)
entry_bar = self.store.get_bar(symbol, date)
if entry_bar is None or entry_bar.get("open") is None:
continue
effective_exec = self._build_effective_execution_config(candidate)
estimated_fill = float(entry_bar["open"]) * (1.0 + effective_exec.slippage_bps_base / 10_000.0)
shares = int(per_position_budget / estimated_fill) if estimated_fill > 0 else 0
if shares <= 0:
continue
plan = PlannedOrder(
candidate=candidate,
shares=shares,
entry_price_limit=float(entry_bar["open"]),
stop_price=0.01,
target_price=float(entry_bar["open"]) * 100.0,
risk_dollars=0.0,
event_date=payload["filing_date"],
timing_class="unknown",
engine_id=_FORM4_CAPTURE_ENGINE_ID,
entry_timing_policy="next_open",
)
position = simulate_entry(plan, entry_bar, effective_exec)
if position is None:
continue
trade_cost = position.entry_price * position.shares_open
if trade_cost <= 0 or trade_cost > self._cash + 1e-9:
continue
self._cash -= trade_cost
self._open_positions.append(position)
def _select_ownership_capture_candidates(self, date: dt.date) -> list[dict[str, Any]]:
if not self._ownership_capture_enabled():
return []
date_index = self._simulation_date_index.get(date)
if date_index is None or date_index <= 0:
return []
prev_trading_date = self._simulation_dates[date_index - 1]
start_filing_date = prev_trading_date
end_filing_date = date - dt.timedelta(days=1)
if end_filing_date < start_filing_date:
return []
cfg = self.config.ownership_capture
filing_events = self._pit_ownership_calendar.get_events_between(
start_filing_date=start_filing_date,
end_filing_date=end_filing_date,
symbols=self.store._bars.keys(),
)
if not filing_events:
return []
allowed_form_groups = {str(group).strip().upper() for group in cfg.form_groups if str(group).strip()}
open_symbols = {
str(position.plan.candidate.symbol).upper()
for position in self._open_positions
}
if self._parking_current_symbol:
open_symbols.add(str(self._parking_current_symbol).upper())
best_by_symbol: dict[str, dict[str, Any]] = {}
loss_cooldown_days = max(0, int(cfg.symbol_cooldown_days_after_loss or 0))
symbol_max_entries = (
int(cfg.symbol_max_entries_in_lookback)
if cfg.symbol_max_entries_in_lookback is not None
else 0
)
symbol_entry_lookback_days = max(1, int(cfg.symbol_entry_lookback_days or 365))
for event in filing_events:
if self._first_trading_day_after(event.filing_date) != date:
continue
form_group = "13D" if "13D" in str(event.form_type).upper() else "13G"
if allowed_form_groups and form_group not in allowed_form_groups:
continue
if bool(cfg.require_amendment) and not bool(event.is_amendment):
continue
if bool(cfg.require_initial) and not bool(event.is_initial_for_owner):
continue
if bool(cfg.require_activist) and not bool(event.activist_flag):
continue
if bool(cfg.require_13g_to_13d_transition) and not bool(event.is_13g_to_13d_transition):
continue
if float(event.percent_owned or 0.0) < float(cfg.min_percent_owned):
continue
if float(cfg.min_percent_delta_points) > 0 and float(event.percent_delta_points or 0.0) < float(cfg.min_percent_delta_points):
continue
purpose_text = str(event.purpose_text or "")
runtime_housekeeping = False
runtime_structural = False
if purpose_text:
lowered_purpose = purpose_text.lower()
runtime_housekeeping = any(
phrase in lowered_purpose for phrase in _OWNERSHIP_RUNTIME_HOUSEKEEPING_PHRASES
)
runtime_structural = any(
phrase in lowered_purpose for phrase in _OWNERSHIP_RUNTIME_STRUCTURAL_PHRASES
)
if bool(cfg.exclude_housekeeping_purpose) and (
bool(event.purpose_housekeeping_flag) or runtime_housekeeping
):
continue
if bool(cfg.exclude_structural_exchange_purpose) and runtime_structural:
continue
if (
cfg.min_strength_score is not None
and int(event.ownership_strength_score or 0) < int(cfg.min_strength_score)
):
continue
symbol = str(event.symbol).upper()
if symbol in open_symbols:
continue
if loss_cooldown_days > 0:
recent_loss_found = False
for trade in self._closed_trades:
candidate = self._candidate_map.get(trade.trade_id)
if candidate is None or candidate.engine_id != _OWNERSHIP_CAPTURE_ENGINE_ID:
continue
if str(candidate.symbol).upper() != symbol:
continue
if float(trade.net_pnl) >= 0:
continue
if (date - trade.exit_date).days <= loss_cooldown_days:
recent_loss_found = True
break
if recent_loss_found:
continue
if symbol_max_entries > 0:
recent_entry_count = 0
for trade in self._closed_trades:
candidate = self._candidate_map.get(trade.trade_id)
if candidate is None or candidate.engine_id != _OWNERSHIP_CAPTURE_ENGINE_ID:
continue
if str(candidate.symbol).upper() != symbol:
continue
if (date - trade.entry_date).days > symbol_entry_lookback_days:
continue
recent_entry_count += 1
if recent_entry_count >= symbol_max_entries:
break
if recent_entry_count >= symbol_max_entries:
continue
bar = self.store.get_bar(symbol, date)
if bar is None or bar.get("open") is None:
continue
features = self.store.get_market_features(symbol, date)
avg_dollar_volume = float(features.get("avg_dollar_volume_20d") or 0.0)
if avg_dollar_volume < float(cfg.min_avg_dollar_volume):
continue
sort_key = (
int(event.ownership_strength_score or 0),
1 if bool(event.activist_flag) else 0,
float(event.percent_delta_points or 0.0),
float(event.percent_owned or 0.0),
)
row = {
"symbol": symbol,
"filing_date": event.filing_date,
"form_type": str(event.form_type).upper(),
"owner_name": event.owner_name,
"owner_key": event.owner_key,
"percent_owned": float(event.percent_owned or 0.0),
"aggregate_shares": float(event.aggregate_shares or 0.0),
"purpose_text": event.purpose_text,
"purpose_housekeeping_flag": bool(event.purpose_housekeeping_flag or runtime_housekeeping),
"ownership_structural_exchange_flag": bool(runtime_structural),
"activist_flag": bool(event.activist_flag),
"is_amendment": bool(event.is_amendment),
"prior_percent_owned": (
float(event.prior_percent_owned)
if event.prior_percent_owned is not None
else None
),
"percent_delta_points": (
float(event.percent_delta_points)
if event.percent_delta_points is not None
else None
),
"prior_form_group": event.prior_form_group,
"is_initial_for_owner": bool(event.is_initial_for_owner),
"is_13g_to_13d_transition": bool(event.is_13g_to_13d_transition),
"ownership_strength_score": int(event.ownership_strength_score or 0),
"avg_dollar_volume": avg_dollar_volume,
"_sort": sort_key,
}
existing = best_by_symbol.get(symbol)
if existing is None or tuple(row["_sort"]) > tuple(existing["_sort"]):
best_by_symbol[symbol] = row
rows = list(best_by_symbol.values())
rows.sort(key=lambda item: item["_sort"], reverse=True)
for row in rows:
row.pop("_sort", None)
return rows
def _build_ownership_capture_candidate(
self,
*,
symbol: str,
date: dt.date,
filing_date: dt.date,
form_type: str,
percent_owned: float,
aggregate_shares: float,
activist_flag: bool,
is_amendment: bool,
prior_percent_owned: float | None,
percent_delta_points: float | None,
prior_form_group: str | None,
is_initial_for_owner: bool,
is_13g_to_13d_transition: bool,
avg_dollar_volume: float,
owner_name: str | None,
owner_key: str | None,
purpose_text: str | None,
purpose_housekeeping_flag: bool,
ownership_strength_score: int,
) -> Candidate:
event_timestamp = dt.datetime.combine(filing_date, dt.time(0, 0), tzinfo=dt.timezone.utc)
score = (
(1_000_000_000.0 if activist_flag else 0.0)
+ float(percent_delta_points or 0.0) * 100_000_000.0
+ float(percent_owned or 0.0) * 1_000_000.0
)
return Candidate(
event_id=f"{_OWNERSHIP_CAPTURE_EVENT_TYPE}:{symbol}:{filing_date.isoformat()}:{form_type}",
symbol=symbol,
source_symbol=symbol,
score=score,
sector="OWNERSHIP",
event_type=_OWNERSHIP_CAPTURE_EVENT_TYPE,
event_timestamp=event_timestamp,
event_date=filing_date,
filing_time_bucket="unknown",
timing_class="unknown",
reaction_date=date,
execution_date=date,
entry_price_est=float((self.store.get_bar(symbol, date) or {}).get("open") or 0.0),
avg_dollar_volume=avg_dollar_volume,
score_bucket="idle_ownership",
engine_id=_OWNERSHIP_CAPTURE_ENGINE_ID,
entry_timing_policy="next_open",
engine_early_failure_close_below_entry_and_reaction_close=(
not bool(self.config.ownership_capture.disable_day1_early_failure)
),
engine_early_failure_no_progress_days=self.config.ownership_capture.no_progress_days_override,
engine_early_failure_no_progress_r=self.config.ownership_capture.no_progress_r_override,
engine_early_failure_no_progress_fraction=self.config.ownership_capture.no_progress_fraction_override,
engine_max_holding_days=int(self.config.ownership_capture.hold_days),
features={
"trade_sleeve": "ownership",
"ownership_filing_date": filing_date.isoformat(),
"ownership_form_type": form_type,
"ownership_percent_owned": percent_owned,
"ownership_aggregate_shares": aggregate_shares,
"ownership_purpose_text": purpose_text,
"ownership_purpose_housekeeping_flag": purpose_housekeeping_flag,
"ownership_activist_flag": activist_flag,
"ownership_is_amendment": is_amendment,
"ownership_prior_percent_owned": prior_percent_owned,
"ownership_percent_delta_points": percent_delta_points,
"ownership_prior_form_group": prior_form_group,
"ownership_is_initial_for_owner": is_initial_for_owner,
"ownership_is_13g_to_13d_transition": is_13g_to_13d_transition,
"ownership_strength_score": ownership_strength_score,
"ownership_owner_name": owner_name,
"ownership_owner_key": owner_key,
},
)
def _enter_ownership_capture_positions(self, date: dt.date) -> None:
selected = self._select_ownership_capture_candidates(date)
if not selected:
return
cfg = self.config.ownership_capture
existing_positions = sum(
1 for position in self._open_positions
if position.plan.engine_id == _OWNERSHIP_CAPTURE_ENGINE_ID
)
available_slots = max(0, int(cfg.max_positions) - existing_positions)
if available_slots <= 0:
return
selected = selected[: min(int(cfg.max_new_per_day), available_slots)]
if not selected:
return
market_value = self._compute_positions_market_value(date)
equity_est = self._sleeve_equity_est(date)
if equity_est <= 0:
return
if float(cfg.reserve_pct) > 0:
reserve_budget = equity_est * float(cfg.reserve_pct)
if reserve_budget <= 0:
return
if self._cash + 1e-9 < reserve_budget and self._get_parking_value(date) > 0:
self._liquidate_parking_for_cash(date, reserve_budget - self._cash)
total_budget = min(reserve_budget, self._cash)
extra_idle_deploy_pct = min(
1.0,
max(0.0, float(cfg.extra_idle_deploy_pct_above_reserve or 0.0)),
)
if extra_idle_deploy_pct > 0 and equity_est > 0:
cash_ratio = self._cash / equity_est
if cash_ratio >= float(cfg.min_cash_ratio_for_overlay):
remaining_cash = max(0.0, self._cash - total_budget)
total_budget += remaining_cash * extra_idle_deploy_pct
else:
cash_ratio = self._cash / equity_est
if cash_ratio < float(cfg.min_cash_ratio_for_overlay):
return
total_budget = self._cash * min(1.0, max(0.0, float(cfg.max_idle_deploy_pct)))
if total_budget <= 0:
return
selected_candidates: list[tuple[dict[str, Any], Candidate]] = []
for payload in selected:
symbol = str(payload["symbol"])
candidate = self._build_ownership_capture_candidate(
symbol=symbol,
date=date,
filing_date=payload["filing_date"],
form_type=str(payload["form_type"]),
percent_owned=float(payload["percent_owned"]),
aggregate_shares=float(payload["aggregate_shares"]),
activist_flag=bool(payload["activist_flag"]),
is_amendment=bool(payload["is_amendment"]),
prior_percent_owned=payload["prior_percent_owned"],
percent_delta_points=payload["percent_delta_points"],
prior_form_group=payload["prior_form_group"],
is_initial_for_owner=bool(payload["is_initial_for_owner"]),
is_13g_to_13d_transition=bool(payload["is_13g_to_13d_transition"]),
avg_dollar_volume=float(payload["avg_dollar_volume"]),
owner_name=payload.get("owner_name"),
owner_key=payload.get("owner_key"),
purpose_text=payload.get("purpose_text"),
purpose_housekeeping_flag=bool(payload.get("purpose_housekeeping_flag")),
ownership_strength_score=int(payload.get("ownership_strength_score") or 0),
)
selected_candidates.append((payload, candidate))
if not selected_candidates:
return
per_position_budgets = [total_budget / len(selected_candidates)] * len(selected_candidates)
for (payload, candidate), per_position_budget in zip(selected_candidates, per_position_budgets, strict=False):
symbol = str(payload["symbol"])
entry_bar = self.store.get_bar(symbol, date)
if entry_bar is None or entry_bar.get("open") is None:
continue
effective_exec = self._build_effective_execution_config(candidate)
estimated_fill = float(entry_bar["open"]) * (1.0 + effective_exec.slippage_bps_base / 10_000.0)
shares = int(per_position_budget / estimated_fill) if estimated_fill > 0 else 0
if shares <= 0:
continue
plan = PlannedOrder(
candidate=candidate,
shares=shares,
entry_price_limit=float(entry_bar["open"]),
stop_price=0.01,
target_price=float(entry_bar["open"]) * 100.0,
risk_dollars=0.0,
event_date=payload["filing_date"],
timing_class="unknown",
engine_id=_OWNERSHIP_CAPTURE_ENGINE_ID,
entry_timing_policy="next_open",
)
position = simulate_entry(plan, entry_bar, effective_exec)
if position is None:
continue
trade_cost = position.entry_price * position.shares_open
if trade_cost <= 0 or trade_cost > self._cash + 1e-9:
continue
self._cash -= trade_cost
self._open_positions.append(position)
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_early_failure_close_below_entry_and_reaction_close": (
engine.early_failure_close_below_entry_and_reaction_close_override
),
"engine_early_failure_no_progress_days": (
engine.early_failure_no_progress_days_override
),
"engine_early_failure_no_progress_r": (
engine.early_failure_no_progress_r_override
),
"engine_early_failure_no_progress_fraction": (
engine.early_failure_no_progress_fraction_override
),
"engine_next_open_gap_cap_pct": engine.next_open_gap_cap_pct,
"engine_use_reaction_day_low_stop": False,
"engine_veto_parse_confidence_min": (
engine.veto_parse_confidence_min_override
),
"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 _schedule_leader_follower_candidates(self, date: dt.date) -> None:
"""Generate synthetic pre-earnings follower candidates from strong leader reactions.
This is a calendar-proxy engine: it only uses the future follower event row to
confirm that an earnings event exists within a short lookahead window. The
synthetic candidate score and sizing inputs use only today's leader reaction
and the follower's current market state.
"""
next_date = self._next_trading_day.get(date)
if next_date is None:
return
follower_engines = [
e for e in self._active_strategy_engines
if e.leader_follower_lookahead_days is not None and self._engine_allowed_for_date(e, date)
]
if not follower_engines:
return
raw_rows = self.store.get_candidates_for_reaction_date(date)
if not raw_rows:
return
try:
current_idx = self._simulation_dates.index(date)
next_idx = self._simulation_dates.index(next_date)
except ValueError:
return
max_lookahead = max(int(e.leader_follower_lookahead_days or 0) for e in follower_engines)
if max_lookahead <= 0:
return
open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions}
preexisting_symbols = {
candidate.symbol.upper()
for candidate in self._scheduled_delayed_entries.get(next_date, [])
}
pending_by_symbol: dict[str, Candidate] = {}
future_dates = self._simulation_dates[next_idx + 1: next_idx + max_lookahead + 1]
future_dates_by_mode: dict[tuple[str, tuple[str, ...]], dict[str, dt.date]] = {}
for engine in follower_engines:
prelimit = max(self.config.signal.max_candidates_per_day * 5, self.config.signal.max_candidates_per_day)
leader_candidates = select_candidates(
raw_rows,
self.config.universe,
self.config.signal,
event_type_profiles=self.config.event_type_profiles or None,
strategy_engine=engine,
engine_lookup=self._strategy_engine_lookup,
truncate_to=prelimit,
)
leader_candidates = self._apply_attention_filters(leader_candidates, engine)
if not leader_candidates:
continue
calendar_mode = str(getattr(engine, "leader_follower_calendar_mode", "future_row") or "future_row")
follower_symbols = sorted(
{
follower_symbol
for leader in leader_candidates
for follower_symbol in self._leader_follower_peer_candidates(
engine,
str(leader.source_symbol or leader.symbol or "").upper(),
str(leader.sector or "UNKNOWN"),
)
}
)
cache_key = (calendar_mode, tuple(follower_symbols))
upcoming_earnings_by_symbol = future_dates_by_mode.get(cache_key)
if upcoming_earnings_by_symbol is None:
upcoming_earnings_by_symbol = self._get_known_upcoming_earnings_by_symbol(
date,
future_dates,
calendar_mode=calendar_mode,
symbols=follower_symbols,
)
future_dates_by_mode[cache_key] = upcoming_earnings_by_symbol
if not upcoming_earnings_by_symbol:
continue
required_end_date = future_dates[-1] if future_dates else next_date
self._ensure_leader_follower_market_data(
list(upcoming_earnings_by_symbol.keys()),
date,
required_end_date=required_end_date,
)
min_days_to_event = max(1, int(engine.leader_follower_min_days_to_event or 2))
hold_buffer_days = max(0, int(engine.leader_follower_hold_buffer_days))
for leader in leader_candidates:
leader_symbol = str(leader.source_symbol or leader.symbol or "").upper()
if not leader_symbol:
continue
leader_sector = str(leader.sector or "UNKNOWN")
if leader_sector == "UNKNOWN":
continue
leader_reaction = float(leader.features.get("reaction_day_return") or 0.0)
leader_close_location = float(leader.features.get("close_location") or 0.5)
leader_volume_ratio = float(
leader.features.get("volume_ratio")
or leader.features.get("volume_ratio_20d")
or 1.0
)
for follower_symbol in self._leader_follower_peer_candidates(engine, leader_symbol, leader_sector):
if (
follower_symbol in open_symbols
or follower_symbol in preexisting_symbols
):
continue
upcoming_reaction_date = upcoming_earnings_by_symbol.get(follower_symbol)
if upcoming_reaction_date is None:
continue
try:
upcoming_idx = self._simulation_dates.index(upcoming_reaction_date)
except ValueError:
continue
trading_days_to_event = upcoming_idx - next_idx
if trading_days_to_event < min_days_to_event:
continue
if trading_days_to_event > int(engine.leader_follower_lookahead_days or 0):
continue
follower_features = self.store.get_market_features(follower_symbol, date)
if not follower_features:
continue
follower_reaction = follower_features.get("reaction_day_return")
follower_gap = follower_features.get("gap_size")
follower_close_location = follower_features.get("close_location")
follower_volume_ratio = follower_features.get("volume_ratio_20d")
follower_adv = follower_features.get("avg_dollar_volume_20d")
follower_event_close = follower_features.get("event_close")
follower_atr = follower_features.get("atr_14")
if self._value_fails_bounds(
float(follower_reaction) if follower_reaction is not None else None,
engine.proxy_reaction_day_return_min,
engine.proxy_reaction_day_return_max,
):
continue
if self._value_fails_bounds(
float(follower_gap) if follower_gap is not None else None,
engine.proxy_gap_size_min,
engine.proxy_gap_size_max,
):
continue
if self._value_fails_bounds(
float(follower_close_location) if follower_close_location is not None else None,
engine.proxy_close_location_min,
engine.proxy_close_location_max,
):
continue
if self._value_fails_bounds(
float(follower_volume_ratio) if follower_volume_ratio is not None else None,
engine.proxy_volume_ratio_min,
engine.proxy_volume_ratio_max,
):
continue
if self._value_fails_bounds(
float(follower_adv) if follower_adv is not None else None,
engine.proxy_avg_dollar_volume_min,
engine.proxy_avg_dollar_volume_max,
):
continue
close_value = float(follower_event_close or 0.0)
if close_value <= 0:
continue
atr_value = float(follower_atr) if follower_atr is not None and float(follower_atr) > 0 else close_value * 0.02
adv_value = float(follower_adv) if follower_adv is not None and float(follower_adv) > 0 else 0.0
if adv_value <= 0:
continue
leader_quality = min(1.0, max(0.0, leader.score))
reaction_quality = min(1.0, max(0.0, leader_reaction) / max(abs(engine.reaction_day_return_min or 0.08), 0.08))
volume_quality = min(1.0, max(0.0, leader_volume_ratio) / max(engine.volume_ratio_min or 2.0, 1.0))
close_quality = min(1.0, max(0.0, leader_close_location))
follower_close_quality = min(1.0, max(0.0, float(follower_close_location or 0.0)))
calm_reaction = 1.0 - min(
1.0,
abs(float(follower_reaction or 0.0)) / max(abs(engine.proxy_reaction_day_return_max or 0.05), 0.05),
)
calm_gap = 1.0 - min(
1.0,
abs(float(follower_gap or 0.0)) / max(abs(engine.proxy_gap_size_max or 0.03), 0.03),
)
timing_quality = 1.0 - min(
1.0,
max(0, trading_days_to_event - min_days_to_event)
/ max(1.0, float((engine.leader_follower_lookahead_days or min_days_to_event) - min_days_to_event)),
)
score = min(
0.99,
0.30
+ 0.20 * leader_quality
+ 0.15 * reaction_quality
+ 0.10 * volume_quality
+ 0.08 * close_quality
+ 0.12 * calm_reaction
+ 0.05 * calm_gap
+ 0.08 * follower_close_quality
+ 0.10 * timing_quality,
)
score_bucket = (
"high" if score >= 0.8
else "medium_high" if score >= 0.6
else "medium"
)
max_holding_days = int(engine.max_holding_days or trading_days_to_event)
max_holding_days = min(max_holding_days, max(1, trading_days_to_event - hold_buffer_days))
candidate = Candidate(
event_id=f"synth_leader_follower_{leader_symbol.lower()}_{follower_symbol.lower()}_{date.isoformat()}",
symbol=follower_symbol,
source_symbol=leader_symbol,
score=score,
sector=leader_sector,
event_type="leader_follower_preearnings",
event_timestamp=dt.datetime.combine(date, dt.time(16, 0), tzinfo=dt.timezone.utc),
event_date=date,
filing_time_bucket="after_close",
reaction_date=date,
execution_date=next_date,
entry_price_est=close_value,
avg_dollar_volume=adv_value,
atr_14=atr_value,
score_bucket=score_bucket,
engine_id=engine.engine_id,
entry_timing_policy="next_open",
trade_direction="long",
engine_max_holding_days=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,
engine_early_failure_close_below_entry_and_reaction_close=False,
engine_early_failure_no_progress_days=engine.early_failure_no_progress_days_override,
engine_early_failure_no_progress_r=engine.early_failure_no_progress_r_override,
engine_early_failure_no_progress_fraction=engine.early_failure_no_progress_fraction_override,
shadow_only=engine.shadow_only,
features={
"leader_symbol": leader_symbol,
"leader_event_id": leader.event_id,
"leader_event_type": leader.event_type,
"leader_score": leader.score,
"leader_reaction_day_return": leader_reaction,
"leader_close_location": leader_close_location,
"leader_volume_ratio_20d": leader_volume_ratio,
"follower_symbol": follower_symbol,
"follower_reaction_day_return": follower_reaction,
"follower_gap_size": follower_gap,
"follower_close_location": follower_close_location,
"follower_volume_ratio_20d": follower_volume_ratio,
"leader_follower_upcoming_reaction_date": upcoming_reaction_date.isoformat(),
"leader_follower_days_to_event": trading_days_to_event,
},
)
existing = pending_by_symbol.get(follower_symbol)
if existing is None or candidate.score > existing.score:
pending_by_symbol[follower_symbol] = candidate
if pending_by_symbol:
scheduled = rank_candidates(list(pending_by_symbol.values()))
self._scheduled_delayed_entries[next_date].extend(scheduled)
def _leader_follower_peer_candidates(
self,
engine: StrategyEngineConfig,
leader_symbol: str,
leader_sector: str,
) -> list[str]:
ordered = peer_candidates_for_symbol(leader_symbol, leader_sector)
extra_by_leader = getattr(engine, "leader_follower_extra_peer_symbols_by_leader", None) or {}
extra_by_sector = getattr(engine, "leader_follower_extra_peer_symbols_by_sector", None) or {}
ordered.extend(extra_by_leader.get(leader_symbol, ()))
ordered.extend(extra_by_sector.get(leader_sector, ()))
allowed = {
str(symbol).strip().upper()
for symbol in (getattr(engine, "leader_follower_allowed_peer_symbols", None) or [])
if str(symbol).strip()
}
seen: set[str] = set()
result: list[str] = []
for symbol in ordered:
candidate = str(symbol).strip().upper()
if not candidate or candidate == leader_symbol or candidate in seen:
continue
if allowed and candidate not in allowed:
continue
seen.add(candidate)
result.append(candidate)
return result
def _schedule_earnings_runup_candidates(self, date: dt.date) -> None:
"""Generate synthetic pre-event drift candidates 3-7 trading days before earnings.
Mirrors `_schedule_leader_follower_candidates` in shape: synthetic Candidate
rows are emitted into `_scheduled_delayed_entries[next_trading_day]` with the
engine's `engine_id`, and routed through the standard allocator/exit path.
The pure trigger logic lives in `libs.backtest.earnings_runup`; this method
only adapts runner state to the provider Protocols.
"""
next_date = self._next_trading_day.get(date)
if next_date is None:
return
engines = [
e for e in self._active_strategy_engines
if getattr(e, "earnings_runup_enabled", False) and self._engine_allowed_for_date(e, date)
]
if not engines:
return
# Universe = symbols that already have bar history in the snapshot store.
# This keeps coverage aligned with the rest of the backtest's data set.
universe_symbols = sorted(self.store._bars.keys())
if not universe_symbols:
return
bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=self.store._bars)
# Pick PIT earnings provider:
# 1. Parquet-backed PointInTimeEarningsCalendar (preferred, free lookups).
# 2. Oracle bulk adapter (one HTTP call per as_of_date, cached on the runner).
# If neither is usable, the scheduler raises rather than silently skipping
# so configuration errors are loud.
pit_provider = self._get_or_build_earnings_runup_pit_provider(universe_symbols)
attention_provider = _BacktestAttentionZscoreAdapter(self._attention_service)
open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions}
preexisting_symbols = {
candidate.symbol.upper()
for candidate in self._scheduled_delayed_entries.get(next_date, [])
}
for engine in engines:
try:
candidates = build_earnings_runup_candidates(
decision_date=date,
next_trading_date=next_date,
universe_symbols=universe_symbols,
engine=engine,
upcoming_earnings_provider=pit_provider,
attention_provider=attention_provider,
bar_provider=bar_provider,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"earnings_runup_build_failed",
engine_id=engine.engine_id,
date=date.isoformat(),
error=str(exc),
)
continue
logger.debug(
"earnings_runup_candidates_built",
engine_id=engine.engine_id,
date=date.isoformat(),
candidate_count=len(candidates),
)
for cand in candidates:
sym = cand.symbol.upper()
if sym in open_symbols or sym in preexisting_symbols:
continue
self._scheduled_delayed_entries[next_date].append(cand)
preexisting_symbols.add(sym)
def _get_or_build_earnings_runup_pit_provider(self, universe_symbols: list[str]) -> Any:
"""Return a cached UpcomingEarningsProvider for the earnings_runup engine.
Lazily constructs and caches one instance for the lifetime of the
runner so the bulk Oracle adapter's per-day cache survives across
``_schedule_earnings_runup_candidates`` calls.
Resolution order:
1. Parquet-backed PIT calendar if present on disk.
2. Oracle PIT bulk adapter (HTTP-backed, but bulk + cached).
Raises if neither is available; this is louder than silently emitting
zero candidates and surfaces wiring/data-availability bugs early.
"""
existing = getattr(self, "_earnings_runup_pit_provider", None)
if existing is not None:
return existing
if self._pit_earnings_calendar is not None:
provider: Any = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=self._pit_earnings_calendar,
trading_days=self._simulation_dates,
)
logger.info(
"earnings_runup_pit_provider_initialized",
source="parquet",
universe_size=len(universe_symbols),
)
else:
from libs.common.config import get_settings
settings = get_settings()
if not settings.stock_oracle_url:
raise RuntimeError(
"earnings_runup engine enabled but no upcoming-earnings provider is "
"available: parquet calendar at data/reference/earnings_calendar_pit.parquet "
"is missing AND FITHIA_STOCK_ORACLE_URL is unset. Configure one of the two."
)
provider = _OracleSurprisePrefetchedEarningsAdapter(
oracle_url=settings.stock_oracle_url,
trading_days=self._simulation_dates,
universe_symbols=universe_symbols,
timeout=float(settings.stock_oracle_timeout),
)
logger.info(
"earnings_runup_pit_provider_initialized",
source="oracle_surprise_prefetch",
universe_size=len(universe_symbols),
)
self._earnings_runup_pit_provider = provider
return provider
def _schedule_peer_sympathy_candidates(
self, date: dt.date, *, phase: str = "next_open"
) -> None:
"""Generate synthetic peer-sympathy candidates from strong leader prints.
Two phases (one method, called twice from the daily loop):
- phase="reaction_close": runs BEFORE _select_candidates_for_date(date).
Emits same-day reaction_close candidates into ``_scheduled_add_ons[date]``
so they are picked up by the daily entries pass on the same iteration.
Only engines whose ``peer_sympathy_entry_timing_policy == "reaction_close"``
are processed.
- phase="next_open" (default): runs AFTER entries (current behavior).
Emits T+1 next_open candidates into ``_scheduled_delayed_entries[next_date]``.
Only engines whose ``peer_sympathy_entry_timing_policy != "reaction_close"``
are processed.
Pure logic lives in ``libs.backtest.peer_sympathy``; this method only
adapts runner state to the provider Protocols.
"""
if phase not in ("next_open", "reaction_close"):
raise ValueError(f"unsupported peer_sympathy phase {phase!r}")
is_reaction_close_phase = phase == "reaction_close"
next_date = self._next_trading_day.get(date)
# next_open phase NEEDS a next trading day; reaction_close enters today.
if not is_reaction_close_phase and next_date is None:
return
def _engine_policy(e: Any) -> str:
return str(getattr(e, "peer_sympathy_entry_timing_policy", "next_open") or "next_open").strip().lower()
engines = [
e for e in self._active_strategy_engines
if getattr(e, "peer_sympathy_enabled", False)
and self._engine_allowed_for_date(e, date)
and (
(is_reaction_close_phase and _engine_policy(e) == "reaction_close")
or (not is_reaction_close_phase and _engine_policy(e) != "reaction_close")
)
]
if not engines:
return
raw_rows = self.store.get_candidates_for_reaction_date(date)
if not raw_rows:
return
bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=self.store._bars)
# Reuse the EarningsRunup PIT provider — it already serves peer earnings
# blackout queries via the same API. Construct lazily so engines without
# blackout config don't pay the cost.
upcoming_provider: Any = None
peer_resolver = _RunnerPeerResolver(self)
open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions}
if is_reaction_close_phase:
preexisting_symbols = {
candidate.symbol.upper()
for candidate in self._scheduled_add_ons.get(date, [])
}
else:
preexisting_symbols = {
candidate.symbol.upper()
for candidate in self._scheduled_delayed_entries.get(next_date, [])
}
for engine in engines:
blackout = int(getattr(engine, "peer_sympathy_blackout_days_to_peer_event", 0) or 0)
if blackout > 0 and upcoming_provider is None:
try:
universe_symbols = sorted(self.store._bars.keys())
upcoming_provider = self._get_or_build_earnings_runup_pit_provider(universe_symbols)
except Exception as exc: # noqa: BLE001
logger.warning(
"peer_sympathy_pit_provider_unavailable",
engine_id=engine.engine_id,
error=str(exc),
)
upcoming_provider = None
prelimit = max(
self.config.signal.max_candidates_per_day * 5,
self.config.signal.max_candidates_per_day,
)
# NOTE: Do NOT pass strategy_engine here. This engine declares
# event_types=['peer_sympathy'] (a synthetic event type emitted
# downstream by build_peer_sympathy_candidates), which would cause
# _row_matches_strategy_engine_filters to drop every real leader row
# whose event_type is earnings_release / guidance_update /
# material_contract — i.e., it would filter out the very leaders
# the engine is supposed to react to. The manual peer_sympathy_leader_event_types
# + peer_sympathy_leader_reaction_min gating below performs the correct
# leader-side filtering. engine_lookup is also omitted since it is only
# consulted in conjunction with strategy_engine.
leader_candidates = select_candidates(
raw_rows,
self.config.universe,
self.config.signal,
event_type_profiles=self.config.event_type_profiles or None,
truncate_to=prelimit,
)
leader_candidates = self._apply_attention_filters(leader_candidates, engine)
if not leader_candidates:
continue
allowed_event_types = {
str(e).strip().lower()
for e in (engine.peer_sympathy_leader_event_types or [])
if str(e).strip()
}
min_reaction = float(engine.peer_sympathy_leader_reaction_min)
leaders: list[LeaderPrint] = []
for c in leader_candidates:
event_type = str(c.event_type or "").lower()
if allowed_event_types and event_type not in allowed_event_types:
continue
reaction = float(c.features.get("reaction_day_return") or 0.0)
if reaction < min_reaction:
continue
leader_symbol = str(c.source_symbol or c.symbol or "").upper()
if not leader_symbol:
continue
leaders.append(
LeaderPrint(
symbol=leader_symbol,
sector=str(c.sector or "UNKNOWN"),
event_id=c.event_id,
event_type=c.event_type,
event_date=c.event_date or date,
event_timestamp=c.event_timestamp,
reaction_day_return=reaction,
score=float(c.score),
filing_time_bucket=str(c.filing_time_bucket or "post_market"),
)
)
if not leaders:
continue
# Eagerly fetch peer bar coverage so correlation has data on hand.
# For reaction_close we need TODAY's bar (entry happens at today's
# close); for next_open we need next_date's bar (entry happens at
# next_date's open). target_end_date is the latest day we may read.
target_end = date if is_reaction_close_phase else (next_date or date)
required_symbols: set[str] = set()
for leader in leaders:
required_symbols.add(leader.symbol)
for peer in peer_resolver.peers_for_leader(engine, leader.symbol, leader.sector):
required_symbols.add(peer)
if required_symbols:
self._ensure_leader_follower_market_data(
sorted(required_symbols),
date,
required_end_date=target_end,
)
# build_peer_sympathy_candidates requires a sane next_trading_date
# even when its content isn't used for execution (reaction_close).
ntd_for_build = next_date if next_date is not None else date
try:
candidates = build_peer_sympathy_candidates(
decision_date=date,
next_trading_date=ntd_for_build,
leaders=leaders,
peer_resolver=peer_resolver,
engine=engine,
bar_provider=bar_provider,
upcoming_earnings_provider=upcoming_provider,
trading_days=self._simulation_dates,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"peer_sympathy_build_failed",
engine_id=engine.engine_id,
date=date.isoformat(),
error=str(exc),
)
continue
logger.debug(
"peer_sympathy_candidates_built",
engine_id=engine.engine_id,
phase=phase,
date=date.isoformat(),
leader_count=len(leaders),
candidate_count=len(candidates),
)
for cand in candidates:
sym = cand.symbol.upper()
if sym in open_symbols or sym in preexisting_symbols:
continue
if is_reaction_close_phase:
self._scheduled_add_ons[date].append(cand)
else:
self._scheduled_delayed_entries[next_date].append(cand)
preexisting_symbols.add(sym)
def _ensure_leader_follower_market_data(
self,
symbols: list[str],
event_date: dt.date,
*,
required_end_date: dt.date | None = None,
) -> None:
target_end_date = max(event_date, required_end_date or event_date)
missing_symbols: list[str] = []
for symbol in symbols:
normalized = str(symbol).strip().upper()
if not normalized:
continue
latest_bar = self.store.get_latest_bar_on_or_before(normalized, event_date)
latest_bar_date = latest_bar[0] if latest_bar is not None else None
all_symbol_bars = self.store._bars.get(normalized) or {}
max_available_date = max(all_symbol_bars.keys()) if all_symbol_bars else None
if latest_bar_date is None or max_available_date is None or max_available_date < target_end_date:
missing_symbols.append(normalized)
if not missing_symbols:
return
import asyncio as _aio
from libs.common.config import get_settings
settings = get_settings()
fetch_start = event_date - dt.timedelta(days=180)
try:
fetched_bars, _ = _aio.run(
SnapshotStore._fetch_price_data(
missing_symbols,
(fetch_start, target_end_date),
settings.stock_oracle_url,
concurrency=8,
)
)
except Exception as exc:
logger.warning(
"leader_follower_market_data_fetch_failed",
symbol_count=len(missing_symbols),
error=str(exc),
)
return
added_bars = 0
touched_symbols: set[str] = set()
for symbol, date_bars in fetched_bars.items():
normalized = str(symbol).strip().upper()
if not date_bars:
continue
existing = self.store._bars.setdefault(normalized, {})
for bar_date, bar in date_bars.items():
if bar_date > target_end_date or bar_date in existing:
continue
existing[bar_date] = bar
added_bars += 1
if existing:
touched_symbols.add(normalized)
if not touched_symbols:
return
for symbol in touched_symbols:
self.store._price_bar_cache.pop(symbol, None)
stale_keys = [
key
for key in self.store._market_feature_cache
if key[0] in touched_symbols
]
for key in stale_keys:
self.store._market_feature_cache.pop(key, None)
logger.info(
"leader_follower_market_data_augmented",
symbol_count=len(touched_symbols),
added_bars=added_bars,
fetch_start=fetch_start.isoformat(),
event_date=event_date.isoformat(),
target_end_date=target_end_date.isoformat(),
)
def _schedule_vol_breakout_52w_candidates(self, date: dt.date) -> None:
"""Generate synthetic VolBreakout52w candidates daily across the universe.
Honest descendant of the retired topgainer family. Buys at next_open T
when T-1 close is a 52-week high with volume confirmation; holds 2 days
with mandatory MOC exit. EVERY feature must be timestamped strictly
before decision_date 09:30 ET — see ``libs.backtest.vol_breakout_52w``.
Pure logic lives in ``libs.backtest.vol_breakout_52w``; this method only
adapts runner state to the provider Protocols and routes synthetic
candidates into the standard ``_scheduled_delayed_entries`` queue.
"""
next_date = self._next_trading_day.get(date)
if next_date is None:
return
engines = [
e for e in self._active_strategy_engines
if getattr(e, "vol_breakout_52w_enabled", False)
and self._engine_allowed_for_date(e, date)
]
if not engines:
return
universe_symbols = sorted(self.store._bars.keys())
if not universe_symbols:
return
# Cache the bar adapter on the runner so the sorted-bar cache survives
# across days (the per-symbol sorted list is invariant within the run).
cached_adapter = getattr(self, "_vol_breakout_52w_bar_adapter", None)
if cached_adapter is None:
cached_adapter = _VolBreakout52wBarAdapter(bars_by_symbol=self.store._bars)
self._vol_breakout_52w_bar_adapter = cached_adapter
bar_provider = cached_adapter
# PreOpenGapProvider not currently wired — broad snapshot does not carry
# premarket data. We pass None and rely on the engine's
# ``vol_breakout_52w_skip_if_no_gap_data`` flag to log loudly.
pre_open_gap_provider = None
# Cache one missing-gap warning marker per runner.
warned = getattr(self, "_vol_breakout_52w_missing_gap_warned", None)
if warned is None:
warned = {}
self._vol_breakout_52w_missing_gap_warned = warned
open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions}
preexisting_symbols = {
candidate.symbol.upper()
for candidate in self._scheduled_delayed_entries.get(next_date, [])
}
for engine in engines:
try:
candidates = build_vol_breakout_52w_candidates(
decision_date=date,
next_trading_date=next_date,
universe_symbols=universe_symbols,
engine=engine,
bar_provider=bar_provider,
pre_open_gap_provider=pre_open_gap_provider,
_missing_gap_warned=warned,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"vol_breakout_52w_build_failed",
engine_id=engine.engine_id,
date=date.isoformat(),
error=str(exc),
)
continue
logger.debug(
"vol_breakout_52w_candidates_built",
engine_id=engine.engine_id,
date=date.isoformat(),
candidate_count=len(candidates),
)
for cand in candidates:
sym = cand.symbol.upper()
if sym in open_symbols or sym in preexisting_symbols:
continue
self._scheduled_delayed_entries[next_date].append(cand)
preexisting_symbols.add(sym)
def _schedule_macro_short_candidates(self, date: dt.date) -> None:
"""Generate synthetic SH (inverse ETF) candidates during deep bearish regimes.
Uses composite risk score to identify stress periods. Only fires when
an engine with macro_short_risk_threshold is configured. Treats SH like
any other PEAD candidate — ATR-based stop, per-trade risk budget, trailing stop.
"""
next_date = self._next_trading_day.get(date)
if next_date is None:
return
macro_engines = [
e for e in self._active_strategy_engines
if e.macro_short_risk_threshold is not None
]
if not macro_engines:
return
# Skip if SH position already open (no pyramiding)
open_symbols = {p.plan.candidate.symbol for p in self._open_positions}
if "SH" in open_symbols:
return
# Cooldown: don't re-enter SH within 5 calendar days of last SH exit (avoid churn)
sh_exits = [t for t in self._closed_trades if t.symbol == "SH"]
if sh_exits:
last_sh_exit = max(t.exit_date for t in sh_exits)
if (date - last_sh_exit).days < 5:
return
macro = self.store.get_macro_for_date(date) or {}
if not macro:
return
# Only enter SH when SPY is in confirmed downtrend (below 20-day SMA).
# This prevents entries during bear-market rallies and recovery phases
# where the risk score lags but the market has already turned.
spy_close = macro.get("spy_close")
spy_sma = macro.get("spy_sma_20")
if spy_close and spy_sma and float(spy_close) > float(spy_sma):
return
risk_score = self._compute_parking_risk_score(macro)
for engine in macro_engines:
if risk_score < engine.macro_short_risk_threshold:
continue
sh_close = macro.get("sh_close")
if not sh_close or sh_close <= 0:
continue
# ATR estimate: annualized vol → daily vol → price-based ATR
# sh_vol_20 is annualized realized vol; daily_vol = annualized / sqrt(252)
sh_vol_ann = macro.get("sh_vol_20")
if sh_vol_ann and sh_vol_ann > 0:
atr_est = sh_close * (sh_vol_ann / (252 ** 0.5)) * 14 ** 0.5 # ~14-day ATR
else:
atr_est = sh_close * 0.02 # 2% fallback
candidate = Candidate(
event_id=f"synth_macro_sh_{date.isoformat()}",
symbol="SH",
score=min(1.0, risk_score / 100.0),
sector="MACRO",
event_type="macro_regime",
event_timestamp=dt.datetime.combine(date, dt.time(16, 0), tzinfo=dt.timezone.utc),
filing_time_bucket="after_close",
reaction_date=date,
execution_date=next_date,
entry_price_est=float(sh_close),
avg_dollar_volume=1e9,
atr_14=float(atr_est),
score_bucket="high",
engine_id=engine.engine_id,
entry_timing_policy="next_open",
trade_direction="long",
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_stop_atr_multiplier=engine.stop_atr_multiplier_override,
engine_trailing_warmup_days=engine.trailing_warmup_days_override,
# Disable PEAD-style early-exit gates for macro trades:
# The global config has early_failure_no_progress_days=1 (exit if no +0.15R by day 1)
# which would kill every SH trade before the bear trend can materialize.
engine_early_failure_no_progress_days=999,
features={
"macro_risk_score": risk_score,
"sh_entry_close": float(sh_close),
},
)
self._scheduled_delayed_entries[next_date].append(candidate)
@staticmethod
def _value_fails_bounds(
value: float | None,
minimum: float | None,
maximum: float | None,
) -> bool:
if value is None:
return minimum is not None or maximum is not None
if minimum is not None and value < minimum:
return True
if maximum is not None and value > maximum:
return True
return False
def _schedule_macro_long_candidates(self, date: dt.date) -> None:
"""Generate synthetic ETF long candidates when macro leadership/breadth expands."""
next_date = self._next_trading_day.get(date)
if next_date is None:
return
macro_engines = [
e for e in self._active_strategy_engines
if e.macro_long_symbol and self._engine_allowed_for_date(e, date)
]
if not macro_engines:
return
open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions}
macro = self.store.get_macro_for_date(date) or {}
macro_vix = macro.get("macro_vix")
if macro_vix is None:
macro_vix = macro.get("VIXCLS")
selected_today = [
candidate
for candidate in self._recent_scored_candidates.get(date, [])
if not candidate.shadow_only
]
event_breadth_count = len(selected_today)
event_breadth_unique_sectors = len({candidate.sector for candidate in selected_today})
for engine in macro_engines:
trigger_symbol = str(engine.macro_long_symbol or "").upper()
if not trigger_symbol:
continue
if self._value_fails_bounds(
float(macro_vix) if macro_vix is not None else None,
engine.macro_vix_min,
engine.macro_vix_max,
):
continue
market_features = self.store.get_market_features(trigger_symbol, date)
if not market_features:
continue
reaction_return = market_features.get("reaction_day_return")
volume_ratio = market_features.get("volume_ratio_20d")
gap_size = market_features.get("gap_size")
close_location = market_features.get("close_location")
event_close = market_features.get("event_close")
atr_14 = market_features.get("atr_14")
avg_dollar_volume = market_features.get("avg_dollar_volume_20d")
if self._value_fails_bounds(
float(reaction_return) if reaction_return is not None else None,
engine.macro_long_reaction_day_return_min,
engine.macro_long_reaction_day_return_max,
):
continue
if self._value_fails_bounds(
float(volume_ratio) if volume_ratio is not None else None,
engine.macro_long_volume_ratio_min,
engine.macro_long_volume_ratio_max,
):
continue
if self._value_fails_bounds(
float(gap_size) if gap_size is not None else None,
engine.macro_long_gap_size_min,
engine.macro_long_gap_size_max,
):
continue
if self._value_fails_bounds(
float(close_location) if close_location is not None else None,
engine.macro_long_close_location_min,
engine.macro_long_close_location_max,
):
continue
breadth_symbols = [
str(raw_symbol).upper()
for raw_symbol in (engine.macro_long_breadth_symbols or [])
if str(raw_symbol).strip()
]
breadth_match_symbols: list[str] = []
breadth_feature_map: dict[str, dict[str, Any]] = {}
breadth_count = event_breadth_count
breadth_unique_sectors = event_breadth_unique_sectors
leadership_vs_spy = None
if breadth_symbols:
deduped_breadth_symbols = list(dict.fromkeys(breadth_symbols))
for breadth_symbol in deduped_breadth_symbols:
breadth_features = self.store.get_market_features(breadth_symbol, date)
if not breadth_features:
continue
breadth_feature_map[breadth_symbol] = breadth_features
if self._value_fails_bounds(
float(breadth_features.get("reaction_day_return"))
if breadth_features.get("reaction_day_return") is not None else None,
engine.macro_long_breadth_reaction_day_return_min,
engine.macro_long_breadth_reaction_day_return_max,
):
continue
if self._value_fails_bounds(
float(breadth_features.get("volume_ratio_20d"))
if breadth_features.get("volume_ratio_20d") is not None else None,
engine.macro_long_breadth_volume_ratio_min,
engine.macro_long_breadth_volume_ratio_max,
):
continue
if self._value_fails_bounds(
float(breadth_features.get("gap_size"))
if breadth_features.get("gap_size") is not None else None,
engine.macro_long_breadth_gap_size_min,
engine.macro_long_breadth_gap_size_max,
):
continue
if self._value_fails_bounds(
float(breadth_features.get("close_location"))
if breadth_features.get("close_location") is not None else None,
engine.macro_long_breadth_close_location_min,
engine.macro_long_breadth_close_location_max,
):
continue
breadth_match_symbols.append(breadth_symbol)
breadth_count = len(breadth_match_symbols)
breadth_unique_sectors = breadth_count
if (
engine.macro_long_min_breadth_count is not None
and breadth_count < engine.macro_long_min_breadth_count
):
continue
else:
if (
engine.macro_long_min_daily_candidate_count is not None
and breadth_count < engine.macro_long_min_daily_candidate_count
):
continue
if (
engine.macro_long_min_unique_sector_count is not None
and breadth_unique_sectors < engine.macro_long_min_unique_sector_count
):
continue
if engine.macro_long_leadership_vs_spy_min is not None:
spy_features = self.store.get_market_features("SPY", date)
spy_reaction_return = spy_features.get("reaction_day_return")
if reaction_return is None or spy_reaction_return is None:
continue
leadership_vs_spy = float(reaction_return) - float(spy_reaction_return)
if leadership_vs_spy < engine.macro_long_leadership_vs_spy_min:
continue
trade_symbol = trigger_symbol
trade_symbol_mode = str(engine.macro_long_trade_symbol_mode or "fixed").lower()
if trade_symbol_mode == "leader" and breadth_match_symbols:
def _leader_rank_key(symbol_name: str) -> tuple[float, float, float]:
features = breadth_feature_map.get(symbol_name) or {}
return (
float(features.get("reaction_day_return") or 0.0),
float(features.get("close_location") or 0.0),
float(features.get("volume_ratio_20d") or 0.0),
)
trade_symbol = max(breadth_match_symbols, key=_leader_rank_key)
market_features = breadth_feature_map.get(trade_symbol) or self.store.get_market_features(trade_symbol, date)
reaction_return = market_features.get("reaction_day_return")
volume_ratio = market_features.get("volume_ratio_20d")
gap_size = market_features.get("gap_size")
close_location = market_features.get("close_location")
event_close = market_features.get("event_close")
atr_14 = market_features.get("atr_14")
avg_dollar_volume = market_features.get("avg_dollar_volume_20d")
if trade_symbol in open_symbols:
continue
execution_bar = self.store.get_bar(trade_symbol, next_date)
if execution_bar is None:
continue
reaction_quality = max(0.0, float(reaction_return or 0.0))
reaction_scale = abs(engine.macro_long_reaction_day_return_min or 0.015) or 0.015
reaction_quality = min(1.0, reaction_quality / reaction_scale)
if engine.macro_long_volume_ratio_min:
volume_quality = min(1.0, float(volume_ratio or 0.0) / engine.macro_long_volume_ratio_min)
else:
volume_quality = 0.5 if volume_ratio is None else min(1.0, float(volume_ratio) / 2.0)
close_quality = 0.5 if close_location is None else max(0.0, min(1.0, float(close_location)))
breadth_components: list[float] = []
if breadth_symbols:
if engine.macro_long_min_breadth_count:
breadth_components.append(
min(1.0, breadth_count / float(engine.macro_long_min_breadth_count))
)
else:
if engine.macro_long_min_daily_candidate_count:
breadth_components.append(
min(1.0, breadth_count / float(engine.macro_long_min_daily_candidate_count))
)
if engine.macro_long_min_unique_sector_count:
breadth_components.append(
min(1.0, breadth_unique_sectors / float(engine.macro_long_min_unique_sector_count))
)
breadth_quality = (
sum(breadth_components) / len(breadth_components)
if breadth_components
else 0.5
)
score = min(
0.99,
0.35
+ 0.30 * reaction_quality
+ 0.15 * volume_quality
+ 0.10 * close_quality
+ 0.10 * breadth_quality,
)
score_bucket = (
"high" if score >= 0.8
else "medium_high" if score >= 0.6
else "medium"
)
close_value = float(event_close) if event_close is not None else float(execution_bar.get("close") or 0.0)
if close_value <= 0:
continue
atr_value = float(atr_14) if atr_14 is not None and float(atr_14) > 0 else close_value * 0.02
adv_value = float(avg_dollar_volume) if avg_dollar_volume is not None and float(avg_dollar_volume) > 0 else 1e9
candidate = Candidate(
event_id=f"synth_macro_long_{trade_symbol.lower()}_{date.isoformat()}",
symbol=trade_symbol,
source_symbol=trigger_symbol,
score=score,
sector="MACRO",
event_type="macro_bullish_event",
event_timestamp=dt.datetime.combine(date, dt.time(16, 0), tzinfo=dt.timezone.utc),
event_date=date,
filing_time_bucket="after_close",
reaction_date=date,
execution_date=next_date,
entry_price_est=close_value,
avg_dollar_volume=adv_value,
atr_14=atr_value,
score_bucket=score_bucket,
engine_id=engine.engine_id,
entry_timing_policy="next_open",
trade_direction="long",
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,
engine_early_failure_close_below_entry_and_reaction_close=False,
engine_early_failure_no_progress_days=999,
shadow_only=engine.shadow_only,
features={
"macro_long_symbol": trigger_symbol,
"macro_long_trade_symbol": trade_symbol,
"macro_long_trade_symbol_mode": trade_symbol_mode,
"macro_long_reaction_day_return": reaction_return,
"macro_long_volume_ratio_20d": volume_ratio,
"macro_long_gap_size": gap_size,
"macro_long_close_location": close_location,
"macro_long_breadth_symbols": breadth_match_symbols,
"macro_long_breadth_count": breadth_count,
"macro_long_leadership_vs_spy": leadership_vs_spy,
"macro_long_daily_candidate_count": breadth_count,
"macro_long_daily_unique_sector_count": breadth_unique_sectors,
"macro_long_event_candidate_count": event_breadth_count,
"macro_long_event_unique_sector_count": event_breadth_unique_sectors,
"macro_long_vix": macro_vix,
"macro_long_event_close": close_value,
},
)
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:
close = self._resolve_close_price(
pos.plan.candidate.symbol, date, pos.entry_price,
)
is_short = pos.plan.candidate.trade_direction == "short"
if is_short:
total += (2.0 * pos.entry_price - close) * pos.shares_open
else:
total += close * 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 _get_parking_signal_prefix(self, symbol: str | None) -> str:
"""Map parking symbol to the macro prefix that has full signal coverage."""
normalized = (symbol or "").lower()
# QQQM is a lower-fee parking vehicle, but the macro feature store only
# computes the full signal stack (mom/vol/entropy/autocorr/...) for QQQ.
# If we use `qqqm` as the signal prefix, stress exits can still fire from
# generic QQQ features, but SGOV -> QQQM re-entry confirmation never
# resolves because `qqqm_mom_*` etc. are missing. That leaves parking
# effectively stuck in SGOV after the first risk-off transition.
if normalized == "qqqm":
return "qqq"
if normalized in (
"spy",
"spym",
"qqq",
"qual",
"gld",
"jepq",
"bufb",
"merix",
"shy",
"usfr",
"bil",
"vgsh",
"iei",
"ief",
"tip",
"dbc",
"sh",
"psq",
):
return normalized
return "qqq"
def _get_parking_defensive_symbol(self) -> str:
"""Secondary defensive ETF used for multi-step parking ladders."""
symbol = (self.config.risk.cash_parking_defensive_symbol or "spy").lower()
if symbol in (
"spy",
"spym",
"qual",
"gld",
"dbc",
"jepq",
"bufb",
"merix",
"shy",
"usfr",
"bil",
"vgsh",
"iei",
"ief",
"tip",
"dbc",
):
return symbol
return "spy"
def _get_parking_defensive_alt_symbol(self) -> str | None:
symbol = (self.config.risk.cash_parking_defensive_alt_symbol or "").lower()
if not symbol:
return None
if symbol in (
"spy",
"spym",
"qual",
"gld",
"dbc",
"jepq",
"bufb",
"merix",
"shy",
"usfr",
"bil",
"vgsh",
"iei",
"ief",
"tip",
):
return symbol
return None
def _get_parking_defensive_prefix(self) -> str:
defensive_symbol = self._get_parking_defensive_symbol()
if defensive_symbol == "qual":
return "qual"
return self._get_parking_signal_prefix(defensive_symbol)
def _get_parking_defensive_corr_key(self) -> str:
return f"{self._get_parking_defensive_prefix()}_qqq_corr_20"
def _get_parking_crisis_symbol(self) -> str | None:
symbol = (self.config.risk.cash_parking_crisis_symbol or "").lower()
if symbol in ("gld", "shy", "usfr", "bil", "vgsh", "iei", "ief", "tip", "dbc"):
return symbol
return None
def _evaluate_crisis_relay_target(self, macro: dict) -> str | None:
"""Use a bond-like safe haven only during deep stress and only when it is already trending up."""
crisis_symbol = self._get_parking_crisis_symbol()
if crisis_symbol is None:
return None
risk_score = float(self._compute_parking_risk_score(macro))
if risk_score < float(self.config.risk.cash_parking_crisis_threshold):
return None
prefix = self._get_parking_signal_prefix(crisis_symbol)
mom_days = max(1, int(self.config.risk.cash_parking_crisis_momentum_days or 20))
momentum = macro.get(f"{prefix}_mom_{mom_days}")
if momentum is None or momentum <= float(self.config.risk.cash_parking_crisis_momentum_min):
return None
vol_max = float(self.config.risk.cash_parking_crisis_vol_max or 0.0)
if vol_max > 0:
vol = macro.get(f"{prefix}_vol_20")
if vol is None or vol > vol_max:
return None
return crisis_symbol
def _evaluate_defensive_relay_target(
self,
date: dt.date,
macro: dict,
park_mode: str,
) -> str | None:
"""Use the defensive ETF instead of SGOV when primary risk-on gate is off but broad stress is still moderate."""
if not self.config.risk.cash_parking_defensive_relay_enabled:
return None
defensive_symbol = self._get_parking_defensive_symbol()
signal_prefix = self._get_parking_signal_prefix(park_mode)
period = self.config.risk.cash_parking_trend_sma_period
risk_score = float(self._compute_parking_risk_score(macro))
relay_risk_cap = min(
float(self.config.risk.cash_parking_composite_exit_score),
float(self.config.risk.cash_parking_defensive_relay_risk_score_max),
)
if risk_score >= relay_risk_cap:
return None
trigger_mode = self.config.risk.cash_parking_defensive_relay_trigger_mode
turn_strength = self._compute_turn_of_month_strength(date)
turn_min = float(self.config.risk.cash_parking_defensive_relay_turn_strength_min)
turn_ok = turn_strength >= turn_min if turn_min > 0 else False
recovery_days = max(1, int(self.config.risk.cash_parking_defensive_relay_recovery_momentum_days))
recovery_mom = macro.get(f"{signal_prefix}_mom_{recovery_days}")
recovery_mom_min = float(self.config.risk.cash_parking_defensive_relay_recovery_momentum_min)
dd_accel = macro.get(f"{signal_prefix}_drawdown_accel_5")
dd_accel_max = float(self.config.risk.cash_parking_defensive_relay_drawdown_accel_max)
recovery_ok = (
recovery_mom is not None
and recovery_mom >= recovery_mom_min
and (dd_accel is None or dd_accel <= dd_accel_max)
)
if trigger_mode == "turn_of_month" and not turn_ok:
return None
if trigger_mode == "recovery" and not recovery_ok:
return None
if trigger_mode == "turn_or_recovery" and not (turn_ok or recovery_ok):
return None
def _candidate_ok(symbol: str) -> tuple[bool, float]:
if symbol in ("", "sgov", park_mode):
return False, float("-inf")
prefix = self._get_parking_signal_prefix(symbol)
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
ent_lb = self.config.risk.cash_parking_entropy_lookback
defensive_vol_threshold = float(
self.config.risk.cash_parking_defensive_vol_max
or self.config.risk.cash_parking_gate_vol_spy_threshold
or self.config.risk.cash_parking_gate_vol_threshold
)
defensive_vol = macro.get(f"{prefix}_vol_{vol_lb}")
if defensive_vol is not None and defensive_vol >= defensive_vol_threshold:
return False, float("-inf")
defensive_mom = macro.get(f"{prefix}_mom_{period}")
if defensive_mom is not None and defensive_mom <= float(self.config.risk.cash_parking_defensive_momentum_min):
return False, float("-inf")
ent_thr = self.config.risk.cash_parking_entropy_threshold
if ent_thr > 0:
defensive_entropy = macro.get(f"{prefix}_entropy_{ent_lb}")
if defensive_entropy is not None and defensive_entropy > ent_thr + 0.15:
return False, float("-inf")
defensive_downside = macro.get(f"{prefix}_downside_vol_20")
if defensive_downside is not None and defensive_downside > 0.18:
return False, float("-inf")
defensive_ulcer = macro.get(f"{prefix}_ulcer_20")
if defensive_ulcer is not None and defensive_ulcer > 0.06:
return False, float("-inf")
defensive_autocorr = macro.get(f"{prefix}_autocorr_20")
if defensive_autocorr is not None and defensive_autocorr < -0.10:
return False, float("-inf")
return True, float(defensive_mom or 0.0)
candidates: list[tuple[str, float]] = []
ok, score = _candidate_ok(defensive_symbol)
if ok:
candidates.append((defensive_symbol, score))
alt_symbol = self._get_parking_defensive_alt_symbol()
if alt_symbol and alt_symbol != defensive_symbol:
ok, score = _candidate_ok(alt_symbol)
if ok:
candidates.append((alt_symbol, score))
if not candidates:
return None
candidates.sort(key=lambda item: item[1], reverse=True)
return candidates[0][0]
def _check_overlay_shock_brake(self, macro: dict) -> bool:
"""Check QQQ/SMH acceleration signals for fast TQQQ exit.
Returns True if any brake signal fires. Does NOT use TQQQ price.
Three signals, any one triggers:
1. Vol acceleration: qqq_vol_5 / qqq_vol_20 > rv_ratio threshold
2. Trend break + sector: qqq_close < qqq_sma_10 AND smh_mom_5 < 0
3. Sharp 5-day drawdown: (qqq_high_5 - qqq_close) / qqq_high_5 > dd5_pct
"""
cfg = self.config.risk
rv_ratio = cfg.cash_parking_overlay_shock_brake_rv_ratio
dd5_pct = cfg.cash_parking_overlay_shock_brake_dd5_pct
sma_cross = cfg.cash_parking_overlay_shock_brake_sma_cross
# Signal 1: vol acceleration
vol5 = macro.get("qqq_vol_5")
vol20 = macro.get("qqq_vol_20")
if vol5 is not None and vol20 is not None and vol20 > 0:
if vol5 / vol20 > rv_ratio:
return True
# Signal 2: trend break + sector weakness
if sma_cross:
qqq_close = macro.get("qqq_close")
qqq_sma10 = macro.get("qqq_sma_10")
smh_mom5 = macro.get("smh_mom_5")
if (
qqq_close is not None
and qqq_sma10 is not None
and smh_mom5 is not None
and qqq_close < qqq_sma10
and smh_mom5 < 0
):
return True
# Signal 3: sharp 5-day drawdown
qqq_high5 = macro.get("qqq_high_5")
qqq_close = macro.get("qqq_close")
if (
qqq_high5 is not None
and qqq_close is not None
and qqq_high5 > 0
and (qqq_high5 - qqq_close) / qqq_high5 > dd5_pct
):
return True
# Signal 4: near-SMA buffer — exit when QQQ is within sma_buffer% ABOVE SMA10 (pre-emptive)
# Requires vol5/vol20 in (vol_lower, rv_ratio_upper) — "barely elevated" short-term vol.
# Rationale: if vol5/vol20 is very high (>0.45), regular gate already handles it.
# Targeting (0.25, 0.45) captures "vol just starting to rise" pre-crash signature.
# Dec 11 2025: vol5/vol20=0.284 ✓ | May 21 2025: 0.529 > 0.45 → filtered out.
sma_buffer = cfg.cash_parking_overlay_shock_brake_sma_buffer
if sma_buffer > 0:
qqq_sma10 = macro.get("qqq_sma_10")
qqq_close2 = macro.get("qqq_close")
vol5_s4 = macro.get("qqq_vol_5")
vol20_s4 = macro.get("qqq_vol_20")
if (
qqq_sma10 and qqq_close2 and qqq_sma10 > 0
and vol5_s4 is not None and vol20_s4 is not None and vol20_s4 > 0
):
rv = vol5_s4 / vol20_s4
rv_lower = 0.25
rv_upper = cfg.cash_parking_overlay_shock_brake_rv_ratio_upper
vol_ok = rv > rv_lower and (rv_upper <= 0 or rv < rv_upper)
if vol_ok:
sma_gap = (qqq_close2 - qqq_sma10) / qqq_sma10
if 0 < sma_gap < sma_buffer:
return True
return False
def _compute_overlay_blend_weights(self, macro: dict) -> tuple[float, float]:
"""Compute QQQM and TQQQ weights for continuous leverage blend.
Returns (w_qqqm, w_tqqq) where sum == 1.0.
Effective leverage = 1*w_qqqm + 3*w_tqqq = 1 + 2*w_tqqq.
Target leverage = clip(target_vol / rv20, 1.0, max_leverage).
"""
cfg = self.config.risk
target_vol = cfg.cash_parking_overlay_blend_target_vol
max_lev = cfg.cash_parking_overlay_blend_max_leverage
rv20 = macro.get("qqq_vol_20")
if not rv20 or rv20 <= 0:
return (1.0, 0.0)
target_L = max(1.0, min(target_vol / rv20, max_lev))
w_tqqq = (target_L - 1.0) / 2.0
w_tqqq = max(0.0, min(w_tqqq, 1.0))
return (1.0 - w_tqqq, w_tqqq)
def _rebalance_parking_blend(self, date: dt.date, macro: dict) -> None:
"""Daily rebalance of TQQQ/QQQM blend weights based on current vol."""
tqqq_p = macro.get("tqqq_close")
qqqm_p = macro.get("qqqm_close")
if not tqqq_p or tqqq_p <= 0 or not qqqm_p or qqqm_p <= 0:
return
current_val = (
self._parking_blend_tqqq_shares * tqqq_p
+ self._parking_blend_qqqm_shares * qqqm_p
)
if current_val <= 0:
return
w_qqqm, w_tqqq = self._compute_overlay_blend_weights(macro)
new_tqqq = int(current_val * w_tqqq / tqqq_p)
new_qqqm = int(current_val * w_qqqm / qqqm_p)
if new_tqqq == self._parking_blend_tqqq_shares and new_qqqm == self._parking_blend_qqqm_shares:
return
# Cash-neutral rebalance (rounding residual goes to cash)
new_val = new_tqqq * tqqq_p + new_qqqm * qqqm_p
self._cash += current_val - new_val
self._parking_blend_tqqq_shares = new_tqqq
self._parking_blend_qqqm_shares = new_qqqm
self._parking_blend_tqqq_avg_price = tqqq_p
self._parking_blend_qqqm_avg_price = qqqm_p
def _evaluate_low_vol_overlay_target(self, macro: dict, park_mode: str) -> str | None:
"""Upgrade a safe parking regime to a stricter overlay symbol.
The base regime decision must already be safe enough for `park_mode`.
This helper only allows the overlay when the market is materially calmer
than the normal QQQ/QQQM parking threshold.
"""
# Shock brake cooldown: suppress overlay re-entry for N days after brake
if self._parking_overlay_brake_cooldown > 0:
return None
overlay_symbol = self.config.risk.cash_parking_low_vol_overlay_symbol
if not overlay_symbol or overlay_symbol == park_mode:
return None
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
overlay_vol_threshold = self.config.risk.cash_parking_low_vol_overlay_vol_threshold
if overlay_vol_threshold > 0:
vol = macro.get(f"qqq_vol_{vol_lb}")
if vol is None or vol >= overlay_vol_threshold:
return None
temp_max = self.config.risk.cash_parking_low_vol_overlay_temperature_max
if temp_max > 0:
vol_short = macro.get("qqq_vol_15")
vol_long = macro.get("qqq_vol_50")
if (
vol_short is None
or vol_long is None
or vol_long <= 0
or (vol_short / vol_long) > temp_max
):
return None
prefix = self._get_parking_signal_prefix(park_mode)
entropy_max = self.config.risk.cash_parking_low_vol_overlay_entropy_max
if entropy_max > 0:
ent_lb = self.config.risk.cash_parking_entropy_lookback
entropy = macro.get(f"{prefix}_entropy_{ent_lb}")
if entropy is None or entropy > entropy_max:
return None
hurst_min = self.config.risk.cash_parking_low_vol_overlay_hurst_min
if hurst_min > 0:
hurst = macro.get(f"{prefix}_hurst_60")
if hurst is None or hurst < hurst_min:
return None
return overlay_symbol
def _evaluate_science_regime_target(self, macro: dict, park_mode: str) -> str:
"""Multi-signal non-levered regime ladder: risk-on -> defensive ETF -> SGOV.
This mode combines composite risk scoring with information-theory and
econophysics-style blockers, then de-risks in stages instead of
collapsing straight to cash.
"""
risk_score = self._compute_parking_risk_score(macro)
enter_threshold = self.config.risk.cash_parking_composite_enter_score
spy_threshold = self.config.risk.cash_parking_composite_spy_score
exit_threshold = self.config.risk.cash_parking_composite_exit_score
defensive_symbol = self._get_parking_defensive_symbol()
defensive_prefix = self._get_parking_defensive_prefix()
signal_prefix = self._get_parking_signal_prefix(park_mode)
period = self.config.risk.cash_parking_trend_sma_period
qqq_mom = macro.get(f"{signal_prefix}_mom_{period}")
defensive_mom = macro.get(f"{defensive_prefix}_mom_{period}")
reentry_pct = self.config.risk.cash_parking_trend_reentry_pct
entropy_hot = False
ent_thr = self.config.risk.cash_parking_entropy_threshold
if ent_thr > 0:
ent_lb = self.config.risk.cash_parking_entropy_lookback
entropy = macro.get(f"{signal_prefix}_entropy_{ent_lb}")
entropy_hot = entropy is not None and entropy > ent_thr
temperature_hot = False
temp_thr = self.config.risk.cash_parking_temperature_threshold
if temp_thr > 0:
vol_short = macro.get("qqq_vol_15")
vol_long = macro.get("qqq_vol_50")
if vol_short is not None and vol_long is not None and vol_long > 0:
temperature_hot = (vol_short / vol_long) > temp_thr
vix = macro.get("VIXCLS")
vix_blocked = False
vix_max = self.config.risk.cash_parking_vix_reentry_max
if vix_max > 0:
vix_blocked = vix is None or vix >= vix_max
qqq_reentry_ok = qqq_mom is None or qqq_mom > reentry_pct
qqq_risk_on_ok = qqq_mom is None or qqq_mom > 0
defensive_ok = defensive_mom is None or defensive_mom > 0
current_target = self._parking_current_symbol
if current_target not in ("sgov", defensive_symbol, park_mode):
current_target = "sgov" if self._parking_trend_sgov else park_mode
hard_defensive = risk_score >= exit_threshold
soft_defensive = (
risk_score >= spy_threshold
or entropy_hot
or temperature_hot
or vix_blocked
)
if current_target == "sgov" or self._parking_trend_sgov:
if hard_defensive:
self._parking_trend_sgov = True
return "sgov"
if risk_score <= enter_threshold and qqq_reentry_ok and not (entropy_hot or temperature_hot or vix_blocked):
self._parking_trend_sgov = False
return park_mode
if risk_score <= spy_threshold and defensive_ok and not vix_blocked:
self._parking_trend_sgov = False
return defensive_symbol
self._parking_trend_sgov = True
return "sgov"
if current_target == defensive_symbol:
if hard_defensive or not defensive_ok:
self._parking_trend_sgov = True
return "sgov"
if risk_score <= enter_threshold and qqq_reentry_ok and not (entropy_hot or temperature_hot or vix_blocked):
return park_mode
return defensive_symbol
if hard_defensive:
self._parking_trend_sgov = True
return "sgov"
if soft_defensive or not qqq_risk_on_ok:
if defensive_ok and not vix_blocked:
return defensive_symbol
self._parking_trend_sgov = True
return "sgov"
return park_mode
def _evaluate_science_blend_plan(self, macro: dict, park_mode: str) -> tuple[str, float]:
"""Continuous non-levered parking plan using composite and regime quality."""
def _clip(value: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, value))
risk_score = float(self._compute_parking_risk_score(macro))
enter_threshold = float(self.config.risk.cash_parking_composite_enter_score)
spy_threshold = float(self.config.risk.cash_parking_composite_spy_score)
exit_threshold = float(self.config.risk.cash_parking_composite_exit_score)
defensive_symbol = self._get_parking_defensive_symbol()
signal_prefix = self._get_parking_signal_prefix(park_mode)
defensive_prefix = self._get_parking_defensive_prefix()
if exit_threshold <= spy_threshold:
exit_threshold = spy_threshold + 10.0
if spy_threshold <= enter_threshold:
spy_threshold = enter_threshold + 8.0
period = self.config.risk.cash_parking_trend_sma_period
qqq_mom = macro.get(f"{signal_prefix}_mom_{period}")
defensive_mom = macro.get(f"{defensive_prefix}_mom_{period}")
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
vol_thr = self.config.risk.cash_parking_gate_vol_threshold or 0.24
vol = macro.get(f"qqq_vol_{vol_lb}")
ent_lb = self.config.risk.cash_parking_entropy_lookback
ent_thr = self.config.risk.cash_parking_entropy_threshold or 1.2
entropy = macro.get(f"{signal_prefix}_entropy_{ent_lb}")
hurst = macro.get(f"{signal_prefix}_hurst_60")
autocorr = macro.get(f"{signal_prefix}_autocorr_20")
corr = macro.get(self._get_parking_defensive_corr_key())
efficiency = macro.get(f"{signal_prefix}_efficiency_20")
downside_vol = macro.get(f"{signal_prefix}_downside_vol_20")
ulcer = macro.get(f"{signal_prefix}_ulcer_20")
current_dd = macro.get(f"{signal_prefix}_drawdown_20")
dd_accel = macro.get(f"{signal_prefix}_drawdown_accel_5")
vix = macro.get("VIXCLS")
hy = macro.get("BAMLH0A0HYM2")
temp = None
vol_short = macro.get("qqq_vol_15")
vol_long = macro.get("qqq_vol_50")
if vol_short is not None and vol_long is not None and vol_long > 0:
temp = vol_short / vol_long
temp_thr = self.config.risk.cash_parking_temperature_threshold or 1.25
trend_quality = 0.5 if qqq_mom is None else _clip((qqq_mom + 0.04) / 0.12)
vol_quality = 0.5 if vol is None else _clip((vol_thr + 0.04 - vol) / 0.12)
predictability = 0.5 if entropy is None else _clip((ent_thr + 0.18 - entropy) / 0.45)
hurst_quality = 0.5 if hurst is None else _clip((hurst - 0.45) / 0.15)
autocorr_quality = 0.5 if autocorr is None else _clip((autocorr + 0.08) / 0.28)
persistence = 0.5 * hurst_quality + 0.5 * autocorr_quality
thermal_quality = 0.5 if temp is None else _clip((temp_thr + 0.10 - temp) / 0.35)
structure_quality = 0.5 if corr is None else _clip((corr - 0.76) / 0.18)
efficiency_quality = 0.5 if efficiency is None else _clip((efficiency - 0.22) / 0.45)
downside_calm = 0.5 if downside_vol is None else _clip((0.18 - downside_vol) / 0.10)
ulcer_calm = 0.5 if ulcer is None else _clip((0.06 - ulcer) / 0.05)
drawdown_calm = 0.5 if current_dd is None else _clip((0.10 - current_dd) / 0.08)
accel_calm = 0.5 if dd_accel is None else _clip((0.02 - dd_accel) / 0.06)
if vix is not None and hy is not None:
macro_calm = 0.5 * _clip((24.0 - vix) / 10.0) + 0.5 * _clip((5.5 - hy) / 2.0)
elif vix is not None:
macro_calm = _clip((24.0 - vix) / 10.0)
elif hy is not None:
macro_calm = _clip((5.5 - hy) / 2.0)
else:
macro_calm = 0.5
confidence = (
0.18 * trend_quality
+ 0.10 * vol_quality
+ 0.11 * predictability
+ 0.09 * persistence
+ 0.08 * thermal_quality
+ 0.06 * structure_quality
+ 0.06 * macro_calm
+ 0.12 * efficiency_quality
+ 0.08 * downside_calm
+ 0.06 * ulcer_calm
+ 0.04 * drawdown_calm
+ 0.02 * accel_calm
)
confidence = _clip(confidence)
base_stress = _clip((risk_score - enter_threshold) / max(exit_threshold - enter_threshold, 1.0))
structural_stress = (
0.16 * (1.0 - downside_calm)
+ 0.12 * (1.0 - ulcer_calm)
+ 0.10 * (1.0 - accel_calm)
+ 0.07 * (1.0 - drawdown_calm)
)
stress = _clip(0.75 * base_stress + structural_stress)
invested_fraction = _clip(0.08 + 0.92 * confidence * (1.0 - 0.82 * stress))
qqq_ok = qqq_mom is None or qqq_mom > 0
defensive_ok = defensive_mom is None or defensive_mom > -0.01
drawdown_break = (
current_dd is not None
and dd_accel is not None
and current_dd >= 0.07
and dd_accel > 0.015
)
if (risk_score >= exit_threshold or drawdown_break) and not defensive_ok:
return "sgov", 0.0
if (
risk_score <= enter_threshold
and confidence >= 0.60
and qqq_ok
and downside_calm >= 0.40
and accel_calm >= 0.35
):
return park_mode, max(0.70, invested_fraction)
if risk_score <= spy_threshold and confidence >= 0.42:
if qqq_ok and confidence >= 0.54 and downside_calm >= 0.32:
return park_mode, max(0.55, invested_fraction)
return defensive_symbol, max(0.45, min(0.80, invested_fraction))
if defensive_ok and risk_score < exit_threshold:
return defensive_symbol, max(0.25, min(0.65, invested_fraction))
return "sgov", 0.0
def _compute_turn_of_month_strength(self, date: dt.date) -> float:
"""Return a 0..1 strength score around the month turn."""
idx = self._simulation_date_index.get(date)
if idx is None:
return 0.0
strength = 0.0
lead_days = max(0, int(self.config.risk.cash_parking_turn_of_month_lead_days))
lag_days = max(0, int(self.config.risk.cash_parking_turn_of_month_lag_days))
days_from_start = 0
while (
lag_days > 0
and days_from_start < lag_days
and idx - days_from_start - 1 >= 0
and self._simulation_dates[idx - days_from_start - 1].month == date.month
):
days_from_start += 1
if lag_days > 0 and days_from_start < lag_days:
strength = max(strength, 1.0 - (days_from_start / lag_days))
days_to_end = 0
while (
lead_days > 0
and days_to_end < lead_days
and idx + days_to_end + 1 < len(self._simulation_dates)
and self._simulation_dates[idx + days_to_end + 1].month == date.month
):
days_to_end += 1
if lead_days > 0 and days_to_end < lead_days:
strength = max(strength, 1.0 - (days_to_end / lead_days))
return strength
def _evaluate_vt_blend_plan(self, macro: dict, park_mode: str) -> tuple[str, float]:
"""Aggressive QQQM/QQQ blend that trims exposure during left-tail stress."""
def _clip(value: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, value))
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
ent_lb = self.config.risk.cash_parking_entropy_lookback
vol_thr = self.config.risk.cash_parking_gate_vol_threshold or 0.24
temp_thr = self.config.risk.cash_parking_temperature_threshold or 1.3
ent_thr = self.config.risk.cash_parking_entropy_threshold or 1.4
vol = macro.get(f"qqq_vol_{vol_lb}")
entropy = macro.get(f"qqq_entropy_{ent_lb}")
downside_vol = macro.get("qqq_downside_vol_20")
ulcer = macro.get("qqq_ulcer_20")
efficiency = macro.get("qqq_efficiency_20")
dd_accel = macro.get("qqq_drawdown_accel_5")
autocorr = macro.get("qqq_autocorr_20")
qqq_mom = macro.get(f"qqq_mom_{self.config.risk.cash_parking_trend_sma_period}")
vol_short = macro.get("qqq_vol_15")
vol_long = macro.get("qqq_vol_50")
temp = None
if vol_short is not None and vol_long is not None and vol_long > 0:
temp = vol_short / vol_long
penalty = 0.0
if vol is not None and vol > vol_thr:
penalty += 0.48 * _clip((vol - vol_thr) / 0.08)
if temp is not None and temp > temp_thr:
penalty += 0.28 * _clip((temp - temp_thr) / 0.28)
if entropy is not None and entropy > ent_thr:
penalty += 0.18 * _clip((entropy - ent_thr) / 0.35)
if downside_vol is not None and downside_vol > 0.20:
penalty += 0.20 * _clip((downside_vol - 0.20) / 0.08)
if ulcer is not None and ulcer > 0.05:
penalty += 0.14 * _clip((ulcer - 0.05) / 0.04)
if dd_accel is not None and dd_accel > 0.015:
penalty += 0.16 * _clip((dd_accel - 0.015) / 0.04)
if efficiency is not None and efficiency < 0.08:
penalty += 0.12 * _clip((0.08 - efficiency) / 0.08)
if autocorr is not None and autocorr < -0.08:
penalty += 0.10 * _clip((-0.08 - autocorr) / 0.12)
risk_score = float(self._compute_parking_risk_score(macro))
if risk_score >= 40:
penalty += 0.10
if qqq_mom is not None and qqq_mom < -0.02:
penalty += 0.12
invested_fraction = _clip(1.0 - penalty, 0.0, 1.0)
if invested_fraction < 0.08:
return "sgov", 0.0
return park_mode, invested_fraction
def _evaluate_vt_pair_blend_plan(self, macro: dict, park_mode: str) -> tuple[str, float]:
"""Aggressive ladder: QQQM/QQQ -> defensive ETF -> SGOV as stress rises."""
target_sym, invested_fraction = self._evaluate_vt_blend_plan(macro, park_mode)
if target_sym == "sgov":
return "sgov", 0.0
defensive_symbol = self._get_parking_defensive_symbol()
defensive_prefix = self._get_parking_defensive_prefix()
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
ent_lb = self.config.risk.cash_parking_entropy_lookback
vol_thr = self.config.risk.cash_parking_gate_vol_threshold or 0.24
temp_thr = self.config.risk.cash_parking_temperature_threshold or 1.3
ent_thr = self.config.risk.cash_parking_entropy_threshold or 1.4
vol = macro.get(f"qqq_vol_{vol_lb}")
entropy = macro.get(f"qqq_entropy_{ent_lb}")
downside_vol = macro.get("qqq_downside_vol_20")
ulcer = macro.get("qqq_ulcer_20")
dd_accel = macro.get("qqq_drawdown_accel_5")
qqq_mom = macro.get(f"qqq_mom_{self.config.risk.cash_parking_trend_sma_period}")
defensive_mom = macro.get(f"{defensive_prefix}_mom_{self.config.risk.cash_parking_trend_sma_period}")
vol_short = macro.get("qqq_vol_15")
vol_long = macro.get("qqq_vol_50")
temp = None
if vol_short is not None and vol_long is not None and vol_long > 0:
temp = vol_short / vol_long
stress = 0.0
if vol is not None and vol > vol_thr:
stress += 0.40 * max(0.0, min(1.0, (vol - vol_thr) / 0.08))
if temp is not None and temp > temp_thr:
stress += 0.22 * max(0.0, min(1.0, (temp - temp_thr) / 0.25))
if entropy is not None and entropy > ent_thr:
stress += 0.14 * max(0.0, min(1.0, (entropy - ent_thr) / 0.35))
if downside_vol is not None and downside_vol > 0.20:
stress += 0.18 * max(0.0, min(1.0, (downside_vol - 0.20) / 0.08))
if ulcer is not None and ulcer > 0.05:
stress += 0.12 * max(0.0, min(1.0, (ulcer - 0.05) / 0.05))
if dd_accel is not None and dd_accel > 0.015:
stress += 0.14 * max(0.0, min(1.0, (dd_accel - 0.015) / 0.04))
if stress >= 0.72:
return "sgov", 0.0
if stress >= 0.38 and (defensive_mom is None or defensive_mom > -0.01):
spy_fraction = max(0.72, min(1.0, 1.02 - 0.45 * (stress - 0.38)))
return defensive_symbol, spy_fraction
if qqq_mom is not None and qqq_mom < -0.03 and (defensive_mom is None or defensive_mom > -0.01):
return defensive_symbol, max(0.78, invested_fraction)
return target_sym, invested_fraction
def _evaluate_relative_strength_plan(
self,
date: dt.date,
macro: dict,
park_mode: str,
) -> tuple[str, float]:
"""Safe base gate with QQQ-vs-SPY leadership and turn-of-month bridge."""
def _clip(value: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, value))
risk_score = float(self._compute_parking_risk_score(macro))
enter_threshold = float(self.config.risk.cash_parking_composite_enter_score)
spy_threshold = float(self.config.risk.cash_parking_composite_spy_score)
exit_threshold = float(self.config.risk.cash_parking_composite_exit_score)
defensive_symbol = self._get_parking_defensive_symbol()
defensive_prefix = self._get_parking_defensive_prefix()
if exit_threshold <= spy_threshold:
exit_threshold = spy_threshold + 10.0
if spy_threshold <= enter_threshold:
spy_threshold = enter_threshold + 8.0
fast_days = max(5, int(self.config.risk.cash_parking_rotation_fast_momentum_days))
slow_days = max(fast_days, int(self.config.risk.cash_parking_rotation_slow_momentum_days))
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
ent_lb = self.config.risk.cash_parking_entropy_lookback
vol_thr = self.config.risk.cash_parking_gate_vol_threshold or 0.24
ent_thr = self.config.risk.cash_parking_entropy_threshold or 1.2
temp_thr = self.config.risk.cash_parking_temperature_threshold or 1.25
qqq_fast = macro.get(f"qqq_mom_{fast_days}")
qqq_slow = macro.get(f"qqq_mom_{slow_days}")
defensive_fast = macro.get(f"{defensive_prefix}_mom_{fast_days}")
defensive_slow = macro.get(f"{defensive_prefix}_mom_{slow_days}")
qqq_vol = macro.get(f"qqq_vol_{vol_lb}")
qqq_entropy = macro.get(f"qqq_entropy_{ent_lb}")
qqq_eff = macro.get("qqq_efficiency_20")
defensive_eff = macro.get(f"{defensive_prefix}_efficiency_20")
qqq_downside = macro.get("qqq_downside_vol_20")
defensive_downside = macro.get(f"{defensive_prefix}_downside_vol_20")
qqq_ulcer = macro.get("qqq_ulcer_20")
defensive_ulcer = macro.get(f"{defensive_prefix}_ulcer_20")
qqq_temp_short = macro.get("qqq_vol_15")
qqq_temp_long = macro.get("qqq_vol_50")
qqq_temp = None
if qqq_temp_short is not None and qqq_temp_long is not None and qqq_temp_long > 0:
qqq_temp = qqq_temp_short / qqq_temp_long
vix = macro.get("VIXCLS")
hy = macro.get("BAMLH0A0HYM2")
if vix is not None and hy is not None:
macro_calm = 0.5 * _clip((24.0 - vix) / 10.0) + 0.5 * _clip((5.5 - hy) / 2.0)
elif vix is not None:
macro_calm = _clip((24.0 - vix) / 10.0)
elif hy is not None:
macro_calm = _clip((5.5 - hy) / 2.0)
else:
macro_calm = 0.5
qqq_lead_threshold = self.config.risk.cash_parking_rotation_lead_threshold
qqq_strong_threshold = self.config.risk.cash_parking_rotation_strong_threshold
turn_strength = self._compute_turn_of_month_strength(date)
turn_boost = self.config.risk.cash_parking_turn_of_month_boost
base_stress = _clip((risk_score - enter_threshold) / max(exit_threshold - enter_threshold, 1.0))
vol_quality = 0.5 if qqq_vol is None else _clip((vol_thr + 0.03 - qqq_vol) / 0.10)
entropy_quality = 0.5 if qqq_entropy is None else _clip((ent_thr + 0.12 - qqq_entropy) / 0.30)
temp_quality = 0.5 if qqq_temp is None else _clip((temp_thr + 0.08 - qqq_temp) / 0.25)
qqq_calm = (
(qqq_vol is None or qqq_vol <= vol_thr)
and (qqq_entropy is None or qqq_entropy <= ent_thr)
and (qqq_temp is None or qqq_temp <= temp_thr)
and (qqq_downside is None or qqq_downside <= 0.18)
)
defensive_ok = (
(defensive_slow is None or defensive_slow > -0.01)
and (defensive_downside is None or defensive_downside <= 0.17)
and (defensive_ulcer is None or defensive_ulcer <= 0.06)
)
qqq_ok = (qqq_fast is None or qqq_fast > -0.005) and (qqq_slow is None or qqq_slow > -0.01)
leadership_gap = (
0.55 * ((qqq_fast or 0.0) - (defensive_fast or 0.0))
+ 0.25 * ((qqq_slow or 0.0) - (defensive_slow or 0.0))
+ 0.35 * ((qqq_eff or 0.25) - (defensive_eff or 0.25))
+ 0.25 * ((defensive_downside or 0.15) - (qqq_downside or 0.15))
+ turn_boost * turn_strength
)
qqq_confidence = _clip(
0.28 * (0.5 if qqq_fast is None else _clip((qqq_fast + 0.015) / 0.05))
+ 0.20 * (0.5 if qqq_slow is None else _clip((qqq_slow + 0.03) / 0.10))
+ 0.16 * vol_quality
+ 0.14 * entropy_quality
+ 0.12 * temp_quality
+ 0.10 * macro_calm
)
spy_confidence = _clip(
0.42 * (0.5 if defensive_slow is None else _clip((defensive_slow + 0.02) / 0.08))
+ 0.20 * (0.5 if defensive_downside is None else _clip((0.17 - defensive_downside) / 0.08))
+ 0.16 * (0.5 if defensive_ulcer is None else _clip((0.055 - defensive_ulcer) / 0.05))
+ 0.22 * macro_calm
)
if risk_score >= exit_threshold:
return "sgov", 0.0
if qqq_calm and qqq_ok and leadership_gap >= qqq_strong_threshold:
frac = _clip(0.74 + 0.14 * turn_strength + 0.10 * qqq_confidence - 0.12 * base_stress)
return park_mode, frac
if qqq_calm and qqq_ok and leadership_gap >= qqq_lead_threshold and risk_score <= spy_threshold + 4:
frac = _clip(0.56 + 0.12 * turn_strength + 0.10 * qqq_confidence - 0.12 * base_stress)
return park_mode, frac
if (
turn_strength >= 0.70
and qqq_fast is not None
and qqq_fast > -0.005
and qqq_vol is not None
and qqq_vol <= vol_thr + 0.03
and (qqq_temp is None or qqq_temp <= temp_thr + 0.05)
and risk_score <= spy_threshold + 2
):
frac = _clip(0.38 + 0.22 * turn_strength + 0.12 * qqq_confidence - 0.08 * base_stress)
return park_mode, frac
if defensive_ok and risk_score <= exit_threshold - 1:
frac = _clip(0.40 + 0.18 * macro_calm + 0.10 * turn_strength + 0.10 * spy_confidence - 0.10 * base_stress)
return defensive_symbol, frac
if defensive_ok and risk_score < exit_threshold:
frac = _clip(0.24 + 0.16 * macro_calm + 0.05 * turn_strength - 0.08 * base_stress)
return defensive_symbol, frac
return "sgov", 0.0
def _evaluate_parking_target(self, date: dt.date) -> str | None:
if self._parking_target_cache_valid and self._parking_target_cache_date == date:
return self._parking_target_cache_value
trend_state_before = self._parking_trend_sgov
gate_state_before = self._parking_gate_in_sgov
raw_target = self._compute_parking_target(date)
target = self._apply_parking_target_confirmation(raw_target)
if target != raw_target:
self._parking_trend_sgov = trend_state_before
self._parking_gate_in_sgov = gate_state_before
# Bearish override: upgrade SGOV → inverse ETF when deep stress confirmed.
# Only activates when cash_parking_bearish_symbol is explicitly configured.
bearish_sym = self.config.risk.cash_parking_bearish_symbol
if bearish_sym and target == "sgov":
macro = self.store.get_macro_for_date(date) or {}
risk_score = self._compute_parking_risk_score(macro)
if risk_score >= self.config.risk.cash_parking_bearish_threshold:
target = bearish_sym
self._parking_target_cache_date = date
self._parking_target_cache_value = target
self._parking_target_cache_valid = True
return target
def _parking_default_target(self) -> str:
if self._parking_current_symbol:
return self._parking_current_symbol
if self._parking_committed_target:
return self._parking_committed_target
if self._parking_trend_sgov:
return "sgov"
park_mode = self.config.risk.cash_parking_symbol
return "qqq" if park_mode == "dynamic" else park_mode
def _commit_parking_target(self, target: str | None) -> None:
self._parking_committed_target = target
self._parking_pending_target = None
self._parking_pending_target_days = 0
def _apply_parking_target_confirmation(self, raw_target: str | None) -> str | None:
if raw_target is None:
return None
current_target = self._parking_default_target()
if self._parking_committed_target is None:
self._parking_committed_target = current_target
if raw_target == current_target:
self._parking_pending_target = None
self._parking_pending_target_days = 0
return current_target
if current_target == "sgov" and raw_target != "sgov":
confirm_days = max(1, self.config.risk.cash_parking_entry_confirm_days)
elif current_target != "sgov" and raw_target == "sgov":
confirm_days = max(1, self.config.risk.cash_parking_exit_confirm_days)
else:
confirm_days = max(
1,
self.config.risk.cash_parking_entry_confirm_days,
self.config.risk.cash_parking_exit_confirm_days,
)
if confirm_days <= 1:
self._commit_parking_target(raw_target)
return raw_target
if self._parking_pending_target == raw_target:
self._parking_pending_target_days += 1
else:
self._parking_pending_target = raw_target
self._parking_pending_target_days = 1
if self._parking_pending_target_days >= confirm_days:
self._commit_parking_target(raw_target)
return raw_target
return current_target
def _compute_parking_target(self, date: dt.date) -> str | None:
"""Evaluate gate and return target parking symbol for today."""
macro = self.store.get_macro_for_date(date) or {}
if not macro:
return None
gate_mode = self.config.risk.cash_parking_gate_mode
park_mode = self.config.risk.cash_parking_symbol
defensive_symbol = self._get_parking_defensive_symbol()
defensive_prefix = self._get_parking_defensive_prefix()
signal_prefix = self._get_parking_signal_prefix(park_mode)
if gate_mode == "regime_tiered":
# VIX-driven 3-tier rotation: risk-on symbol → neutral symbol → SGOV
vix = macro.get("VIXCLS")
if vix is None:
return park_mode # fallback to default when VIX unavailable
low_thr = self.config.risk.cash_parking_regime_vix_low_threshold
high_thr = self.config.risk.cash_parking_regime_vix_high_threshold
hyst = self.config.risk.cash_parking_regime_hysteresis_buffer
risk_on_sym = self.config.risk.cash_parking_regime_risk_on_symbol
neutral_sym = self.config.risk.cash_parking_regime_neutral_symbol
current = self._parking_current_symbol or neutral_sym
# Hysteresis: require extra VIX movement to exit current tier
if current == risk_on_sym:
if vix > high_thr:
return "sgov"
elif vix > low_thr + hyst:
return neutral_sym
return risk_on_sym
elif current == "sgov":
if vix < low_thr:
return risk_on_sym
elif vix < high_thr - hyst:
return neutral_sym
return "sgov"
else: # neutral
if vix < low_thr:
return risk_on_sym
elif vix > high_thr:
return "sgov"
return neutral_sym
if gate_mode in ("vol_proportional", "vol_tqqq"):
# These modes handle symbol selection internally
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
vol = macro.get(f"qqq_vol_{vol_lb}")
if gate_mode == "vol_tqqq":
tqqq_pct = self.config.risk.cash_parking_gate_vol_tqqq_pct
vol_thr = self.config.risk.cash_parking_gate_vol_threshold
if vol is not None and vol < tqqq_pct:
return "tqqq"
elif vol is None or vol < vol_thr:
return "qqq"
return "sgov"
return None # proportional handles internally
# Evaluate gate for volatility mode (most common)
if gate_mode == "volatility":
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
vol = macro.get(f"qqq_vol_{vol_lb}")
threshold = self.config.risk.cash_parking_gate_vol_threshold
if vol is not None and vol >= threshold:
self._parking_gate_in_sgov = True
return "sgov"
require_trend = self.config.risk.cash_parking_require_trend
def _should_exit_on_stress() -> bool:
if require_trend:
return True
if not self._parking_trend_sgov:
self._parking_trend_sgov = True
return True
return False
# Entropy check: high entropy = chaotic market → SGOV
ent_thr = self.config.risk.cash_parking_entropy_threshold
if ent_thr > 0:
ent_lb = self.config.risk.cash_parking_entropy_lookback
entropy = macro.get(f"{signal_prefix}_entropy_{ent_lb}")
if entropy is not None and entropy > ent_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
# Re-enter when entropy drops (no momentum check)
if entropy is not None and entropy <= ent_thr * 0.8:
self._parking_trend_sgov = False
else:
return "sgov"
# VRP check: VIX - realized_vol divergence = "quiet before the storm"
vrp_thr = self.config.risk.cash_parking_vrp_threshold
if vrp_thr > 0:
vix_vrp = macro.get("VIXCLS")
vol_vrp = macro.get(f"qqq_vol_{self.config.risk.cash_parking_gate_vol_lookback}")
if vix_vrp is not None and vol_vrp is not None:
vrp = vix_vrp - (vol_vrp * 100)
if vrp > vrp_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
if vrp <= vrp_thr * 0.6:
self._parking_trend_sgov = False
else:
return "sgov"
# Temperature check: vol acceleration (vol_15/vol_50 ratio)
temp_thr = self.config.risk.cash_parking_temperature_threshold
if temp_thr > 0:
vol_short = macro.get("qqq_vol_15")
vol_long = macro.get("qqq_vol_50")
if vol_short is not None and vol_long is not None and vol_long > 0:
temp = vol_short / vol_long
if temp > temp_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
if temp <= temp_thr * 0.7:
self._parking_trend_sgov = False
else:
return "sgov"
# Hurst check: H < threshold = mean-reverting/anti-persistent → SGOV
hurst_thr = self.config.risk.cash_parking_hurst_threshold
if hurst_thr > 0:
hurst = macro.get(f"{signal_prefix}_hurst_60")
if hurst is not None and hurst < hurst_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
if hurst is not None and hurst >= hurst_thr + 0.05:
self._parking_trend_sgov = False
elif hurst is not None:
return "sgov"
# Efficiency check: noisy zig-zag moves are worse than smooth trend.
eff_thr = self.config.risk.cash_parking_efficiency_threshold
if eff_thr > 0:
efficiency = macro.get(f"{signal_prefix}_efficiency_20")
if efficiency is not None and efficiency < eff_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
if efficiency is not None and efficiency >= eff_thr + 0.05:
self._parking_trend_sgov = False
elif efficiency is not None:
return "sgov"
# Downside semivolatility check: penalize left-tail turbulence only.
downside_thr = self.config.risk.cash_parking_downside_vol_threshold
if downside_thr > 0:
downside_vol = macro.get(f"{signal_prefix}_downside_vol_20")
if downside_vol is not None and downside_vol > downside_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
if downside_vol is not None and downside_vol <= downside_thr * 0.82:
self._parking_trend_sgov = False
elif downside_vol is not None:
return "sgov"
# Ulcer check: stay out when recent drawdown pain is persistent.
ulcer_thr = self.config.risk.cash_parking_ulcer_threshold
if ulcer_thr > 0:
ulcer = macro.get(f"{signal_prefix}_ulcer_20")
if ulcer is not None and ulcer > ulcer_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
if ulcer is not None and ulcer <= ulcer_thr * 0.75:
self._parking_trend_sgov = False
elif ulcer is not None:
return "sgov"
# Drawdown acceleration check: rising damage often arrives before vol fully expands.
dd_accel_thr = self.config.risk.cash_parking_drawdown_accel_threshold
if dd_accel_thr > 0:
dd_accel = macro.get(f"{signal_prefix}_drawdown_accel_5")
if dd_accel is not None and dd_accel > dd_accel_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
if dd_accel is not None and dd_accel <= dd_accel_thr * 0.5:
self._parking_trend_sgov = False
elif dd_accel is not None:
return "sgov"
# Kurtosis check: fat tails → SGOV
kurt_thr = self.config.risk.cash_parking_kurtosis_threshold
if kurt_thr > 0:
kurt = macro.get(f"{signal_prefix}_kurtosis_20")
if kurt is not None and kurt > kurt_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
if kurt is not None and kurt <= kurt_thr * 0.6:
self._parking_trend_sgov = False
elif kurt is not None:
return "sgov"
# Autocorrelation check: negative = reversal regime → SGOV
ac_thr = self.config.risk.cash_parking_autocorr_threshold
if ac_thr > -99:
ac = macro.get(f"{signal_prefix}_autocorr_20")
if ac is not None and ac < ac_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
if ac is not None and ac >= ac_thr + 0.1:
self._parking_trend_sgov = False
elif ac is not None:
return "sgov"
# SPY-QQQ correlation check: decorrelation = regime shift → SGOV
corr_thr = self.config.risk.cash_parking_corr_threshold
if corr_thr > 0:
corr = macro.get(self._get_parking_defensive_corr_key())
if corr is not None and corr < corr_thr and _should_exit_on_stress():
self._parking_gate_in_sgov = True
return "sgov"
if self._parking_trend_sgov and not require_trend:
if corr is not None and corr >= corr_thr + 0.05:
self._parking_trend_sgov = False
elif corr is not None:
return "sgov"
# Additional trend check: QQQ must confirm uptrend to stay invested
if require_trend:
trend_mode = self.config.risk.cash_parking_trend_mode
period = self.config.risk.cash_parking_trend_sma_period
reentry_pct = self.config.risk.cash_parking_trend_reentry_pct
if trend_mode == "momentum":
mom = macro.get(f"{signal_prefix}_mom_{period}")
if self._parking_trend_sgov:
# In SGOV: need strong recovery + VIX calm to re-enter
mom_ok = mom is not None and mom > reentry_pct
vix_max = self.config.risk.cash_parking_vix_reentry_max
vix_ok = True
if vix_max > 0:
vix = macro.get("VIXCLS")
vix_ok = vix is not None and vix < vix_max
if mom_ok and vix_ok:
self._parking_trend_sgov = False
return park_mode # recovery confirmed
return "sgov" # still waiting
else:
# In QQQ: exit if momentum turns negative
if mom is not None and mom <= 0:
self._parking_trend_sgov = True
return "sgov"
# Also exit if entropy is too high (chaotic market)
ent_thr = self.config.risk.cash_parking_entropy_threshold
if ent_thr > 0:
ent_lb = self.config.risk.cash_parking_entropy_lookback
entropy = macro.get(f"{signal_prefix}_entropy_{ent_lb}")
if entropy is not None and entropy > ent_thr:
self._parking_trend_sgov = True
return "sgov"
else: # sma
c = macro.get(f"{signal_prefix}_close")
sma = macro.get(f"{signal_prefix}_sma_{period}")
if c and sma and c < sma:
return "sgov"
if self._parking_gate_in_sgov:
vol_mult = float(self.config.risk.cash_parking_stress_reentry_vol_mult or 1.0)
temp_mult = float(self.config.risk.cash_parking_stress_reentry_temperature_mult or 1.0)
ent_mult = float(self.config.risk.cash_parking_stress_reentry_entropy_mult or 1.0)
ac_buffer = float(self.config.risk.cash_parking_stress_reentry_autocorr_buffer or 0.0)
if vol is not None and vol_mult < 1.0 and vol >= threshold * vol_mult:
return "sgov"
if ent_thr > 0:
entropy = macro.get(f"{signal_prefix}_entropy_{self.config.risk.cash_parking_entropy_lookback}")
if entropy is not None and ent_mult < 1.0 and entropy > ent_thr * ent_mult:
return "sgov"
if temp_thr > 0:
vol_short = macro.get("qqq_vol_15")
vol_long = macro.get("qqq_vol_50")
if vol_short is not None and vol_long is not None and vol_long > 0:
temp = vol_short / vol_long
if temp_mult < 1.0 and temp > temp_thr * temp_mult:
return "sgov"
if ac_thr > -99 and ac_buffer > 0:
ac = macro.get(f"{signal_prefix}_autocorr_20")
if ac is None or ac < ac_thr + ac_buffer:
return "sgov"
if require_trend and self.config.risk.cash_parking_trend_mode == "momentum":
mom = macro.get(f"{signal_prefix}_mom_{self.config.risk.cash_parking_trend_sma_period}")
if mom is None or mom <= reentry_pct:
return "sgov"
self._parking_gate_in_sgov = False
overlay_target = self._evaluate_low_vol_overlay_target(macro, park_mode)
if overlay_target is not None:
return overlay_target
return park_mode # qqq or spy
# Composite gate: multi-signal risk score
if gate_mode == "guarded_regime":
vol_lb = self.config.risk.cash_parking_gate_vol_lookback
vol = macro.get(f"qqq_vol_{vol_lb}")
if vol is None:
return "sgov"
qqq_mom_days = self.config.risk.cash_parking_trend_sma_period
qqq_mom = macro.get(f"qqq_mom_{qqq_mom_days}")
defensive_mom = macro.get(f"{defensive_prefix}_mom_{qqq_mom_days}")
tqqq_threshold = self.config.risk.cash_parking_gate_vol_tqqq_pct
qqq_threshold = self.config.risk.cash_parking_gate_vol_threshold
spy_threshold = self.config.risk.cash_parking_gate_vol_spy_threshold
tqqq_mom_min = self.config.risk.cash_parking_trend_reentry_pct
entropy_hot = False
ent_thr = self.config.risk.cash_parking_entropy_threshold
if ent_thr > 0:
ent_lb = self.config.risk.cash_parking_entropy_lookback
entropy = macro.get(f"qqq_entropy_{ent_lb}")
entropy_hot = entropy is not None and entropy > ent_thr
temperature_hot = False
temp_thr = self.config.risk.cash_parking_temperature_threshold
if temp_thr > 0:
vol_short = macro.get("qqq_vol_15")
vol_long = macro.get("qqq_vol_50")
if vol_short is not None and vol_long is not None and vol_long > 0:
temperature_hot = (vol_short / vol_long) > temp_thr
vix_blocked = False
vix_max = self.config.risk.cash_parking_vix_reentry_max
if vix_max > 0:
vix = macro.get("VIXCLS")
vix_blocked = vix is None or vix >= vix_max
if entropy_hot or temperature_hot or vix_blocked:
if vol < spy_threshold and defensive_mom is not None and defensive_mom > 0:
return defensive_symbol
return "sgov"
if (
vol < tqqq_threshold
and qqq_mom is not None
and qqq_mom > tqqq_mom_min
):
return "tqqq"
if vol < qqq_threshold and qqq_mom is not None and qqq_mom > 0:
return park_mode
if vol < spy_threshold and defensive_mom is not None and defensive_mom > 0:
return defensive_symbol
return "sgov"
if gate_mode == "science_regime":
return self._evaluate_science_regime_target(macro, park_mode)
if gate_mode == "science_blend":
target_sym, invested_fraction = self._evaluate_science_blend_plan(macro, park_mode)
if invested_fraction <= 0.05:
return "sgov"
return target_sym
if gate_mode == "vt_blend":
target_sym, invested_fraction = self._evaluate_vt_blend_plan(macro, park_mode)
if invested_fraction <= 0.05:
return "sgov"
return target_sym
if gate_mode == "vt_pair_blend":
target_sym, invested_fraction = self._evaluate_vt_pair_blend_plan(macro, park_mode)
if invested_fraction <= 0.05:
return "sgov"
return target_sym
if gate_mode == "relative_strength":
target_sym, invested_fraction = self._evaluate_relative_strength_plan(date, macro, park_mode)
if invested_fraction <= 0.05:
return "sgov"
return target_sym
if gate_mode == "composite":
risk_score = self._compute_parking_risk_score(macro)
exit_threshold = self.config.risk.cash_parking_composite_exit_score
enter_threshold = self.config.risk.cash_parking_composite_enter_score
if self._parking_trend_sgov:
# In SGOV: need low risk AND momentum recovery to re-enter
risk_ok = risk_score <= enter_threshold if enter_threshold > 0 else True
mom_ok = True
if self.config.risk.cash_parking_require_trend and self.config.risk.cash_parking_trend_mode == "momentum":
period = self.config.risk.cash_parking_trend_sma_period
reentry_pct = self.config.risk.cash_parking_trend_reentry_pct
mom = macro.get(f"{signal_prefix}_mom_{period}")
mom_ok = mom is not None and mom > reentry_pct
if risk_ok and mom_ok:
self._parking_trend_sgov = False
return park_mode
return "sgov"
else:
# In QQQ: exit if risk too high OR momentum negative
if risk_score >= exit_threshold:
self._parking_trend_sgov = True
return "sgov"
# Also check momentum for low-vol declines
if self.config.risk.cash_parking_require_trend and self.config.risk.cash_parking_trend_mode == "momentum":
period = self.config.risk.cash_parking_trend_sma_period
mom = macro.get(f"{signal_prefix}_mom_{period}")
if mom is not None and mom <= 0:
self._parking_trend_sgov = True
return "sgov"
overlay_target = self._evaluate_low_vol_overlay_target(macro, park_mode)
if overlay_target is not None:
return overlay_target
return park_mode
# For other gate modes, use simple SMA check
gate_p = self.config.risk.cash_parking_gate_sma_period
if park_mode in ("spy", "qqq", "spym", "qual") and self.config.risk.cash_parking_trend_gate:
prefix = park_mode
c = macro.get(f"{prefix}_close")
sma = macro.get(f"{prefix}_sma_{gate_p}")
if c and sma and c < sma:
return "sgov"
return park_mode
def _compute_parking_risk_score(self, macro: dict) -> int:
"""Compute composite risk score from multiple independent signals.
Returns 0 (safest) to 100 (most dangerous).
Signals: VIX level, VIX velocity, HY credit spread, realized vol, momentum.
"""
score = 0
# 1. VIX level (forward-looking implied vol — fastest fear signal)
vix = macro.get("VIXCLS")
if vix is not None:
if vix > 30:
score += 45
elif vix > 25:
score += 35
elif vix > 20:
score += 15
elif vix > 17:
score += 5
# 2. VIX velocity (rapid rise = panic incoming)
vix_chg_5 = macro.get("vix_change_5d")
if vix_chg_5 is not None:
if vix_chg_5 > 8:
score += 25
elif vix_chg_5 > 5:
score += 15
elif vix_chg_5 > 3:
score += 8
# 3. HY credit spread (independent bond market stress)
hy = macro.get("BAMLH0A0HYM2")
if hy is not None:
if hy > 6.0:
score += 30
elif hy > 5.0:
score += 20
elif hy > 4.0:
score += 8
# 4. QQQ realized vol
vol = macro.get("qqq_vol_20")
if vol is not None:
if vol > 0.30:
score += 20
elif vol > 0.24:
score += 10
elif vol > 0.20:
score += 3
# 5. QQQ momentum (trend direction)
mom = macro.get("qqq_mom_20")
if mom is not None:
if mom < -0.05:
score += 15
elif mom < -0.02:
score += 10
elif mom < 0:
score += 5
# 6. Defensive ETF + QQQ dual weakness (broad market confirmation)
defensive_mom = macro.get(f"{self._get_parking_defensive_prefix()}_mom_20")
if defensive_mom is not None and mom is not None:
if defensive_mom < 0 and mom < 0:
score += 8
# 7. VRP — Volatility Risk Premium (Carr & Wu, 2009)
if vix is not None and vol is not None:
vrp = vix - (vol * 100)
if vrp > 12:
score += 20
elif vrp > 8:
score += 10
elif vrp > 5:
score += 5
# 8. Market Temperature — vol acceleration (vol_15 / vol_50)
vol_short = macro.get("qqq_vol_15")
vol_long = macro.get("qqq_vol_50")
if vol_short is not None and vol_long is not None and vol_long > 0:
temp = vol_short / vol_long
if temp > 1.5:
score += 25
elif temp > 1.3:
score += 15
elif temp > 1.1:
score += 5
# 9. Hurst exponent — fractal dimension (Mandelbrot)
hurst = macro.get("qqq_hurst_60")
if hurst is not None:
if hurst < 0.40:
score += 15
elif hurst < 0.45:
score += 8
# 10. Rolling kurtosis — fat tail detection (Taleb)
kurt = macro.get("qqq_kurtosis_20")
if kurt is not None:
if kurt > 4.0:
score += 15
elif kurt > 3.0:
score += 8
# 11. Return autocorrelation — momentum quality (Lo, 2004)
autocorr = macro.get("qqq_autocorr_20")
if autocorr is not None:
if autocorr < -0.2:
score += 12
elif autocorr < -0.1:
score += 6
# 12. Defensive-QQQ decorrelation — regime shift
corr = macro.get(self._get_parking_defensive_corr_key())
if corr is not None:
if corr < 0.75:
score += 15
elif corr < 0.80:
score += 8
return min(score, 100)
def _get_parking_value(self, date: dt.date) -> float:
"""Current market value of all parked positions."""
val = 0.0
if self._parking_current_symbol == "tqqq_blend":
macro = self.store.get_macro_for_date(date) or {}
if self._parking_blend_tqqq_shares > 0:
tqqq_p = macro.get("tqqq_close", self._parking_blend_tqqq_avg_price)
val += self._parking_blend_tqqq_shares * tqqq_p
if self._parking_blend_qqqm_shares > 0:
qqqm_p = macro.get("qqqm_close", self._parking_blend_qqqm_avg_price)
val += self._parking_blend_qqqm_shares * qqqm_p
elif self._parking_shares > 0:
macro = self.store.get_macro_for_date(date) or {}
p = macro.get(f"{self._parking_current_symbol}_close", self._parking_avg_price)
val += self._parking_shares * p
val += self._mark_parallel_sgov_to_market(date)
return val
def _build_portfolio_state(
self,
date: dt.date,
drawdown_pct: float,
unrealized: float,
) -> DailyPortfolioState:
gross_exposure, net_exposure = self._compute_portfolio_exposure(date)
# Idle capital decomposition: separate IA-sleeve vs primary notional
ia_exposure = 0.0
for pos in self._open_positions:
if self._is_post_allocation_idle_engine_id(pos.plan.engine_id):
close = self._resolve_close_price(
pos.plan.candidate.symbol, date, pos.entry_price,
)
ia_exposure += close * pos.shares_open
parking_val = self._get_parking_value(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,
raw_cash=self._cash,
parking_value=parking_val,
idle_alpha_exposure=ia_exposure,
primary_exposure=max(0.0, gross_exposure - ia_exposure),
)
def _liquidate_parking(self, date: dt.date, timing: str = "auto") -> bool:
"""Sell parking position to free cash for event entries. Returns True if cash was freed.
timing:
- "auto" (default): pick open if no event activity today yet, else close.
- "close": force EOD close (sim-end cleanup).
- "open": force session open (fund event entries at open).
"""
freed = False
macro = self.store.get_macro_for_date(date) or {}
self._mark_parallel_sgov_to_market(date, macro)
if timing == "auto":
_px_key = "close" if getattr(self, "_had_event_activity_today", False) else "open"
else:
_px_key = timing
# Blend mode: liquidate both TQQQ and QQQM legs
if self._parking_current_symbol == "tqqq_blend":
for sym, shares, avg_price in [
("tqqq", self._parking_blend_tqqq_shares, self._parking_blend_tqqq_avg_price),
("qqqm", self._parking_blend_qqqm_shares, self._parking_blend_qqqm_avg_price),
]:
if shares > 0:
close_price = macro.get(f"{sym}_{_px_key}") or macro.get(f"{sym}_close")
if close_price and close_price > 0:
exit_price = close_price
proceeds = shares * close_price
else:
exit_price = avg_price
proceeds = shares * avg_price
parking_pnl = proceeds - (shares * avg_price)
self._cash += proceeds
self._realized_pnl += parking_pnl
self._record_parking_trade(date, sym, shares, avg_price, exit_price)
self._parking_blend_tqqq_shares = 0
self._parking_blend_tqqq_avg_price = 0.0
self._parking_blend_qqqm_shares = 0
self._parking_blend_qqqm_avg_price = 0.0
self._parking_current_symbol = ""
self._parking_sold_today = True
self._parking_overlay_hold_days = 0
freed = True
elif self._parking_shares > 0:
entry_price_for_record = self._parking_avg_price
park_close = macro.get(f"{self._parking_current_symbol}_{_px_key}") or macro.get(f"{self._parking_current_symbol}_close")
if park_close and park_close > 0:
exit_price = park_close
proceeds = self._parking_shares * park_close
parking_pnl = proceeds - (self._parking_shares * self._parking_avg_price)
self._cash += proceeds
self._realized_pnl += parking_pnl
else:
exit_price = self._parking_avg_price
self._cash += self._parking_shares * self._parking_avg_price
# Record parking trade
self._record_parking_trade(
date, self._parking_current_symbol, self._parking_shares,
entry_price_for_record, exit_price,
)
sold_sym = self._parking_current_symbol
self._parking_shares = 0
self._parking_avg_price = 0.0
self._parking_current_symbol = ""
self._parking_overlay_hold_days = 0
# Mark sold to prevent same-day re-buy (day trade rule)
if sold_sym:
self._parking_sold_today = True
freed = True
if self._parking_sgov_value > 0:
self._realized_pnl += self._parking_sgov_value - self._parking_sgov_entry_value
self._cash += self._parking_sgov_value
self._reset_parallel_sgov_state()
freed = True
if self._parking_shares <= 0 and self._parking_sgov_value <= 0 and self._parking_current_symbol != "tqqq_blend":
self._parking_entry_date = None
self._parking_peak_price = 0.0
return freed
def _liquidate_parking_for_cash(self, date: dt.date, required_cash: float) -> bool:
"""Free only the cash shortfall from parking instead of liquidating the whole sleeve."""
remaining_needed = max(0.0, required_cash)
if remaining_needed <= 0:
return False
freed = False
if self._parking_sgov_value > 0 and remaining_needed > 0:
self._mark_parallel_sgov_to_market(date)
starting_value = self._parking_sgov_value
released = min(starting_value, remaining_needed)
basis_released = (
self._parking_sgov_entry_value * (released / starting_value)
if starting_value > 0
else 0.0
)
self._parking_sgov_value -= released
self._parking_sgov_entry_value = max(
0.0,
self._parking_sgov_entry_value - basis_released,
)
self._cash += released
self._realized_pnl += released - basis_released
remaining_needed -= released
freed = released > 0
if self._parking_sgov_value < 1e-9:
self._reset_parallel_sgov_state()
# Blend mode: liquidate entire blend position if needed
# This sell funds an event entry filling at OPEN, so use open price.
if self._parking_current_symbol == "tqqq_blend" and remaining_needed > 0:
blend_freed = self._liquidate_parking(date, timing="open")
if blend_freed:
self._parking_freed_for_cash_today = True
freed = True
return freed
if self._parking_shares <= 0 or remaining_needed <= 0:
if self._parking_shares <= 0 and self._parking_sgov_value <= 0:
self._parking_entry_date = None
self._parking_peak_price = 0.0
if freed:
self._parking_freed_for_cash_today = True
return freed
macro = self.store.get_macro_for_date(date) or {}
symbol = self._parking_current_symbol
# Fund event entries at OPEN: prefer the open bar, fall back to close if missing.
park_close = macro.get(f"{symbol}_open") or macro.get(f"{symbol}_close")
if not park_close or park_close <= 0:
park_close = self._parking_avg_price
if park_close <= 0:
return freed
shares_to_sell = min(
self._parking_shares,
max(1, math.ceil(remaining_needed / park_close)),
)
proceeds = shares_to_sell * park_close
parking_pnl = proceeds - (shares_to_sell * self._parking_avg_price)
self._cash += proceeds
self._realized_pnl += parking_pnl
self._record_parking_trade(
date,
symbol,
shares_to_sell,
self._parking_avg_price,
park_close,
)
self._parking_shares -= shares_to_sell
if self._parking_shares <= 0:
self._parking_shares = 0
self._parking_avg_price = 0.0
self._parking_current_symbol = ""
if self._parking_sgov_value <= 0:
self._parking_entry_date = None
self._parking_peak_price = 0.0
self._parking_sold_today = True
self._parking_freed_for_cash_today = True
return True
def _record_parking_trade(
self, exit_date: dt.date, symbol: str, shares: int,
entry_price: float, exit_price: float,
) -> None:
"""Record a parking round-trip as a FilledTrade."""
if shares <= 0 or not symbol:
return
entry_date = self._parking_entry_date or exit_date
entry_px = entry_price
exit_px = exit_price
shares_count = shares
gross_pnl = (exit_px - entry_px) * shares_count
net_pnl = gross_pnl
pnl_pct = (exit_px - entry_px) / entry_px if entry_px > 0 else 0
self._parking_trade_counter += 1
trade = FilledTrade(
trade_id=f"park-{self._parking_trade_counter}",
position_id=f"park-{self._parking_trade_counter}",
event_id="cash_parking",
symbol=symbol.upper(),
source_symbol=None,
event_type="cash_parking",
score=0.0,
engine_id="cash_parking",
trade_symbol_mode="event",
entry_date=entry_date,
exit_date=exit_date,
entry_price=round(entry_px, 2),
exit_price=round(exit_px, 2),
exit_reason=ExitReason.PARKING,
shares=shares_count,
commission=0.0,
slippage_bps=0.0,
gross_pnl=round(gross_pnl, 2),
net_pnl=round(net_pnl, 2),
pnl_pct=round(pnl_pct, 4),
r_multiple=0.0,
holding_days=(exit_date - entry_date).days,
)
self._closed_trades.append(trade)
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
if (
engine_cfg.rotation_min_unrealized_r is not None
and unrealized_r >= engine_cfg.rotation_min_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)."""
exit_reason = (
ExitReason.END_OF_BACKTEST
if reason == "end_of_backtest"
else ExitReason.KILL_SWITCH
)
for pos in list(self._open_positions):
exit_date = date
bar = self.store.get_bar(pos.plan.candidate.symbol, exit_date)
if bar is None:
latest_bar = self.store.get_latest_bar_on_or_before(
pos.plan.candidate.symbol,
exit_date,
)
if latest_bar is not None:
exit_date, bar = latest_bar
trade = simulate_kill_switch_exit(
pos,
bar,
exit_date,
self.config.execution,
exit_reason=exit_reason,
)
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_request_id = config.requested_snapshot_id or config.dataset_snapshot_id
snapshot_dir = resolve_snapshot_path(
snapshot_request_id,
snapshot_dir=snapshot_dir_override,
)
if snapshot_dir is None:
raise FileNotFoundError(
f"Snapshot directory not found for requested snapshot '{snapshot_request_id}' "
f"(canonical '{config.canonical_snapshot_id or config.dataset_snapshot_id}')"
)
scoring_fn = _resolve_scoring_fn(config)
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 _resolve_scoring_fn(config: BacktestConfig) -> Any | None:
scoring_model = config.signal.scoring_model
if scoring_model == "pead":
from functools import partial
from libs.backtest.scoring import compute_pead_score
return partial(
compute_pead_score,
reaction_threshold=config.signal.pead_reaction_threshold,
volume_threshold=config.signal.pead_volume_threshold,
)
if scoring_model.startswith("return_max_long_"):
from libs.backtest import scoring as scoring_mod
suffix = scoring_model.removeprefix("return_max_long_")
fn_name = "compute_return_max_long_score" if suffix == "v1" else f"compute_return_max_long_score_{suffix}"
return getattr(scoring_mod, fn_name)
if scoring_model.startswith("return_max_short_"):
from libs.backtest import scoring as scoring_mod
suffix = scoring_model.removeprefix("return_max_short_")
return getattr(scoring_mod, f"compute_return_max_short_score_{suffix}")
if scoring_model.startswith("return_max_longshort_"):
from libs.backtest import scoring as scoring_mod
suffix = scoring_model.removeprefix("return_max_longshort_")
return getattr(scoring_mod, f"compute_return_max_longshort_{suffix}")
if scoring_model == "oversold_bounce":
from libs.backtest.scoring import compute_oversold_bounce_score
return compute_oversold_bounce_score
if scoring_model == "patient_drift":
from libs.backtest.scoring import compute_patient_drift_score
return compute_patient_drift_score
if scoring_model == "microstructure":
from libs.backtest.scoring import compute_microstructure_score
return compute_microstructure_score
return None
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:
from libs.common.config import get_settings
s = get_settings()
snapshot_request_id = config.requested_snapshot_id or config.dataset_snapshot_id
snapshot_dir = resolve_snapshot_path(
snapshot_request_id,
snapshot_dir=snapshot_dir_override,
)
if snapshot_dir is None:
raise FileNotFoundError(
f"Snapshot directory not found for requested snapshot '{snapshot_request_id}' "
f"(canonical '{config.canonical_snapshot_id or config.dataset_snapshot_id}')"
)
return SnapshotStore.load_merged(
snapshot_dir=snapshot_dir,
split_names=["train", "valid", "test"],
oracle_url=s.stock_oracle_url,
db_dsn=s.postgres_dsn,
scoring_fn=_resolve_scoring_fn(config),
)
def _last_market_closed_date() -> dt.date:
from libs.common.time_utils import is_trading_day, to_eastern, utc_now
now_et = to_eastern(utc_now())
if not is_trading_day(now_et.date()) or now_et.hour >= 16:
return now_et.date()
return now_et.date() - dt.timedelta(days=1)
def _compute_max_effective_mhd(config: BacktestConfig) -> int:
"""Return the widest max_holding_days value across all engines and event profiles."""
mhd = config.execution.max_holding_days
if config.execution.dynamic_hold_enabled:
mhd = max(mhd, config.execution.dynamic_hold_extend_to)
for engine in config.get_strategy_engines():
if engine.max_holding_days is not None:
engine_mhd = engine.max_holding_days
if engine.dynamic_hold_extend_to_override is not None:
engine_mhd = max(engine_mhd, engine.dynamic_hold_extend_to_override)
mhd = max(mhd, engine_mhd)
for profile in (config.event_type_profiles or {}).values():
if profile.max_holding_days_override is not None:
mhd = max(mhd, profile.max_holding_days_override)
return mhd
def _extend_store_to_requested_window(
*,
store: SnapshotStore,
config: BacktestConfig,
start_date: dt.date,
end_date: dt.date,
snapshot_dir_override: str | None = None,
) -> SnapshotStore:
"""Extend macro/bars to requested window so parking-only runs honor the full range."""
import asyncio as _aio
import pickle
from libs.common.config import get_settings
if start_date > end_date:
return store
setattr(store, "_requested_start_date", start_date)
setattr(store, "_requested_end_date", end_date)
settings = get_settings()
snapshot_request_id = config.requested_snapshot_id or config.dataset_snapshot_id
snapshot_path = resolve_snapshot_path(
snapshot_request_id,
snapshot_dir=snapshot_dir_override,
)
extend_end = min(end_date, _last_market_closed_date())
def _merge_macro_dict(extra_macro: dict[dt.date, dict[str, Any]]) -> int:
added = 0
for macro_date, values in extra_macro.items():
existing = store._macro.get(macro_date)
if existing is None:
store._macro[macro_date] = dict(values)
added += 1
else:
existing.update(values)
return added
# Backfill/extend macro window. Parking-only runs depend on macro dates
# to create the full trading-day calendar even when there are no events.
macro_cache_file = (
snapshot_path / f"macro_window_{start_date.isoformat()}_{extend_end.isoformat()}.pkl"
if snapshot_path is not None
else None
)
macro_loaded_from_cache = False
if macro_cache_file and macro_cache_file.exists():
try:
cached_macro = pickle.loads(macro_cache_file.read_bytes())
if isinstance(cached_macro, dict):
added = _merge_macro_dict(cached_macro)
logger.info(
"backtest_macro_window_loaded_from_cache",
file=str(macro_cache_file),
added_days=added,
)
macro_loaded_from_cache = True
except Exception as exc:
logger.warning(
"backtest_macro_window_cache_failed",
file=str(macro_cache_file),
error=str(exc),
)
if not macro_loaded_from_cache:
existing_macro_dates = sorted(store._macro.keys())
missing_ranges: list[tuple[dt.date, dt.date]] = []
if not existing_macro_dates:
missing_ranges.append((start_date, extend_end))
else:
min_macro = existing_macro_dates[0]
max_macro = existing_macro_dates[-1]
if start_date < min_macro:
missing_ranges.append((start_date, min_macro - dt.timedelta(days=1)))
if max_macro < extend_end:
missing_ranges.append((max_macro + dt.timedelta(days=1), extend_end))
cached_macro_payload: dict[dt.date, dict[str, Any]] = {}
total_added = 0
for range_start, range_end in missing_ranges:
if range_start > range_end:
continue
try:
fred_macro = _aio.run(
SnapshotStore._fetch_macro((range_start, range_end), settings.postgres_dsn)
)
except Exception as exc:
logger.warning(
"backtest_macro_fetch_failed",
range_start=range_start.isoformat(),
range_end=range_end.isoformat(),
error=str(exc),
)
fred_macro = {}
try:
price_macro = _aio.run(
SnapshotStore._fetch_spy_macro((range_start, range_end), settings.stock_oracle_url)
)
except Exception as exc:
logger.warning(
"backtest_spy_macro_fetch_failed",
range_start=range_start.isoformat(),
range_end=range_end.isoformat(),
error=str(exc),
)
price_macro = {}
merged_segment: dict[dt.date, dict[str, Any]] = {}
for macro_date, values in fred_macro.items():
merged_segment.setdefault(macro_date, {}).update(values)
for macro_date, values in price_macro.items():
merged_segment.setdefault(macro_date, {}).update(values)
total_added += _merge_macro_dict(merged_segment)
for macro_date, values in merged_segment.items():
cached_macro_payload.setdefault(macro_date, {}).update(values)
if total_added:
logger.info(
"backtest_macro_window_extended",
added_days=total_added,
start=min(store._macro).isoformat() if store._macro else None,
end=max(store._macro).isoformat() if store._macro else None,
)
if macro_cache_file and cached_macro_payload:
try:
macro_cache_file.parent.mkdir(parents=True, exist_ok=True)
macro_cache_file.write_bytes(
pickle.dumps(cached_macro_payload, protocol=pickle.HIGHEST_PROTOCOL)
)
logger.info(
"backtest_macro_window_cached",
file=str(macro_cache_file),
)
except Exception as exc:
logger.warning(
"backtest_macro_window_cache_write_failed",
file=str(macro_cache_file),
error=str(exc),
)
# Cap _requested_end_date at the last day where parking symbols actually have
# close-price data. SPY/QQQ are fetched as core and settle before QQQM/TQQQ/SGOV,
# which are fetched as extras and can lag Oracle by minutes after market close.
_parking_close_keys = ("qqqm_close", "tqqq_close", "sgov_close")
_parking_last_dates: list[dt.date] = []
for _k in _parking_close_keys:
_sym_dates = [d for d in store._macro if store._macro[d].get(_k)]
if _sym_dates:
_parking_last_dates.append(max(_sym_dates))
if _parking_last_dates:
_parking_cap = min(_parking_last_dates)
_parking_best = max(_parking_last_dates)
# Allow up to 1 trading-day lag per symbol (extras like QQQM can lag Oracle
# by a few minutes after market close, leaving a None for the most recent bar).
# If the straggler is only 1 trading day behind the best-covered symbol, use
# the best date so the simulation isn't unnecessarily capped.
if _parking_cap < _parking_best:
from libs.backtest.calendar import get_trading_days as _gtd
_lag_days = max(0, len(_gtd(_parking_cap, _parking_best)) - 1)
if _lag_days <= 1:
_parking_cap = _parking_best
current_end = getattr(store, "_requested_end_date", end_date)
if _parking_cap < current_end:
setattr(store, "_requested_end_date", _parking_cap)
# Extend individual stock bars only forward. Needed so open positions and
# event entries can still be valued when end_date exceeds snapshot coverage.
if store._bars:
cache_file = (
snapshot_path / f"bars_extended_{extend_end.isoformat()}.pkl"
if snapshot_path is not None
else None
)
cached = False
if cache_file and cache_file.exists():
try:
cached_bars = pickle.loads(cache_file.read_bytes())
added = 0
for sym, date_bars in cached_bars.items():
existing = store._bars.setdefault(sym, {})
for bar_date, bar in date_bars.items():
if bar_date not in existing:
existing[bar_date] = bar
added += 1
if added:
logger.info(
"backtest_bars_loaded_from_cache",
file=str(cache_file),
added_bars=added,
)
cached = True
except Exception:
cached = False
if not cached:
symbols_to_extend: list[tuple[str, dt.date]] = []
for sym, sym_bars in store._bars.items():
if not sym_bars:
continue
max_bar_date = max(sym_bars.keys())
if max_bar_date < extend_end:
symbols_to_extend.append((sym, max_bar_date))
if symbols_to_extend:
fetch_start = min(max_bar_date for _, max_bar_date in symbols_to_extend)
total = len(symbols_to_extend)
logger.info(
"backtest_bars_extending",
symbols=total,
fetch_range=f"{fetch_start}{extend_end}",
)
batch_size = 50
all_new_bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
for batch_idx in range(0, total, batch_size):
batch = symbols_to_extend[batch_idx:batch_idx + batch_size]
batch_num = batch_idx // batch_size + 1
total_batches = (total + batch_size - 1) // batch_size
logger.info(
"backtest_bars_batch",
batch=f"{batch_num}/{total_batches}",
symbols=len(batch),
)
try:
result = _aio.run(
SnapshotStore._fetch_price_data(
[sym for sym, _ in batch],
(fetch_start, extend_end),
settings.stock_oracle_url,
concurrency=8,
)
)
except Exception as exc:
logger.warning(
"backtest_bars_batch_failed",
batch=batch_num,
error=str(exc),
)
continue
for sym, new_bars in result[0].items():
all_new_bars.setdefault(sym, {}).update(new_bars)
added_count = 0
new_bars_only: dict[str, dict[dt.date, dict[str, Any]]] = {}
for sym, date_bars in all_new_bars.items():
existing = store._bars.setdefault(sym, {})
existing_max = max(existing.keys()) if existing else None
for bar_date, bar in date_bars.items():
if existing_max is None or bar_date > existing_max:
existing[bar_date] = bar
new_bars_only.setdefault(sym, {})[bar_date] = bar
added_count += 1
if added_count:
logger.info(
"backtest_bars_extended",
symbols=len(symbols_to_extend),
added_bars=added_count,
)
if cache_file and new_bars_only:
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_bytes(
pickle.dumps(new_bars_only, protocol=pickle.HIGHEST_PROTOCOL)
)
logger.info("backtest_bars_cached", file=str(cache_file))
except Exception as exc:
logger.warning("backtest_bars_cache_failed", error=str(exc))
return store
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,
start_date: dt.date | None = None,
end_date: dt.date | 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)
if start_date is not None or end_date is not None:
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.")
start_d = start_date or all_dates[0]
end_d = end_date or all_dates[-1]
merged_store = merged_store.slice_by_date_range(start_d, end_d)
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,
start_date: dt.date | None = None,
end_date: dt.date | None = None,
) -> RobustnessMatrixSummary:
"""Run rolling horizon robustness validation over multiple start dates."""
merged_store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override)
if start_date is not None or end_date is not None:
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.")
start_d = start_date or all_dates[0]
end_d = end_date or all_dates[-1]
merged_store = merged_store.slice_by_date_range(start_d, end_d)
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/all). 'all' merges all splits for full-period backtest.")
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)")
parser.add_argument("--start", default=None, help="Start date filter YYYY-MM-DD (inclusive)")
parser.add_argument("--end", default=None, help="End date filter YYYY-MM-DD (inclusive)")
parser.add_argument("--parking", default=None, help="Cash parking preset (e.g. qqqm_low_dd)")
parser.add_argument("--idle-alpha", default=None, help="Idle alpha sleeve preset (e.g. micro_event_alpha)")
parser.add_argument("--idle-alpha-dedup", default=None, choices=["skip", "rename"], help="IA dedup mode: 'skip' (default) skips conflicting IA engines; 'rename' adds __ia_sleeve suffix and injects anyway")
parser.add_argument("--dividend-sleeve", default=None, help="Dividend capture sleeve preset name")
parser.add_argument("--form4-sleeve", default=None, help="Form 4 capture sleeve preset (e.g. reserve_form4_cluster)")
parser.add_argument("--ownership-sleeve", default=None, help="Ownership 13D/13G sleeve preset (e.g. ownership_13d_raise_reserve_plus_strict)")
parser.add_argument("--risk-off-sleeve", default=None, help="Risk-off alpha sleeve preset (e.g. risk_off_alpha_gld_crisis60)")
parser.add_argument("--non-core-allocator-v2", action="store_true", help="Enable non-core allocator v2")
parser.add_argument("--non-core-allocator-v2-mode", choices=["shadow", "live"], default=None, help="Non-core allocator v2 mode")
args = parser.parse_args()
manifest = load_manifest(args.manifest)
config = resolve_config(manifest, config_root=args.config_root, snapshot_id_override=args.snapshot_id)
# Auto-refresh snapshot if stale (covers CLI and web subprocess paths).
# Uses _last_market_closed_date() as the reference so the snapshot updates
# once market closes today (same criteria as price bar extension).
import asyncio as _asyncio
from apps.paper_trader.backtest_sim import _refresh_snapshot, _snapshot_needs_refresh
_snap_id = config.canonical_snapshot_id or config.dataset_snapshot_id
if _snapshot_needs_refresh(_snap_id, _last_market_closed_date()):
_asyncio.run(_refresh_snapshot(_snap_id, universe_profile=None))
if args.mode:
config.risk.backtest_mode = args.mode
if args.parking:
config.risk.cash_parking_preset = args.parking
config.risk.apply_parking_preset()
if args.idle_alpha:
if args.idle_alpha_dedup:
config.idle_alpha_dedup_mode = args.idle_alpha_dedup
config.idle_alpha_sleeve_preset = args.idle_alpha
config.apply_idle_alpha_sleeve_preset()
if args.dividend_sleeve:
config.dividend_capture_sleeve_preset = args.dividend_sleeve
config.apply_dividend_capture_sleeve_preset()
if args.form4_sleeve:
config.form4_capture_sleeve_preset = args.form4_sleeve
config.apply_form4_capture_sleeve_preset()
if args.ownership_sleeve:
config.ownership_capture_sleeve_preset = args.ownership_sleeve
config.apply_ownership_capture_sleeve_preset()
if args.risk_off_sleeve:
config.risk_off_alpha_sleeve_preset = args.risk_off_sleeve
config.apply_risk_off_alpha_sleeve_preset()
if args.non_core_allocator_v2 or args.non_core_allocator_v2_mode:
config.non_core_allocator_v2.enabled = True
if args.non_core_allocator_v2_mode:
config.non_core_allocator_v2.mode = args.non_core_allocator_v2_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,
start_date=dt.date.fromisoformat(args.start) if args.start else None,
end_date=dt.date.fromisoformat(args.end) if args.end else None,
)
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,
start_date=dt.date.fromisoformat(args.start) if args.start else None,
end_date=dt.date.fromisoformat(args.end) if args.end else None,
)
else:
if args.split == "all":
store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override=args.snapshot_dir)
else:
store = _build_store(manifest, config, args.split, snapshot_dir_override=args.snapshot_dir)
if args.start or args.end:
all_store_dates = store.all_trading_days(include_reaction_dates=True)
if not all_store_dates:
raise RuntimeError("No trading days found in snapshot store.")
start_d = dt.date.fromisoformat(args.start) if args.start else all_store_dates[0]
end_d = dt.date.fromisoformat(args.end) if args.end else all_store_dates[-1]
if config.execution.lookback_entry_enabled and args.start:
# Extend slice start backward so pre-start events remain in the store
# for _collect_lookback_candidates; simulation dates are still gated by
# _requested_start_date set below, so those rows stay dormant otherwise.
max_mhd = _compute_max_effective_mhd(config)
lookback_start = start_d - dt.timedelta(days=max_mhd * 2)
store = store.slice_by_date_range(lookback_start, end_d)
else:
store = store.slice_by_date_range(start_d, end_d)
store = _extend_store_to_requested_window(
store=store,
config=config,
start_date=start_d,
end_date=end_d,
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())})")
# Print sleeve decomposition if available
sleeve_path_str = result.artifact_paths.get("sleeve_decomposition")
sleeve_path = Path(sleeve_path_str) if sleeve_path_str else None
if sleeve_path and sleeve_path.exists():
import json as _json
sd = _json.loads(sleeve_path.read_text())
core_pct = sd.get("core", {}).get("contribution_pct", 0)
ia_pct = sd.get("idle_alpha", {}).get("contribution_pct", 0)
park_pct = sd.get("parking", {}).get("contribution_pct", 0)
amp = sd.get("composite_amplification")
idle = sd.get("avg_idle_fraction_pct")
amp_str = f" amp: {amp:.2f}x" if amp is not None else ""
idle_str = f" idle: {idle:.1f}%" if idle is not None else ""
print(
f"Sleeve: Core {core_pct:.1f}% | IA {ia_pct:.1f}% | Parking {park_pct:.1f}%"
f"{amp_str}{idle_str}"
)
if __name__ == "__main__":
main()