|
|
"""BacktestRunner: main simulation class and CLI entry point."""
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import argparse
|
|
|
import datetime as dt
|
|
|
import json
|
|
|
import math
|
|
|
import statistics
|
|
|
import subprocess
|
|
|
import sys
|
|
|
from 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.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 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
|
|
|
|
|
|
# Update trailing stop if configured
|
|
|
if self.config.execution.trailing_model:
|
|
|
update_trailing_stop(
|
|
|
pos, bar,
|
|
|
self.config.execution.trailing_model,
|
|
|
warmup_days=self.config.execution.trailing_warmup_days,
|
|
|
)
|
|
|
|
|
|
effective_exec = self._build_effective_execution_config(pos.plan.candidate)
|
|
|
|
|
|
prev_status = pos.status
|
|
|
trade = simulate_exit(pos, bar, effective_exec, date)
|
|
|
if trade is not None:
|
|
|
newly_closed.append(trade)
|
|
|
# Partial exit: status just changed from ENTERED to PARTIALLY_EXITED
|
|
|
# Keep position open for remaining shares
|
|
|
if prev_status == PositionStatus.ENTERED and pos.status == PositionStatus.PARTIALLY_EXITED:
|
|
|
still_open.append(pos)
|
|
|
else:
|
|
|
pending_exit = self._evaluate_pending_open_exit(pos, bar, effective_exec, date)
|
|
|
if pending_exit is not None:
|
|
|
self._queue_pending_open_exit(date, pending_exit)
|
|
|
still_open.append(pos)
|
|
|
|
|
|
# Process closed trades
|
|
|
for trade in newly_closed:
|
|
|
self._closed_trades.append(trade)
|
|
|
# Map trade to candidate for attribution
|
|
|
cand = pos_to_candidate.get(trade.position_id)
|
|
|
if cand:
|
|
|
self._candidate_map[trade.trade_id] = cand
|
|
|
self._realized_pnl += trade.net_pnl
|
|
|
self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
|
|
|
# Track consecutive losses for cooldown
|
|
|
if trade.net_pnl < 0:
|
|
|
self._consecutive_losses += 1
|
|
|
else:
|
|
|
self._consecutive_losses = 0
|
|
|
if (
|
|
|
self.config.risk.cooldown_after_loss_streak > 0
|
|
|
and self._consecutive_losses >= self.config.risk.cooldown_after_loss_streak
|
|
|
):
|
|
|
self._cooldown_remaining = self.config.risk.cooldown_days
|
|
|
self._consecutive_losses = 0
|
|
|
|
|
|
self._open_positions = still_open
|
|
|
|
|
|
# --- Compute current equity for kill-switch check ---
|
|
|
market_value = self._compute_positions_market_value(date)
|
|
|
unrealized = market_value - sum(
|
|
|
p.entry_price * p.shares_open for p in self._open_positions
|
|
|
)
|
|
|
self._equity = self._cash + market_value + self._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)
|
|
|
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_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 17–April 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 _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_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()
|