From 72681e69e5bab63ca0fde1d1777ec2483da716a6 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Mon, 6 Apr 2026 01:45:02 -0700 Subject: [PATCH] Add Form4 residual-cash sleeve and UI support --- apps/backtester/run.py | 3013 ++++++++++++++--- apps/paper_trader/backtest_sim.py | 226 +- apps/paper_trader/engine.py | 476 ++- apps/paper_trader/models.py | 25 +- apps/paper_trader/state.py | 35 +- apps/tools/build_form4_pit_cache.py | 299 ++ apps/web/direct_runner.py | 95 + apps/web/routers/backtest.py | 139 +- apps/web/routers/paper_trading.py | 59 +- .../{index-DS-hUHgy.js => index-BxBohZ7I.js} | 78 +- apps/web/static/index.html | 2 +- apps/web_frontend/src/api/client.ts | 48 +- apps/web_frontend/src/pages/Backtest.tsx | 181 +- apps/web_frontend/src/pages/PaperTrading.tsx | 208 +- libs/backtest/domain.py | 629 +++- libs/backtest/form4_calendar.py | 179 + libs/oracle_client/dividends.py | 94 + libs/oracle_client/models.py | 41 + tests/unit/test_oracle_client.py | 80 + 19 files changed, 5019 insertions(+), 888 deletions(-) create mode 100644 apps/tools/build_form4_pit_cache.py create mode 100644 apps/web/direct_runner.py rename apps/web/static/assets/{index-DS-hUHgy.js => index-BxBohZ7I.js} (55%) create mode 100644 libs/backtest/form4_calendar.py create mode 100644 libs/oracle_client/dividends.py diff --git a/apps/backtester/run.py b/apps/backtester/run.py index 2698963..3e9fd97 100644 --- a/apps/backtester/run.py +++ b/apps/backtester/run.py @@ -8,6 +8,7 @@ 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 @@ -47,6 +48,12 @@ from libs.backtest.domain import ( 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.execution import ( simulate_scheduled_open_exit, simulate_entry, @@ -57,8 +64,10 @@ from libs.backtest.execution import ( 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.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 @@ -68,6 +77,10 @@ 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" def _get_git_commit_hash() -> str: @@ -101,16 +114,53 @@ class BacktestRunner: 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 + ) + 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 @@ -125,6 +175,7 @@ class BacktestRunner: 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 @@ -138,6 +189,8 @@ class BacktestRunner: 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 @@ -166,6 +219,50 @@ class BacktestRunner: self._simulation_dates: list[dt.date] = [] 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 + + 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: @@ -195,6 +292,191 @@ class BacktestRunner: 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() @@ -241,13 +523,9 @@ class BacktestRunner: self._simulate_day(date) # Force-close any remaining open positions at end of backtest. - # Use the last execution date (not macro-extended date) so we have - # actual bar data for individual stocks. - exec_dates = self.store.all_execution_dates() - last_exec_date = exec_dates[-1] if exec_dates else (all_dates[-1] if all_dates else dt.date.today()) last_date = all_dates[-1] if all_dates else dt.date.today() if self._open_positions: - self._force_close_all(last_exec_date, reason="end_of_backtest") + 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) @@ -327,16 +605,6 @@ class BacktestRunner: self._kill_switch_cooldown_remaining -= 1 # --- CASH PARKING: check gate, sell if signal changed, hold if same --- - if self._parking_shares > 0 and self._parking_current_symbol == "sgov": - daily_rate = self.config.risk.cash_parking_sgov_annual_rate / 252 - interest = self._parking_avg_price * daily_rate - self._parking_avg_price += interest - self._realized_pnl += interest - if self._parking_sgov_value > 0: - daily_rate = self.config.risk.cash_parking_sgov_annual_rate / 252 - interest = self._parking_sgov_value * daily_rate - self._parking_sgov_value += interest - self._realized_pnl += interest # Check gate + trailing stop: sell parking if signal changed or stop hit if self._parking_shares > 0 and self.config.risk.cash_parking_enabled: sold = False @@ -385,6 +653,9 @@ class BacktestRunner: 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) @@ -543,146 +814,83 @@ class BacktestRunner: # Inject delayed entry candidates delayed = self._scheduled_delayed_entries.pop(date, []) + idle_delayed_candidates: list[Candidate] = [] if delayed: - candidates = list(candidates) + 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: - 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) - portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized) - - for candidate in candidates: - plan = build_planned_order( - candidate=candidate, - portfolio_state=portfolio_state, - open_positions=self._open_positions, - config=self.config, - execution_config=self._build_effective_execution_config(candidate), - cooldown_remaining=self._cooldown_remaining, - macro_data=macro_data, - engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id], - ) - - if plan.skip_reason is not None: - # Try same-day cash recycle first - if plan.skip_reason == "insufficient_cash" and self._attempt_same_day_cash_recycle( - date=date, - candidate=candidate, - portfolio_state=portfolio_state, - ): - mv = self._compute_positions_market_value(date) - self._equity = self._cash + mv + self._get_parking_value(date) - ur = mv - sum( - p.entry_price * p.shares_open - for p in self._open_positions - ) - portfolio_state = self._build_portfolio_state(date, drawdown_pct, ur) - plan = build_planned_order( - candidate=candidate, - portfolio_state=portfolio_state, - open_positions=self._open_positions, - config=self.config, - execution_config=self._build_effective_execution_config(candidate), - cooldown_remaining=self._cooldown_remaining, - macro_data=macro_data, - engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id], - ) - # Free only the cash shortfall from parking to avoid full round-trip churn. - shortfall = self._estimate_cash_shortfall(candidate, portfolio_state) - if plan.skip_reason == "insufficient_cash" and self._liquidate_parking_for_cash(date, shortfall): - mv = self._compute_positions_market_value(date) - self._equity = self._cash + mv + self._get_parking_value(date) - ur = mv - sum( - p.entry_price * p.shares_open - for p in self._open_positions - ) - portfolio_state = self._build_portfolio_state(date, drawdown_pct, ur) - plan = build_planned_order( - candidate=candidate, - portfolio_state=portfolio_state, - open_positions=self._open_positions, - config=self.config, - execution_config=self._build_effective_execution_config(candidate), - cooldown_remaining=self._cooldown_remaining, - macro_data=macro_data, - engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id], - ) - - if plan.skip_reason is not None: - self._total_orders_rejected += 1 - self._release_add_on_reservation(candidate) - logger.debug( - "order_rejected", - engine_id=candidate.engine_id, - symbol=candidate.symbol, - reason=plan.skip_reason, - date=str(date), - ) - continue + portfolio_state = self._refresh_portfolio_state(date, drawdown_pct) - bar = self.store.get_bar(candidate.symbol, candidate.execution_date) - gap_skip_reason = self._check_next_open_gap_cap(candidate, bar) - if gap_skip_reason is not None: - self._total_orders_rejected += 1 - self._release_add_on_reservation(candidate) - logger.debug( - "order_rejected", - engine_id=candidate.engine_id, - symbol=candidate.symbol, - reason=gap_skip_reason, - date=str(date), - ) - continue - pos = simulate_entry( - plan, - bar, - self._build_effective_execution_config(candidate), - ) - if pos is not None: - pos.parent_position_id = candidate.parent_position_id - pos.is_add_on = candidate.is_add_on - self._open_positions.append(pos) - _trade_cost = pos.entry_price * pos.shares_total - # Proactively free parking cash using the actual post-slippage cost. - # (cash_available includes parking value, so plan is approved even - # when self._cash ≈ 0 and all money is in SGOV/QQQ.) - if self._cash < _trade_cost and self._get_parking_value(date) > 0: - shortfall = _trade_cost - self._cash - if self._liquidate_parking_for_cash(date, shortfall): - mv = self._compute_positions_market_value(date) - self._equity = self._cash + mv + self._get_parking_value(date) - ur = mv - sum( - p.entry_price * p.shares_open - for p in self._open_positions - ) - portfolio_state = self._build_portfolio_state(date, drawdown_pct, ur) - self._cash -= _trade_cost - self._daily_new_risk_used += plan.risk_dollars - self._engine_daily_new_risk_used[candidate.engine_id] += plan.risk_dollars - # Update equity and portfolio state for next candidate - mv = self._compute_positions_market_value(date) - self._equity = self._cash + mv + self._get_parking_value(date) - ur = mv - sum( - p.entry_price * p.shares_open - for p in self._open_positions - ) - portfolio_state = self._build_portfolio_state( - date, drawdown_pct, ur - ) - else: - self._release_add_on_reservation(candidate) + 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-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, + ) + 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, + ) + + # --- DIVIDEND CAPTURE RESERVE SLEEVE --- + if self._dividend_capture_enabled(): + self._enter_dividend_capture_positions(date) + + # --- FORM 4 RESIDUAL-CASH SLEEVE --- + if self._form4_capture_enabled(): + self._enter_form4_capture_positions(date) # --- CASH PARKING: invest idle cash --- if self.config.risk.cash_parking_enabled: @@ -881,12 +1089,10 @@ class BacktestRunner: # Calculate investable from idle cash existing_parking_value = 0.0 - if self._parking_shares > 0 and self._parking_current_symbol == "sgov": - existing_parking_value = self._parking_avg_price - elif self._parking_shares > 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._parking_sgov_value + existing_parking_value += self._mark_parallel_sgov_to_market(date, macro_data_eod) mv_for_parking = self._compute_positions_market_value(date) equity_est = self._cash + mv_for_parking + existing_parking_value @@ -924,7 +1130,7 @@ class BacktestRunner: else: sgov_amount += qqq_amount if sgov_amount > 0: - self._parking_sgov_value = sgov_amount + 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. @@ -969,7 +1175,7 @@ class BacktestRunner: else: sgov_amount = investable if sgov_amount > 0: - self._parking_sgov_value = sgov_amount + 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 @@ -989,13 +1195,20 @@ class BacktestRunner: reserve = equity_est * self.config.risk.cash_parking_reserve_pct investable = max(0.0, self._cash - reserve) if target_sym == "sgov": - if investable > 0: - self._parking_shares = 1 - self._parking_avg_price += investable # add to existing - self._parking_current_symbol = "sgov" - self._commit_parking_target("sgov") - self._parking_sgov_entry_value += investable - self._cash -= investable + park_close = macro_data_eod.get("sgov_close") + if park_close and park_close > 0 and investable >= park_close: + new_shares = int(investable / 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: park_close = macro_data_eod.get(f"{target_sym}_close") if park_close and park_close > 0 and investable >= park_close: @@ -1059,22 +1272,28 @@ class BacktestRunner: and self._parking_current_symbol == target_sym ): defensive_amount = new_shares * park_close - self._parking_sgov_value += defensive_amount + self._allocate_parallel_sgov( + date=date, + amount=defensive_amount, + macro=macro_data_eod, + ) self._cash -= defensive_amount elif target_sym == "sgov": - if investable > 0: - if self._parking_shares > 0 and self._parking_current_symbol == "sgov": - # Add to existing SGOV - self._parking_avg_price += investable - self._parking_sgov_entry_value += investable - self._commit_parking_target("sgov") - else: - self._parking_shares = 1 - self._parking_avg_price = investable - self._parking_current_symbol = "sgov" - self._parking_sgov_entry_value = investable + park_close = macro_data_eod.get("sgov_close") + if park_close and park_close > 0 and investable >= park_close: + new_shares = int(investable / 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 -= investable + self._cash -= new_shares * park_close elif target_sym and investable > 0: park_close = macro_data_eod.get(f"{target_sym}_close") if park_close and park_close > 0 and investable >= park_close: @@ -1137,7 +1356,11 @@ class BacktestRunner: and self._parking_current_symbol == target_sym ): defensive_amount = new_shares * park_close - self._parking_sgov_value += defensive_amount + self._allocate_parallel_sgov( + date=date, + amount=defensive_amount, + macro=macro_data_eod, + ) self._cash -= defensive_amount # Track parking entry date and peak price @@ -1148,9 +1371,7 @@ class BacktestRunner: # --- Record daily equity curve snapshot --- market_value_final = self._compute_positions_market_value(date) macro_for_eq = self.store.get_macro_for_date(date) or {} - if self._parking_shares > 0 and self._parking_current_symbol == "sgov": - parking_value = self._parking_avg_price # SGOV: face value - elif self._parking_shares > 0 and self._parking_current_symbol: + if 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: @@ -1158,7 +1379,7 @@ class BacktestRunner: unrealized_final = market_value_final - sum( p.entry_price * p.shares_open for p in self._open_positions ) - parking_value += self._parking_sgov_value # add parallel SGOV (proportional mode) + 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 = ( @@ -1187,6 +1408,13 @@ class BacktestRunner: 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) if not self.config.get_strategy_engines(): return self.store.all_trading_days() include_reaction_dates = any( @@ -1195,9 +1423,15 @@ class BacktestRunner: ) return self.store.all_trading_days(include_reaction_dates=include_reaction_dates) - def _select_candidates_for_date(self, date: dt.date) -> list[Candidate]: + 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 not self.config.get_strategy_engines(): + 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, @@ -1207,23 +1441,25 @@ class BacktestRunner: ) self._annotate_candidate_slate_features(selected) return selected - if not self._active_strategy_engines: + 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 self._active_strategy_engines: + 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(engine): + if self._engine_requires_attention(effective_engine): prelimit = max(prelimit * 5, prelimit) raw_rows = ( self.store.get_candidates_for_reaction_date(date) - if engine.entry_timing_policy == "reaction_close" + if effective_engine.entry_timing_policy == "reaction_close" else self.store.get_candidates_for_date(date) ) selected = select_candidates( @@ -1231,20 +1467,20 @@ class BacktestRunner: self.config.universe, self.config.signal, event_type_profiles=self.config.event_type_profiles or None, - strategy_engine=engine, + 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, engine) + selected = self._apply_attention_filters(selected, effective_engine) if selected: - engine_queues[engine.engine_id] = selected - if engine.residual_reserve_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, []) + 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: @@ -1271,23 +1507,28 @@ class BacktestRunner: def _select_shadow_candidates_for_date(self, date: dt.date) -> list[Candidate]: """Select shadow candidates used only for synthetic lookback logic.""" - if not self._shadow_strategy_engines: + 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 self._shadow_strategy_engines: + 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(engine): + if self._engine_requires_attention(effective_engine): prelimit = max(prelimit * 5, prelimit) raw_rows = ( self.store.get_candidates_for_reaction_date(date) - if engine.entry_timing_policy == "reaction_close" + if effective_engine.entry_timing_policy == "reaction_close" else self.store.get_candidates_for_date(date) ) selected = select_candidates( @@ -1295,21 +1536,317 @@ class BacktestRunner: self.config.universe, self.config.signal, event_type_profiles=self.config.event_type_profiles or None, - strategy_engine=engine, + 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, engine) + selected = self._apply_attention_filters(selected, effective_engine) if selected: selected_shadow.extend(selected) - if engine.residual_reserve_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(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 _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 + + bar = self.store.get_bar(candidate.symbol, candidate.execution_date) + gap_skip_reason = self._check_next_open_gap_cap(candidate, bar) + if gap_skip_reason is not None: + self._total_orders_rejected += 1 + self._release_add_on_reservation(candidate) + logger.debug( + "order_rejected", + engine_id=candidate.engine_id, + symbol=candidate.symbol, + reason=gap_skip_reason, + date=str(date), + ) + continue + + pos = simulate_entry( + plan, + bar, + self._build_effective_execution_config(candidate), + ) + if pos is None: + self._release_add_on_reservation(candidate) + continue + + pos.parent_position_id = candidate.parent_position_id + pos.is_add_on = candidate.is_add_on + 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: @@ -1336,6 +1873,8 @@ class BacktestRunner: 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 @@ -1343,8 +1882,103 @@ class BacktestRunner: return True return self._macro_regime_state_for_date(date) in set(allowed_regimes) - def _engine_requires_attention(self, engine: Any) -> bool: - return self._attention_service.engine_requires_attention(engine) + def _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) @@ -1361,7 +1995,8 @@ class BacktestRunner: def _get_event_attention(self, candidate: Candidate) -> EventAttentionResponse | None: event_date = candidate.event_date or candidate.reaction_date - cache_key = (candidate.symbol, event_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] @@ -1371,14 +2006,14 @@ class BacktestRunner: try: response = self._attention_session.get( - f"{self._attention_base_url}/api/v1/attention/event/{candidate.symbol}", + 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=candidate.symbol, + symbol=attention_symbol, event_date=event_date.isoformat(), status_code=response.status_code, ) @@ -1388,7 +2023,7 @@ class BacktestRunner: except Exception as exc: logger.warning( "attention_fetch_error", - symbol=candidate.symbol, + symbol=attention_symbol, event_date=event_date.isoformat(), error=str(exc), ) @@ -1639,12 +2274,19 @@ class BacktestRunner: }: 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): - plan = build_planned_order( + 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), @@ -1912,6 +2554,448 @@ class BacktestRunner: 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 _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._cash + market_value + self._get_parking_value(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()) + + 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 event.total_value < float(cfg.min_total_value): + continue + if event.weighted_purchase_pct < float(cfg.min_purchase_pct): + 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 + symbol = str(event.symbol).upper() + if symbol in open_symbols: + 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, + "has_officer_or_director": bool(event.has_officer_or_director), + "avg_dollar_volume": avg_dollar_volume, + } + ) + + rows.sort( + key=lambda item: ( + int(item["owner_count"]), + int(item["event_day_count"]), + float(item["weighted_purchase_pct"]), + float(item["total_value"]), + ), + reverse=True, + ) + 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, + 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(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_max_holding_days=int(self.config.form4_capture.hold_days), + features={ + "trade_sleeve": "idle_alpha", + "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_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._cash + market_value + self._get_parking_value(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"], + 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 _schedule_add_on_candidates(self, date: dt.date) -> None: next_date = self._next_trading_day.get(date) if next_date is None: @@ -2111,8 +3195,23 @@ class BacktestRunner: "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, @@ -2125,7 +3224,397 @@ class BacktestRunner: }, } ) - self._scheduled_delayed_entries[next_date].append(candidate) + 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. @@ -2220,6 +3709,307 @@ class BacktestRunner: ) 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 @@ -2319,12 +4109,15 @@ class BacktestRunner: 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"): + if symbol in ("spy", "spym", "qual"): return symbol return "spy" def _get_parking_defensive_prefix(self) -> str: - return self._get_parking_signal_prefix(self._get_parking_defensive_symbol()) + 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" @@ -2491,11 +4284,11 @@ class BacktestRunner: 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 = macro.get("VIXCLS") - vix_blocked = vix is None or vix >= vix_max + 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 @@ -3029,6 +4822,37 @@ class BacktestRunner: 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 @@ -3387,7 +5211,7 @@ class BacktestRunner: # 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") and self.config.risk.cash_parking_trend_gate: + 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}") @@ -3521,13 +5345,10 @@ class BacktestRunner: """Current market value of all parked positions.""" val = 0.0 if self._parking_shares > 0: - if self._parking_current_symbol == "sgov": - val += self._parking_avg_price - else: - 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._parking_sgov_value + 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( @@ -3557,24 +5378,19 @@ class BacktestRunner: """Sell parking position to free cash for event entries. Returns True if cash was freed.""" freed = False macro = self.store.get_macro_for_date(date) or {} + self._mark_parallel_sgov_to_market(date, macro) if self._parking_shares > 0: entry_price_for_record = self._parking_avg_price - exit_price = self._parking_avg_price - if self._parking_current_symbol == "sgov": - self._cash += self._parking_avg_price - exit_price = self._parking_avg_price # current value (with interest) - entry_price_for_record = self._parking_sgov_entry_value or self._parking_avg_price + park_close = 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: - park_close = 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 + 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, @@ -3584,16 +5400,18 @@ class BacktestRunner: self._parking_shares = 0 self._parking_avg_price = 0.0 self._parking_current_symbol = "" - self._parking_entry_date = None - self._parking_sgov_entry_value = 0.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._parking_sgov_value = 0.0 + self._reset_parallel_sgov_state() freed = True + if self._parking_shares <= 0 and self._parking_sgov_value <= 0: + 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: @@ -3605,55 +5423,36 @@ class BacktestRunner: freed = False if self._parking_sgov_value > 0 and remaining_needed > 0: - released = min(self._parking_sgov_value, remaining_needed) + 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._parking_sgov_value = 0.0 + self._reset_parallel_sgov_state() 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 - if symbol == "sgov": - total_value = self._parking_avg_price - if total_value <= 0: - return freed - released = min(total_value, remaining_needed) - if released <= 0: - return freed - original_entry_value = self._parking_sgov_entry_value or total_value - entry_value_sold = original_entry_value * (released / total_value) - self._cash += released - self._record_parking_trade( - date, - symbol, - 1, - entry_value_sold, - released, - ) - remaining_value = max(0.0, total_value - released) - remaining_entry_value = max(0.0, original_entry_value - entry_value_sold) - if remaining_value <= 1e-9: - self._parking_shares = 0 - self._parking_avg_price = 0.0 - self._parking_current_symbol = "" - self._parking_entry_date = None - self._parking_sgov_entry_value = 0.0 - self._parking_peak_price = 0.0 - else: - self._parking_shares = 1 - self._parking_avg_price = remaining_value - self._parking_sgov_entry_value = remaining_entry_value - self._parking_sold_today = True - self._parking_freed_for_cash_today = True - return True - park_close = macro.get(f"{symbol}_close") if not park_close or park_close <= 0: park_close = self._parking_avg_price @@ -3680,9 +5479,9 @@ class BacktestRunner: self._parking_shares = 0 self._parking_avg_price = 0.0 self._parking_current_symbol = "" - self._parking_entry_date = None - self._parking_sgov_entry_value = 0.0 - self._parking_peak_price = 0.0 + 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 @@ -3695,30 +5494,23 @@ class BacktestRunner: if shares <= 0 or not symbol: return entry_date = self._parking_entry_date or exit_date - if symbol == "sgov": - # SGOV: entry_price is total value, normalize per-share - gross_pnl = exit_price - entry_price # interest earned - net_pnl = gross_pnl - pnl_pct = gross_pnl / entry_price if entry_price > 0 else 0 - entry_px = entry_price - exit_px = exit_price - shares_count = 1 - else: - 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 + 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), @@ -4020,9 +5812,28 @@ class BacktestRunner: 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): - bar = self.store.get_bar(pos.plan.candidate.symbol, date) - trade = simulate_kill_switch_exit(pos, bar, date, self.config.execution) + 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 @@ -4044,251 +5855,75 @@ def _build_store( from libs.common.config import get_settings s = get_settings() - snapshot_dir = Path(snapshot_dir_override or s.parquet_dir) / config.dataset_snapshot_id + 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}')" + ) - # Resolve scoring function from config - scoring_fn = None - if config.signal.scoring_model == "pead": - from libs.backtest.scoring import compute_pead_score + 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 - scoring_fn = partial( + return partial( compute_pead_score, reaction_threshold=config.signal.pead_reaction_threshold, volume_threshold=config.signal.pead_volume_threshold, ) - elif config.signal.scoring_model == "return_max_long_v1": - from libs.backtest.scoring import compute_return_max_long_score - - scoring_fn = compute_return_max_long_score - elif config.signal.scoring_model == "return_max_long_v2": - from libs.backtest.scoring import compute_return_max_long_score_v2 - - scoring_fn = compute_return_max_long_score_v2 - elif config.signal.scoring_model == "return_max_long_v3": - from libs.backtest.scoring import compute_return_max_long_score_v3 - - scoring_fn = compute_return_max_long_score_v3 - elif config.signal.scoring_model == "return_max_long_v4": - from libs.backtest.scoring import compute_return_max_long_score_v4 - scoring_fn = compute_return_max_long_score_v4 - elif config.signal.scoring_model == "return_max_long_v5": - from libs.backtest.scoring import compute_return_max_long_score_v5 + if scoring_model.startswith("return_max_long_"): + from libs.backtest import scoring as scoring_mod - scoring_fn = compute_return_max_long_score_v5 - elif config.signal.scoring_model == "return_max_long_v6": - from libs.backtest.scoring import compute_return_max_long_score_v6 + 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) - scoring_fn = compute_return_max_long_score_v6 - elif config.signal.scoring_model == "return_max_long_v7": - from libs.backtest.scoring import compute_return_max_long_score_v7 + if scoring_model.startswith("return_max_short_"): + from libs.backtest import scoring as scoring_mod - scoring_fn = compute_return_max_long_score_v7 - elif config.signal.scoring_model == "return_max_long_v8": - from libs.backtest.scoring import compute_return_max_long_score_v8 + suffix = scoring_model.removeprefix("return_max_short_") + return getattr(scoring_mod, f"compute_return_max_short_score_{suffix}") - scoring_fn = compute_return_max_long_score_v8 - elif config.signal.scoring_model == "return_max_long_v9": - from libs.backtest.scoring import compute_return_max_long_score_v9 + if scoring_model.startswith("return_max_longshort_"): + from libs.backtest import scoring as scoring_mod - scoring_fn = compute_return_max_long_score_v9 - elif config.signal.scoring_model == "return_max_long_v9g": - from libs.backtest.scoring import compute_return_max_long_score_v9g + suffix = scoring_model.removeprefix("return_max_longshort_") + return getattr(scoring_mod, f"compute_return_max_longshort_{suffix}") - scoring_fn = compute_return_max_long_score_v9g - elif config.signal.scoring_model == "return_max_long_v10": - from libs.backtest.scoring import compute_return_max_long_score_v10 + if scoring_model == "oversold_bounce": + from libs.backtest.scoring import compute_oversold_bounce_score - scoring_fn = compute_return_max_long_score_v10 - elif config.signal.scoring_model == "return_max_long_v11": - from libs.backtest.scoring import compute_return_max_long_score_v11 + return compute_oversold_bounce_score - scoring_fn = compute_return_max_long_score_v11 - elif config.signal.scoring_model == "return_max_long_v11g": - from libs.backtest.scoring import compute_return_max_long_score_v11g - - scoring_fn = compute_return_max_long_score_v11g - elif config.signal.scoring_model == "return_max_long_v11_surprise": - from libs.backtest.scoring import compute_return_max_long_score_v11_surprise - - scoring_fn = compute_return_max_long_score_v11_surprise - elif config.signal.scoring_model == "return_max_long_v12": - from libs.backtest.scoring import compute_return_max_long_score_v12 - - scoring_fn = compute_return_max_long_score_v12 - elif config.signal.scoring_model == "return_max_long_v12r": - from libs.backtest.scoring import compute_return_max_long_score_v12r - - scoring_fn = compute_return_max_long_score_v12r - elif config.signal.scoring_model == "return_max_long_v12b": - from libs.backtest.scoring import compute_return_max_long_score_v12b - - scoring_fn = compute_return_max_long_score_v12b - elif config.signal.scoring_model == "return_max_long_v12o": - from libs.backtest.scoring import compute_return_max_long_score_v12o - - scoring_fn = compute_return_max_long_score_v12o - elif config.signal.scoring_model == "return_max_long_v13": - from libs.backtest.scoring import compute_return_max_long_score_v13 - - scoring_fn = compute_return_max_long_score_v13 - elif config.signal.scoring_model == "return_max_long_v13h": - from libs.backtest.scoring import compute_return_max_long_score_v13h - - scoring_fn = compute_return_max_long_score_v13h - elif config.signal.scoring_model == "return_max_long_v13e": - from libs.backtest.scoring import compute_return_max_long_score_v13e - - scoring_fn = compute_return_max_long_score_v13e - elif config.signal.scoring_model == "return_max_long_v13e_surp": - from libs.backtest.scoring import compute_return_max_long_score_v13e_surp - - scoring_fn = compute_return_max_long_score_v13e_surp - elif config.signal.scoring_model == "return_max_long_v13e_pd": - from libs.backtest.scoring import compute_return_max_long_score_v13e_pd - - scoring_fn = compute_return_max_long_score_v13e_pd - elif config.signal.scoring_model == "return_max_long_v13e_mr": - from libs.backtest.scoring import compute_return_max_long_score_v13e_mr - - scoring_fn = compute_return_max_long_score_v13e_mr - elif config.signal.scoring_model == "return_max_long_v13e_pdmr": - from libs.backtest.scoring import compute_return_max_long_score_v13e_pdmr - - scoring_fn = compute_return_max_long_score_v13e_pdmr - elif config.signal.scoring_model == "return_max_long_v13e_pd2": - from libs.backtest.scoring import compute_return_max_long_score_v13e_pd2 - - scoring_fn = compute_return_max_long_score_v13e_pd2 - elif config.signal.scoring_model == "return_max_long_v13e_h": - from libs.backtest.scoring import compute_return_max_long_score_v13e_h - - scoring_fn = compute_return_max_long_score_v13e_h - elif config.signal.scoring_model == "return_max_long_v13e_ou": - from libs.backtest.scoring import compute_return_max_long_score_v13e_ou - - scoring_fn = compute_return_max_long_score_v13e_ou - elif config.signal.scoring_model == "return_max_long_v13e_t3": - from libs.backtest.scoring import compute_return_max_long_score_v13e_t3 - - scoring_fn = compute_return_max_long_score_v13e_t3 - elif config.signal.scoring_model == "return_max_long_v13e_t23": - from libs.backtest.scoring import compute_return_max_long_score_v13e_t23 - - scoring_fn = compute_return_max_long_score_v13e_t23 - elif config.signal.scoring_model == "return_max_long_v13e_qsc": - from libs.backtest.scoring import compute_return_max_long_score_v13e_qsc - - scoring_fn = compute_return_max_long_score_v13e_qsc - elif config.signal.scoring_model == "return_max_long_v13e_btg": - from libs.backtest.scoring import compute_return_max_long_score_v13e_btg - - scoring_fn = compute_return_max_long_score_v13e_btg - elif config.signal.scoring_model == "return_max_long_v13e_hug": - from libs.backtest.scoring import compute_return_max_long_score_v13e_hug - - scoring_fn = compute_return_max_long_score_v13e_hug - elif config.signal.scoring_model == "return_max_long_v13s": - from libs.backtest.scoring import compute_return_max_long_score_v13s - - scoring_fn = compute_return_max_long_score_v13s - elif config.signal.scoring_model == "return_max_long_v16": - from libs.backtest.scoring import compute_return_max_long_score_v16 - - scoring_fn = compute_return_max_long_score_v16 - elif config.signal.scoring_model == "return_max_long_v16e": - from libs.backtest.scoring import compute_return_max_long_score_v16e - - scoring_fn = compute_return_max_long_score_v16e - elif config.signal.scoring_model == "return_max_long_v15": - from libs.backtest.scoring import compute_return_max_long_score_v15 - scoring_fn = compute_return_max_long_score_v15 - elif config.signal.scoring_model == "return_max_long_v15b": - from libs.backtest.scoring import compute_return_max_long_score_v15b - scoring_fn = compute_return_max_long_score_v15b - elif config.signal.scoring_model == "return_max_long_v15c": - from libs.backtest.scoring import compute_return_max_long_score_v15c - scoring_fn = compute_return_max_long_score_v15c - elif config.signal.scoring_model == "return_max_long_v15d": - from libs.backtest.scoring import compute_return_max_long_score_v15d - scoring_fn = compute_return_max_long_score_v15d - elif config.signal.scoring_model == "return_max_long_v15e": - from libs.backtest.scoring import compute_return_max_long_score_v15e - scoring_fn = compute_return_max_long_score_v15e - elif config.signal.scoring_model == "return_max_long_v15f": - from libs.backtest.scoring import compute_return_max_long_score_v15f - scoring_fn = compute_return_max_long_score_v15f - elif config.signal.scoring_model == "return_max_long_v14": - from libs.backtest.scoring import compute_return_max_long_score_v14 - scoring_fn = compute_return_max_long_score_v14 - elif config.signal.scoring_model == "return_max_long_v14_ou": - from libs.backtest.scoring import compute_return_max_long_score_v14_ou - scoring_fn = compute_return_max_long_score_v14_ou - elif config.signal.scoring_model == "return_max_long_v14_gp": - from libs.backtest.scoring import compute_return_max_long_score_v14_gp - scoring_fn = compute_return_max_long_score_v14_gp - elif config.signal.scoring_model == "return_max_long_v14_mt": - from libs.backtest.scoring import compute_return_max_long_score_v14_mt - scoring_fn = compute_return_max_long_score_v14_mt - elif config.signal.scoring_model == "return_max_long_v14e": - from libs.backtest.scoring import compute_return_max_long_score_v14e - scoring_fn = compute_return_max_long_score_v14e - elif config.signal.scoring_model == "return_max_long_v17": - from libs.backtest.scoring import compute_return_max_long_score_v17 - scoring_fn = compute_return_max_long_score_v17 - elif config.signal.scoring_model == "return_max_long_v17b": - from libs.backtest.scoring import compute_return_max_long_score_v17b - scoring_fn = compute_return_max_long_score_v17b - elif config.signal.scoring_model == "return_max_long_v17c": - from libs.backtest.scoring import compute_return_max_long_score_v17c - scoring_fn = compute_return_max_long_score_v17c - elif config.signal.scoring_model == "return_max_long_v18": - from libs.backtest.scoring import compute_return_max_long_score_v18 - scoring_fn = compute_return_max_long_score_v18 - elif config.signal.scoring_model == "return_max_long_v18b": - from libs.backtest.scoring import compute_return_max_long_score_v18b - scoring_fn = compute_return_max_long_score_v18b - elif config.signal.scoring_model == "return_max_long_v18c": - from libs.backtest.scoring import compute_return_max_long_score_v18c - scoring_fn = compute_return_max_long_score_v18c - elif config.signal.scoring_model == "return_max_long_v18d": - from libs.backtest.scoring import compute_return_max_long_score_v18d - scoring_fn = compute_return_max_long_score_v18d - elif config.signal.scoring_model == "return_max_short_v1": - from libs.backtest.scoring import compute_return_max_short_score_v1 - scoring_fn = compute_return_max_short_score_v1 - elif config.signal.scoring_model == "return_max_long_ml_v1": - from libs.backtest.scoring import compute_return_max_long_score_ml_v1 - scoring_fn = compute_return_max_long_score_ml_v1 - elif config.signal.scoring_model == "return_max_longshort_v1": - from libs.backtest.scoring import compute_return_max_longshort_v1 - scoring_fn = compute_return_max_longshort_v1 - elif config.signal.scoring_model == "return_max_longshort_ml_v1": - from libs.backtest.scoring import compute_return_max_longshort_ml_v1 - scoring_fn = compute_return_max_longshort_ml_v1 - elif config.signal.scoring_model == "return_max_longshort_v1b": - from libs.backtest.scoring import compute_return_max_longshort_v1b - scoring_fn = compute_return_max_longshort_v1b - elif config.signal.scoring_model == "oversold_bounce": - from libs.backtest.scoring import compute_oversold_bounce_score - scoring_fn = compute_oversold_bounce_score - elif config.signal.scoring_model == "patient_drift": + if scoring_model == "patient_drift": from libs.backtest.scoring import compute_patient_drift_score - scoring_fn = compute_patient_drift_score - elif config.signal.scoring_model == "microstructure": + return compute_patient_drift_score + + if scoring_model == "microstructure": from libs.backtest.scoring import compute_microstructure_score - scoring_fn = compute_microstructure_score + return compute_microstructure_score - return SnapshotStore.load( - snapshot_dir=snapshot_dir, - split_name=split_name, - oracle_url=s.stock_oracle_url, - db_dsn=s.postgres_dsn, - scoring_fn=scoring_fn, - ) + return None def _build_split_result_from_metrics(run_id: str, metrics: MetricsBundle) -> SplitResult: @@ -4366,43 +6001,282 @@ def _build_merged_snapshot_store( config: BacktestConfig, snapshot_dir_override: str | None, ) -> SnapshotStore: - stores: list[SnapshotStore] = [] - for split in ["train", "valid", "test"]: + 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 _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: - stores.append(_build_store(manifest, config, split, snapshot_dir_override=snapshot_dir_override)) - except FileNotFoundError: - continue - - if not stores: - raise FileNotFoundError("No snapshot splits found.") - - merged_candidates: dict[dt.date, dict[tuple[Any, ...], dict[str, Any]]] = defaultdict(dict) - merged_bars: dict[str, dict[dt.date, dict[str, Any]]] = {} - merged_macro: dict[dt.date, dict[str, Any]] = {} - - for store in stores: - for exec_date in store.all_execution_dates(): - for candidate in store.get_candidates_for_date(exec_date): - dedupe_key = ( - candidate.get("event_id"), - candidate.get("symbol"), - candidate.get("execution_date"), - candidate.get("reaction_date"), + 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, ) - merged_candidates[exec_date].setdefault(dedupe_key, candidate) - for symbol, bars in store._bars.items(): - merged_bars.setdefault(symbol, {}).update(bars) - for macro_date, macro_values in store._macro.items(): - merged_macro.setdefault(macro_date, {}).update(macro_values) - - return SnapshotStore( - candidates_by_exec_date={ - date: list(rows.values()) - for date, rows in merged_candidates.items() - }, - bars_by_symbol_date=merged_bars, - macro_by_date=merged_macro, - ) + 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), + ) + + # 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( @@ -4414,10 +6288,19 @@ def run_walk_forward( 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: @@ -4528,9 +6411,18 @@ def run_robustness_matrix( 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.") @@ -4640,6 +6532,12 @@ def main() -> None: 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("--dividend-sleeve", default=None, help="Dividend capture sleeve preset (e.g. reserve_dividend_capture)") + parser.add_argument("--form4-sleeve", default=None, help="Form 4 capture sleeve preset (e.g. reserve_form4_cluster)") args = parser.parse_args() manifest = load_manifest(args.manifest) @@ -4648,6 +6546,19 @@ def main() -> 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: + 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.walk_forward and args.robustness_matrix: raise SystemExit("Use either --walk-forward or --robustness-matrix, not both.") @@ -4661,6 +6572,8 @@ def main() -> None: 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()] @@ -4672,9 +6585,25 @@ def main() -> None: 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: 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] + 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, diff --git a/apps/paper_trader/backtest_sim.py b/apps/paper_trader/backtest_sim.py index cbadc93..0401ff8 100644 --- a/apps/paper_trader/backtest_sim.py +++ b/apps/paper_trader/backtest_sim.py @@ -18,6 +18,10 @@ import tempfile from pathlib import Path from typing import Any +from libs.backtest.snapshots import ( + resolve_snapshot, + resolve_snapshot_path as _resolve_registry_snapshot_path, +) from libs.common.config import get_settings from libs.common.logging import get_logger @@ -31,6 +35,8 @@ def run_backtest_session_sync( start_date: dt.date, end_date: dt.date, parking_preset: str | None = None, + idle_alpha_preset: str | None = None, + form4_sleeve_preset: str | None = None, snapshot_id_override: str | None = None, ) -> dict[str, Any]: """Run a single strategy using BacktestRunner (same as research backtester). @@ -40,156 +46,38 @@ def run_backtest_session_sync( """ from apps.backtester.run import ( BacktestRunner, - _build_store, _build_merged_snapshot_store, + _extend_store_to_requested_window, load_manifest, resolve_config, ) - from libs.backtest.snapshot_store import SnapshotStore manifest = load_manifest(config_path) - config = resolve_config(manifest) - - # Apply snapshot override (e.g. for OOT periods like 2020-2021) - if snapshot_id_override: - config.dataset_snapshot_id = snapshot_id_override + config = resolve_config(manifest, snapshot_id_override=snapshot_id_override) # Apply parking preset override (CLI --parking option) if parking_preset: config.risk.cash_parking_preset = parking_preset config.risk.apply_parking_preset() + if idle_alpha_preset: + config.idle_alpha_sleeve_preset = idle_alpha_preset + config.apply_idle_alpha_sleeve_preset() + if form4_sleeve_preset: + config.form4_capture_sleeve_preset = form4_sleeve_preset + config.apply_form4_capture_sleeve_preset() # Use merged store (train+valid+test) to cover the full date range. - # Try default parquet_dir first, fall back to data/datasets/snapshots. - from libs.common.config import get_settings - settings = get_settings() - snapshot_dir_override = None - default_path = Path(settings.parquet_dir) / config.dataset_snapshot_id - alt_path = Path("data/datasets/snapshots") / config.dataset_snapshot_id - if not default_path.exists() and alt_path.exists(): - snapshot_dir_override = "data/datasets/snapshots" - store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override=snapshot_dir_override) + store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override=None) # Slice to requested date range store = store.slice_by_date_range(start_date, end_date) - - # Extend macro data if user's end_date is beyond the snapshot's event range. - # This allows parking to run on days with no events (e.g. today, 3/30). - # Also needed so event positions can be valued up to end_date. - # Clamp to last market-closed date (don't fetch today if market hasn't closed). - import asyncio as _aio - from libs.common.time_utils import is_trading_day, to_eastern, utc_now - _oracle_url = get_settings().stock_oracle_url - now_et = to_eastern(utc_now()) - _last_closed = now_et.date() if (not is_trading_day(now_et.date()) or now_et.hour >= 16) else now_et.date() - dt.timedelta(days=1) - _extend_end = min(end_date, _last_closed) - if store._macro: - max_macro = max(store._macro.keys()) - if max_macro < _extend_end: - try: - extended = _aio.run(SnapshotStore._fetch_spy_macro( - date_range=(max_macro, _extend_end), - oracle_url=_oracle_url, - )) - added = 0 - for d, vals in extended.items(): - if d > max_macro: - store._macro[d] = vals - added += 1 - if added: - logger.info("backtest_macro_extended", added_days=added, new_end=max(store._macro.keys()).isoformat()) - except Exception as exc: - logger.warning("backtest_macro_extend_failed", error=str(exc)) - elif not store._macro and config.risk.cash_parking_enabled: - try: - store._macro = _aio.run(SnapshotStore._fetch_spy_macro( - date_range=(start_date, _extend_end), - oracle_url=_oracle_url, - )) - except Exception as exc: - logger.warning("backtest_macro_fetch_failed", error=str(exc)) - - # Extend individual stock bars to _extend_end. - # Bars are cached to disk so Oracle API is only called once per date extension. - if store._bars: - import pickle - snapshot_path = _resolve_snapshot_path(config.dataset_snapshot_id) - cache_file = snapshot_path / f"bars_extended_{_extend_end.isoformat()}.pkl" if snapshot_path else None - - # Try loading from cache first - cached = False - if cache_file and cache_file.exists(): - try: - with open(cache_file, "rb") as f: - cached_bars = pickle.load(f) - added = 0 - for sym, date_bars in cached_bars.items(): - for d, bar in date_bars.items(): - existing = store._bars.get(sym, {}) - if d not in existing: - store._bars.setdefault(sym, {})[d] = 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 = [] - for sym, sym_bars in store._bars.items(): - if sym_bars: - 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(d for _, d in symbols_to_extend) - total = len(symbols_to_extend) - logger.info("backtest_bars_extending", symbols=total, - fetch_range=f"{fetch_start}→{_extend_end}") - - # Fetch in batches with progress - batch_size = 50 - all_new_bars: dict[str, dict] = {} - 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), - _oracle_url, - concurrency=8, - )) - for sym, new_bars in result[0].items(): - all_new_bars.setdefault(sym, {}).update(new_bars) - except Exception as exc: - logger.warning("backtest_bars_batch_failed", batch=batch_num, error=str(exc)) - - # Merge into store - added_count = 0 - new_bars_only: dict[str, dict] = {} # for cache - for sym, date_bars in all_new_bars.items(): - existing_max = max(store._bars.get(sym, {}).keys()) if store._bars.get(sym) else None - for d, bar in date_bars.items(): - if existing_max is None or d > existing_max: - store._bars.setdefault(sym, {})[d] = bar - new_bars_only.setdefault(sym, {})[d] = bar - added_count += 1 - if added_count: - logger.info("backtest_bars_extended", symbols=len(symbols_to_extend), added_bars=added_count) - # Save to cache for next run - if cache_file and new_bars_only: - try: - cache_file.parent.mkdir(parents=True, exist_ok=True) - with open(cache_file, "wb") as f: - pickle.dump(new_bars_only, f, 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)) + store = _extend_store_to_requested_window( + store=store, + config=config, + start_date=start_date, + end_date=end_date, + snapshot_dir_override=None, + ) runner = BacktestRunner( manifest=manifest, @@ -267,6 +155,7 @@ def _convert_from_runner( "event_type": str(row.get("event_type", "-")), "score": float(row.get("score", 0.0)), "engine_id": str(row.get("engine_id", "")), + "trade_sleeve": str(row.get("trade_sleeve", "") or ""), } # Skip same-day KILL_SWITCH — backtest period end artifact if trade["entry_date"] == trade["exit_date"] and trade["reason"] == "KILL_SWITCH": @@ -342,6 +231,11 @@ def _snapshot_needs_refresh( Skips refresh if already refreshed today (marker file). """ + resolution = resolve_snapshot(snapshot_id, snapshot_dir=snapshot_dir) + if resolution.refresh_policy == "manual_only": + return False + if not resolution.is_registry_managed and snapshot_dir is None: + return False if _snapshot_has_required_coverage(snapshot_id=snapshot_id, end_date=end_date, snapshot_dir=snapshot_dir): return False # Check if we already attempted refresh today (avoid repeated pipeline runs) @@ -408,31 +302,34 @@ def _resolve_snapshot_path( snapshot_dir: str | None = None, ) -> Path | None: """Resolve the on-disk snapshot directory using the same fallback order as the runner.""" - candidates: list[Path] = [] - if snapshot_dir is not None: - candidates.append(Path(snapshot_dir) / snapshot_id) - else: - settings = get_settings() - candidates.append(Path(settings.parquet_dir) / snapshot_id) - candidates.append(Path("data/datasets/snapshots") / snapshot_id) - - seen: set[Path] = set() - for candidate in candidates: - candidate = candidate.resolve() - if candidate in seen: - continue - seen.add(candidate) - if candidate.exists(): - return candidate - return None + return _resolve_registry_snapshot_path(snapshot_id, snapshot_dir=snapshot_dir) async def _refresh_snapshot( snapshot_id: str, universe_profile: str | None, console=None, + *, + manual: bool = False, ) -> None: """Re-run pipeline steps and re-export the snapshot.""" + resolution = resolve_snapshot(snapshot_id) + if not resolution.is_registry_managed: + raise RuntimeError( + f"Snapshot '{snapshot_id}' is not registry-managed; phase-1 refresh only supports canonical snapshots." + ) + if resolution.refresh_policy == "manual_only" and not manual: + if console: + console.print( + f"\n[bold yellow]Snapshot '{snapshot_id}' resolves to frozen canonical " + f"'{resolution.canonical_snapshot_id}' — skipping auto-refresh.[/]" + ) + return + if console and resolution.requested_snapshot_id != resolution.canonical_snapshot_id: + console.print( + f"\n[bold cyan]Resolved snapshot:[/] {resolution.requested_snapshot_id} " + f"→ {resolution.canonical_snapshot_id}" + ) if console: console.print("\n[bold yellow]Snapshot stale — refreshing pipeline...[/]") @@ -483,26 +380,17 @@ async def _refresh_snapshot( if console: console.print(f" [yellow]Label generator skipped: {exc}[/]") - # Step 2: Re-export snapshot + # Step 2: Rebuild canonical snapshot if console: console.print(" [dim]4/4 Exporting snapshot...[/]") try: - from libs.db.session import get_session - from libs.export.snapshot_export import export_dataset_snapshot - - async with get_session() as session: - await export_dataset_snapshot( - session=session, - snapshot_id=snapshot_id, - split_policy="temporal_70_15_15", - output_dir="data/datasets/snapshots", - feature_versions=["market_v1", "event_v1"], - universe_profile=universe_profile, - ) + from libs.export.canonical_snapshots import build_canonical_snapshot + + await build_canonical_snapshot(snapshot_id, manual=manual) if console: console.print(" [green]Snapshot refreshed.[/]") # Write marker to avoid re-refreshing today - snapshot_path = _resolve_snapshot_path(snapshot_id) + snapshot_path = _resolve_snapshot_path(resolution.canonical_snapshot_id) if snapshot_path: (snapshot_path / ".last_refresh").write_text(dt.date.today().isoformat()) except Exception as exc: @@ -520,6 +408,7 @@ def run_backtest( oracle_url: str, console=None, parking_preset: str | None = None, + idle_alpha_preset: str | None = None, snapshot_id_override: str | None = None, auto_refresh: bool = True, ) -> list[dict[str, Any]]: @@ -552,8 +441,8 @@ def run_backtest( for config_path in configs: from apps.backtester.run import load_manifest, resolve_config manifest = load_manifest(config_path) - config = resolve_config(manifest) - snapshot_id = config.dataset_snapshot_id + config = resolve_config(manifest, snapshot_id_override=snapshot_id_override) + snapshot_id = config.requested_snapshot_id or config.dataset_snapshot_id if auto_refresh and _snapshot_needs_refresh(snapshot_id, end_date): universe_profile = None @@ -567,7 +456,7 @@ def run_backtest( if console: console.print(f"\n[bold yellow]Snapshot '{snapshot_id}' is stale — refreshing...[/]") try: - asyncio.run(_refresh_snapshot(snapshot_id, universe_profile, console=console)) + asyncio.run(_refresh_snapshot(snapshot_id, universe_profile, console=console, manual=False)) except Exception: if _snapshot_has_required_coverage(snapshot_id, end_date): if console: @@ -589,6 +478,7 @@ def run_backtest( start_date=start_date, end_date=end_date, parking_preset=parking_preset, + idle_alpha_preset=idle_alpha_preset, snapshot_id_override=snapshot_id_override, ) diff --git a/apps/paper_trader/engine.py b/apps/paper_trader/engine.py index 08619dc..67bd09d 100644 --- a/apps/paper_trader/engine.py +++ b/apps/paper_trader/engine.py @@ -6,6 +6,7 @@ Order execution (HOW to execute) is done via Alpaca Paper Trading API. from __future__ import annotations import datetime as dt +import json import math import time from dataclasses import dataclass, field @@ -21,7 +22,7 @@ from libs.backtest.domain import ( PlannedOrder, PositionStatus, ) -from libs.backtest.execution import simulate_exit, update_trailing_stop +from libs.backtest.execution import simulate_exit, simulate_scheduled_open_exit, update_trailing_stop from libs.backtest.manifests import load_manifest, resolve_config from libs.backtest.selector import select_candidates from libs.common.logging import get_logger @@ -92,6 +93,12 @@ class PaperTradingEngine: if session.parking_preset: self._config.risk.cash_parking_preset = session.parking_preset self._config.risk.apply_parking_preset() + if session.idle_alpha_preset: + self._config.idle_alpha_sleeve_preset = session.idle_alpha_preset + self._config.apply_idle_alpha_sleeve_preset() + if session.form4_sleeve_preset: + self._config.form4_capture_sleeve_preset = session.form4_sleeve_preset + self._config.apply_form4_capture_sleeve_preset() # Shared attention filtering service (matches BacktestRunner) from libs.backtest.attention import AttentionFilterService @@ -100,6 +107,184 @@ class PaperTradingEngine: oracle_url=oracle_url, scoring_model=self._config.signal.scoring_model, ) + self._capital_bucket_specs: dict[str, float] = {} + get_strategy_engines = getattr(self._config, "get_strategy_engines", None) + strategy_engines = get_strategy_engines() if callable(get_strategy_engines) else [] + for engine in strategy_engines or []: + bucket_id = getattr(engine, "capital_bucket_id", None) + allocation = getattr(engine, "capital_bucket_allocation_pct", None) + if not bucket_id or allocation is None or allocation <= 0: + continue + self._capital_bucket_specs[bucket_id] = max( + self._capital_bucket_specs.get(bucket_id, 0.0), + float(allocation), + ) + + def _get_candidate_capital_bucket_id(self, candidate: Candidate) -> str | None: + return candidate.engine_capital_bucket_id + + def _get_strategy_state_capital_bucket_id(self, state: StrategyStateRow) -> str | None: + try: + payload = json.loads(state.candidate_json) + except Exception: + return None + bucket_id = payload.get("engine_capital_bucket_id") or payload.get("capital_bucket_id") + if not bucket_id: + return None + return str(bucket_id) + + def _active_capital_bucket_ids_for_candidates( + self, + candidates: list[Candidate], + strategy_states: dict[str, StrategyStateRow], + ) -> 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 state in strategy_states.values(): + bucket_id = self._get_strategy_state_capital_bucket_id(state) + if bucket_id: + active_bucket_ids.add(bucket_id) + return active_bucket_ids + + def _capital_bucket_notional( + self, + bucket_id: str, + alpaca_positions: list[Position], + strategy_states: dict[str, StrategyStateRow], + ) -> float: + notional = 0.0 + for position in alpaca_positions: + state = strategy_states.get(position.symbol) + if state is None or self._get_strategy_state_capital_bucket_id(state) != bucket_id: + continue + notional += abs(float(position.market_value)) + return notional + + def _capital_bucket_entry_cost( + self, + bucket_id: str, + alpaca_positions: list[Position], + strategy_states: dict[str, StrategyStateRow], + ) -> float: + entry_cost = 0.0 + for position in alpaca_positions: + state = strategy_states.get(position.symbol) + if state is None or self._get_strategy_state_capital_bucket_id(state) != bucket_id: + continue + entry_cost += abs(float(position.avg_entry_price) * float(position.qty)) + return entry_cost + + def _capital_bucket_realized_pnl(self, session_id: str, bucket_id: str) -> float: + realized = 0.0 + for trade in self._state.list_trades(session_id): + if trade.get("capital_bucket_id") != bucket_id: + continue + realized += float(trade.get("net_pnl") or 0.0) + return realized + + def _capital_bucket_equity( + self, + bucket_id: str, + session_id: str, + alpaca_positions: list[Position], + strategy_states: dict[str, StrategyStateRow], + ) -> float: + allocation = self._capital_bucket_specs.get(bucket_id) + if allocation is None: + return 0.0 + initial_bucket_equity = self._session.initial_equity * allocation + market_value = self._capital_bucket_notional(bucket_id, alpaca_positions, strategy_states) + entry_cost = self._capital_bucket_entry_cost(bucket_id, alpaca_positions, strategy_states) + unrealized = market_value - entry_cost + return max( + 0.0, + initial_bucket_equity + + self._capital_bucket_realized_pnl(session_id, bucket_id) + + unrealized, + ) + + def _capital_bucket_cash_available( + self, + bucket_id: str, + session_id: str, + alpaca_positions: list[Position], + strategy_states: dict[str, StrategyStateRow], + ) -> float: + market_value = self._capital_bucket_notional(bucket_id, alpaca_positions, strategy_states) + return max( + 0.0, + self._capital_bucket_equity(bucket_id, session_id, alpaca_positions, strategy_states) + - market_value, + ) + + def _adjust_portfolio_state_for_candidate( + self, + *, + session_id: str, + candidate: Candidate, + portfolio_state: DailyPortfolioState, + active_bucket_ids: set[str], + alpaca_positions: list[Position], + strategy_states: dict[str, StrategyStateRow], + ) -> DailyPortfolioState: + if not self._capital_bucket_specs or portfolio_state.cash_available <= 0: + return portfolio_state + + configured_bucket_ids = set(self._capital_bucket_specs) + 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, session_id, alpaca_positions, strategy_states + ) + for bucket_id in relevant_bucket_ids + } + bucket_equity = { + bucket_id: self._capital_bucket_equity( + bucket_id, session_id, alpaca_positions, strategy_states + ) + for bucket_id in relevant_bucket_ids + } + sizing_equity = portfolio_state.sizing_equity or portfolio_state.equity + 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, + } + ) # ------------------------------------------------------------------ # # Reconciliation & safety @@ -164,6 +349,8 @@ class PaperTradingEngine: self._state.record_trade( session_id=session_id, symbol=sym, + engine_id=ss.engine_id, + capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss), entry_date=ss.entry_date, exit_date=today.isoformat(), entry_price=None, @@ -341,6 +528,8 @@ class PaperTradingEngine: self._state.record_trade( session_id=session_id, symbol=sym, + engine_id=ss.engine_id, + capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss), entry_date=ss.entry_date, exit_date=today.isoformat(), entry_price=alpaca_pos.avg_entry_price, @@ -397,13 +586,8 @@ class PaperTradingEngine: # Reset sold_today flag from yesterday if parking_st and parking_st.get("sold_today", 0): self._state.update_parking_gate_state(session_id, sold_today=0) - # Accrue SGOV interest - if parking_st and parking_st["symbol"] == "SGOV": - daily_rate = self._config.risk.cash_parking_sgov_annual_rate / 252 - new_value = parking_st["entry_value"] * (1 + daily_rate) - self._state.update_parking_sgov_value(session_id, new_value) # Update peak price for trailing stop / top-up - elif parking_st and parking_st["symbol"] != "SGOV": + if parking_st and parking_st["symbol"] != "SGOV": sym = parking_st["symbol"] bars = self._broker.get_latest_bars([sym]) if sym in bars: @@ -493,6 +677,7 @@ class PaperTradingEngine: p.symbol for p in alpaca_positions_after_exits if p.symbol in strategy_states_after_exits } + engine_batches: list[tuple[Any, list[Candidate]]] = [] for engine_cfg in engines: prelimit = self._config.signal.max_candidates_per_day if self._attention_service.engine_requires_attention(engine_cfg): @@ -515,12 +700,30 @@ class PaperTradingEngine: if engine_cfg.residual_reserve_selected and engine_candidates: reserved_event_ids.update(c.event_id for c in engine_candidates) reserved_symbols.update(c.symbol.upper() for c in engine_candidates) - - engine_risk_used = engine_daily_risk_used.get(engine_cfg.engine_id, 0.0) + engine_batches.append((engine_cfg, engine_candidates)) + + active_bucket_ids = self._active_capital_bucket_ids_for_candidates( + [ + candidate + for _, batch_candidates in engine_batches + for candidate in batch_candidates + ], + strategy_states_after_exits, + ) + for engine_cfg, engine_candidates in engine_batches: for candidate in engine_candidates: - plan = build_planned_order( + engine_risk_used = engine_daily_risk_used.get(engine_cfg.engine_id, 0.0) + candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( + session_id=session_id, candidate=candidate, portfolio_state=portfolio_state, + active_bucket_ids=active_bucket_ids, + alpaca_positions=alpaca_positions_after_exits, + strategy_states=strategy_states_after_exits, + ) + plan = build_planned_order( + candidate=candidate, + portfolio_state=candidate_portfolio_state, open_positions=open_positions, config=self._config, cooldown_remaining=session_st.cooldown_remaining, @@ -543,10 +746,19 @@ class PaperTradingEngine: if self._parking_liquidate_for_event(session_id, today, needed): account = self._broker.get_account() _ap2 = self._broker.list_positions() + alpaca_positions_after_exits = _ap2 portfolio_state = self._build_portfolio_state(account, _ap2, today) - plan = build_planned_order( + candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( + session_id=session_id, candidate=candidate, portfolio_state=portfolio_state, + active_bucket_ids=active_bucket_ids, + alpaca_positions=_ap2, + strategy_states=strategy_states_after_exits, + ) + plan = build_planned_order( + candidate=candidate, + portfolio_state=candidate_portfolio_state, open_positions=open_positions, config=self._config, cooldown_remaining=session_st.cooldown_remaining, @@ -620,7 +832,8 @@ class PaperTradingEngine: ), ) - trade_risk = portfolio_state.equity * ( + trade_risk_state = candidate_portfolio_state.sizing_equity or candidate_portfolio_state.equity + trade_risk = trade_risk_state * ( candidate.engine_per_trade_risk_pct or self._config.risk.per_trade_risk_pct ) @@ -628,6 +841,11 @@ class PaperTradingEngine: session_st.daily_new_risk_used += trade_risk # Refresh portfolio state after each entry + alpaca_positions_after_exits = self._broker.list_positions() + strategy_states_after_exits = { + ss.symbol: ss + for ss in self._state.get_open_strategy_states(session_id) + } open_positions = self._to_open_positions( alpaca_positions_after_exits, strategy_states_after_exits ) @@ -637,6 +855,7 @@ class PaperTradingEngine: portfolio_state = DailyPortfolioState( date=portfolio_state.date, equity=portfolio_state.equity, + sizing_equity=portfolio_state.sizing_equity, cash_available=max( 0.0, portfolio_state.cash_available - plan.entry_price_limit * plan.shares, @@ -673,10 +892,22 @@ class PaperTradingEngine: excluded_event_ids={ss.event_id for ss in strategy_states_after_exits.values()}, excluded_symbols={p.symbol for p in alpaca_positions_after_exits if p.symbol in strategy_states_after_exits}, ) + active_bucket_ids = self._active_capital_bucket_ids_for_candidates( + list(all_candidates), + strategy_states_after_exits, + ) for candidate in all_candidates: - plan = build_planned_order( + candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( + session_id=session_id, candidate=candidate, portfolio_state=portfolio_state, + active_bucket_ids=active_bucket_ids, + alpaca_positions=alpaca_positions_after_exits, + strategy_states=strategy_states_after_exits, + ) + plan = build_planned_order( + candidate=candidate, + portfolio_state=candidate_portfolio_state, open_positions=open_positions, config=self._config, cooldown_remaining=session_st.cooldown_remaining, @@ -998,32 +1229,28 @@ class PaperTradingEngine: # --- Execute sell --- sym = parking_st["symbol"] qty = parking_st["qty"] - entry_price_for_pnl = parking_st.get("sgov_entry_value", 0) or parking_st["avg_price"] logger.info("parking_sell", symbol=sym, qty=qty, reason=f"gate→{target}") try: - if sym != "SGOV" and qty > 0: + if qty > 0: self._broker.close_position(sym, qty=qty) time.sleep(1) self._state.close_parking_state(session_id) - # Persist gate_in_sgov state for next parking buy - # (store in a separate row or use session-level tracking) # Record trade - bars = self._broker.get_latest_bars([sym]) if sym != "SGOV" else {} + bars = self._broker.get_latest_bars([sym]) exit_price = bars[sym].close if sym in bars else parking_st["avg_price"] - if sym == "SGOV": - exit_price = parking_st["entry_value"] # includes accrued interest - entry_price_for_pnl = parking_st.get("sgov_entry_value", 0) or parking_st["avg_price"] self._state.record_trade( session_id=session_id, symbol=sym, + engine_id=None, + capital_bucket_id=None, entry_date=parking_st["entry_date"], exit_date=today.isoformat(), - entry_price=entry_price_for_pnl if sym == "SGOV" else parking_st["avg_price"], + entry_price=parking_st["avg_price"], exit_price=exit_price, exit_reason="PARKING", shares=qty, - net_pnl=(exit_price - entry_price_for_pnl) * qty if sym == "SGOV" else (exit_price - parking_st["avg_price"]) * qty, + net_pnl=(exit_price - parking_st["avg_price"]) * qty, r_multiple=0.0, holding_days=(today - dt.date.fromisoformat(parking_st["entry_date"])).days, ) @@ -1039,8 +1266,7 @@ class PaperTradingEngine: """Release parking cash to fund an event entry that has insufficient cash. Mirrors BacktestRunner._liquidate_parking_for_cash(). - - SGOV (virtual): reduces entry_value in DB; cash becomes available immediately. - - QQQM/QQQ/SPY (real): sells shares via broker; waits 1 s for fill. + Sells shares via broker; waits 1 s for fill. Returns True if any cash was freed. """ parking_st = self._state.get_parking_state(session_id) @@ -1049,23 +1275,7 @@ class PaperTradingEngine: sym = parking_st["symbol"] - if sym == "SGOV": - current_value = float(parking_st.get("entry_value", 0.0)) - release = min(current_value, needed) - if release < 1.0: - return False - new_value = current_value - release - if new_value < 1.0: - self._state.close_parking_state(session_id) - else: - self._state.update_parking_sgov_value(session_id, new_value) - logger.info( - "parking_partial_release_for_event", - symbol="SGOV", released=round(release, 2), remaining=round(new_value, 2), - ) - return True - - # Real broker position (QQQM / QQQ / SPY) + # Broker position (SGOV / QQQM / QQQ / SPY) qty = parking_st.get("qty", 0) if qty <= 0: return False @@ -1112,12 +1322,7 @@ class PaperTradingEngine: if parking_st is None: return 0.0, 0.0 sym = parking_st["symbol"] - if sym == "SGOV": - # Virtual position — no actual broker share - current_value = float(parking_st.get("entry_value", 0.0)) - original = float(parking_st.get("sgov_entry_value", 0) or current_value) - return current_value, current_value - original - # Real broker position (QQQ, SPY, TQQQ, QQQM …) + # Broker position (SGOV / QQQ / SPY / TQQQ / QQQM …) qty = parking_st.get("qty", 0) avg = float(parking_st.get("avg_price", 0)) if qty <= 0: @@ -1172,8 +1377,6 @@ class PaperTradingEngine: # --- Top-up existing parking --- if parking_st is not None: sym = parking_st["symbol"] - if sym == "SGOV": - return # SGOV top-up handled via interest accrual # Check top-up conditions topup_dd = getattr(risk, "cash_parking_topup_max_peak_drawdown_pct", 0) if topup_dd > 0: @@ -1201,20 +1404,7 @@ class PaperTradingEngine: # --- New parking position --- target = self._parking_evaluate_gate(today) - if target == "sgov": - session_equity, session_cash = self._session_cash(session_id) - reserve = session_equity * risk.cash_parking_reserve_pct - investable = max(0.0, session_cash - reserve) - if investable > 100: - self._state.save_parking_state( - session_id, "SGOV", today, 1, investable, investable, - gate_in_sgov=1, committed_target="sgov", - sgov_entry_value=investable, - ) - logger.info("parking_buy_sgov", amount=round(investable, 2)) - return - - # QQQ/SPY/QQQM: buy through broker + # SGOV/QQQ/SPY/QQQM: buy through broker sym = target.upper() session_equity, session_cash = self._session_cash(session_id) reserve = session_equity * risk.cash_parking_reserve_pct @@ -1241,7 +1431,7 @@ class PaperTradingEngine: avg_price = filled.filled_avg_price self._state.save_parking_state( session_id, sym, today, qty, avg_price, avg_price * qty, - peak_price=avg_price, gate_in_sgov=0, + peak_price=avg_price, gate_in_sgov=1 if target == "sgov" else 0, committed_target=target, ) logger.info("parking_filled", symbol=sym, qty=qty, price=round(avg_price, 2)) @@ -1284,12 +1474,12 @@ class PaperTradingEngine: all_rows = await self._detector.get_candidates_for_date( today, self._config, convention="reaction_close" ) - same_day_rows = [r for r in all_rows if self._is_same_day_event(r)] macro_data = await self._fetch_macro(today) entries, rejected = await self._process_entries( - today, same_day_rows, account, alpaca_positions, strategy_states, + today, all_rows, account, alpaca_positions, strategy_states, session_st, macro_data, self._broker.submit_moc_buy, + entry_timing="reaction_close", ) session_st.last_processed_date = today.isoformat() @@ -1300,7 +1490,7 @@ class PaperTradingEngine: logger.info( "paper_engine_reaction_close_done", date=today.isoformat(), - same_day_candidates=len(same_day_rows), + candidates=len(all_rows), entries=len(entries), rejected=len(rejected), ) @@ -1310,7 +1500,7 @@ class PaperTradingEngine: "phase": phase, "entries": entries, "rejected": rejected, - "candidates_detected": len(same_day_rows), + "candidates_detected": len(all_rows), "account": {"equity": session_eq, "cash": session_ca, "market_value": account.long_market_value}, } @@ -1369,23 +1559,36 @@ class PaperTradingEngine: if self._config.risk.cash_parking_enabled: parking_sold_today = self._parking_check_and_sell(session_id, today) - # Entry: after-close 이벤트만 — next_open_after_reaction_close convention + # Entry: all events with entry_date == today, across both conventions. + # Mirrors BacktestRunner: next_open engines call get_candidates_for_date(date) + # which returns ALL rows with execution_date==date regardless of entry_convention. + # + # Exclusion: same-day events with reaction_close convention are excluded here + # because in the Parquet their execution_date is reaction_date+1 (next_open_after + # _reaction_close entry), not reaction_date. Their next_open entry will appear + # tomorrow with entry_convention='next_open_after_reaction_close'. + # ENB (after-close with reaction_close convention, entry_date=reaction_date) is + # correctly included because event_date != reaction_date (not same_day). all_rows = await self._detector.get_candidates_for_date( - today, self._config, convention="next_open_after_reaction_close" + today, self._config, convention=None ) - after_close_rows = [r for r in all_rows if not self._is_same_day_event(r)] + next_open_rows = [ + r for r in all_rows + if not (self._is_same_day_event(r) and r.get("entry_convention") == "reaction_close") + ] macro_data = await self._fetch_macro(today) entries, rejected = await self._process_entries( - today, after_close_rows, account, alpaca_positions, strategy_states, + today, next_open_rows, account, alpaca_positions, strategy_states, session_st, macro_data, self._broker.submit_market_buy, + entry_timing="next_open", ) # CASH PARKING: buy with remaining idle cash after entries if self._config.risk.cash_parking_enabled and not parking_sold_today: self._parking_buy(session_id, today) - summary = self._finalize_day(today, session_st, exits, entries, rejected, len(all_rows)) + summary = self._finalize_day(today, session_st, exits, entries, rejected, len(next_open_rows)) summary["phase"] = phase self._state.mark_phase_processed(session_id, today, phase) return summary @@ -1451,6 +1654,8 @@ class PaperTradingEngine: self._state.record_trade( session_id=session_id, symbol=pos.symbol, + engine_id=ss.engine_id, + capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss), entry_date=ss.entry_date, exit_date=dt.date.today().isoformat(), entry_price=pos.avg_entry_price, @@ -1503,7 +1708,8 @@ class PaperTradingEngine: return exits bar_start = bar_date - dt.timedelta(days=30) - bars_by_symbol = self._broker.get_bars_as_dict(held_symbols, bar_start, bar_date) + # Fetch up to today so NO_PROGRESS / EARLY_FAILURE can execute at today's open. + bars_by_symbol = self._broker.get_bars_as_dict(held_symbols, bar_start, today) for alpaca_pos in alpaca_positions: sym = alpaca_pos.symbol @@ -1548,6 +1754,47 @@ class PaperTradingEngine: ss.peak_price = open_pos.peak_price filled_trade = simulate_exit(open_pos, bar, effective_exec, bar_date) + + # NO_PROGRESS / EARLY_FAILURE: check yesterday's close, execute at today's open. + # Mirrors BacktestRunner._evaluate_pending_open_exit + _process_pending_open_exits. + if filled_trade is None: + close_val = bar.get("close") + if close_val is not None: + close_val = float(close_val) + np_days = effective_exec.early_failure_no_progress_days + np_r = effective_exec.early_failure_no_progress_r + scheduled_reason: str | None = None + # NO_PROGRESS: not enough progress by day N + if ( + np_days is not None and np_r is not None + and ss.days_held == np_days + and open_pos.status.value != "partial" + ): + initial_r = abs(alpaca_pos.avg_entry_price - ss.current_stop) + progress_price = alpaca_pos.avg_entry_price + initial_r * np_r + if close_val < progress_price: + scheduled_reason = "NO_PROGRESS" + # EARLY_FAILURE: day-1 close below both entry and reaction close + if scheduled_reason is None and ( + effective_exec.early_failure_close_below_entry_and_reaction_close + and ss.days_held == 1 + and close_val < alpaca_pos.avg_entry_price + ): + reaction_close = float(open_pos.plan.candidate.features.get("event_close") or close_val) + if close_val < reaction_close: + scheduled_reason = "EARLY_FAILURE" + if scheduled_reason is not None: + today_bar = bars_by_symbol.get(sym, {}).get(today) + if today_bar is not None: + filled_trade = simulate_scheduled_open_exit( + position=open_pos, + bar=today_bar, + config=effective_exec, + current_date=today, + reason=scheduled_reason, + fraction=float(effective_exec.early_failure_no_progress_fraction or 1.0), + ) + if filled_trade is not None: is_partial = filled_trade.shares < alpaca_pos.qty try: @@ -1574,6 +1821,8 @@ class PaperTradingEngine: self._state.close_strategy_state(session_id, sym) self._state.record_trade( session_id=session_id, symbol=sym, + engine_id=ss.engine_id, + capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss), entry_date=ss.entry_date, exit_date=today.isoformat(), entry_price=alpaca_pos.avg_entry_price, exit_price=filled_trade.exit_price, exit_reason=filled_trade.exit_reason.value, shares=filled_trade.shares, @@ -1614,8 +1863,14 @@ class PaperTradingEngine: session_st: Any, macro_data: dict[str, Any], order_fn: Any, + entry_timing: str | None = None, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - """후보군에 대해 진입 판단 + 주문 제출. order_fn = submit_market_buy | submit_moc_buy.""" + """후보군에 대해 진입 판단 + 주문 제출. order_fn = submit_market_buy | submit_moc_buy. + + entry_timing: 'reaction_close' or 'next_open'. When set, only engines with + matching entry_timing_policy are used. Mirrors BacktestRunner's per-engine + get_candidates_for_date vs get_candidates_for_reaction_date split. + """ session_id = self._session.session_id entries: list[dict[str, Any]] = [] rejected: list[dict[str, Any]] = [] @@ -1630,6 +1885,19 @@ class PaperTradingEngine: if not self._state.has_processed_event(session_id, str(r.get("event_id", ""))) ] + # Inject macro values from _fetch_macro() into candidate rows. + # EventDetector (PostgreSQL) rows lack macro_vix/macro_hy_spread; the + # Parquet snapshot pre-embeds them. Without this injection, any engine + # with macro_vix_max set will reject all candidates (None fails the check). + _macro_vix = macro_data.get("VIXCLS") + _macro_hy = macro_data.get("BAMLH0A0HYM2") + if _macro_vix is not None or _macro_hy is not None: + for row in candidate_rows: + if _macro_vix is not None and row.get("macro_vix") is None: + row["macro_vix"] = _macro_vix + if _macro_hy is not None and row.get("macro_hy_spread") is None: + row["macro_hy_spread"] = _macro_hy + open_positions = self._to_open_positions(alpaca_positions, strategy_states) portfolio_state = self._build_portfolio_state(account, alpaca_positions, today) engines = self._config.get_active_strategy_engines() @@ -1658,8 +1926,14 @@ class PaperTradingEngine: engine_list = engines if engines else [None] reserved_event_ids: set[str] = {ss.event_id for ss in strategy_states.values()} reserved_symbols: set[str] = {p.symbol for p in alpaca_positions if p.symbol in strategy_states} + candidate_batches: list[tuple[Any | None, list[Candidate]]] = [] for engine_cfg in engine_list: if engine_cfg is not None: + # Skip engines that don't match the requested entry timing policy. + # Mirrors BacktestRunner: reaction_close engines use get_candidates_for_reaction_date, + # next_open engines use get_candidates_for_date. + if entry_timing is not None and engine_cfg.entry_timing_policy != entry_timing: + continue prelimit = self._config.signal.max_candidates_per_day if self._attention_service.engine_requires_attention(engine_cfg): prelimit = max(prelimit * 5, prelimit) @@ -1680,7 +1954,6 @@ class PaperTradingEngine: if engine_cfg.residual_reserve_selected and engine_candidates: reserved_event_ids.update(c.event_id for c in engine_candidates) reserved_symbols.update(c.symbol.upper() for c in engine_candidates) - engine_risk_used = engine_daily_risk_used.get(engine_cfg.engine_id, 0.0) else: engine_candidates = select_candidates( raw_rows=candidate_rows, @@ -1690,11 +1963,33 @@ class PaperTradingEngine: excluded_event_ids=reserved_event_ids, excluded_symbols=reserved_symbols, ) - engine_risk_used = 0.0 - + candidate_batches.append((engine_cfg, engine_candidates)) + + active_bucket_ids = self._active_capital_bucket_ids_for_candidates( + [ + candidate + for _, batch_candidates in candidate_batches + for candidate in batch_candidates + ], + strategy_states, + ) + for engine_cfg, engine_candidates in candidate_batches: for candidate in engine_candidates: + engine_risk_used = ( + engine_daily_risk_used.get(engine_cfg.engine_id, 0.0) + if engine_cfg is not None + else 0.0 + ) + candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( + session_id=session_id, + candidate=candidate, + portfolio_state=portfolio_state, + active_bucket_ids=active_bucket_ids, + alpaca_positions=alpaca_positions, + strategy_states=strategy_states, + ) plan = build_planned_order( - candidate=candidate, portfolio_state=portfolio_state, + candidate=candidate, portfolio_state=candidate_portfolio_state, open_positions=open_positions, config=self._config, cooldown_remaining=session_st.cooldown_remaining, macro_data=macro_data, @@ -1711,9 +2006,18 @@ class PaperTradingEngine: if self._parking_liquidate_for_event(session_id, today, needed): account = self._broker.get_account() _ap2 = self._broker.list_positions() + alpaca_positions = _ap2 portfolio_state = self._build_portfolio_state(account, _ap2, today) + candidate_portfolio_state = self._adjust_portfolio_state_for_candidate( + session_id=session_id, + candidate=candidate, + portfolio_state=portfolio_state, + active_bucket_ids=active_bucket_ids, + alpaca_positions=_ap2, + strategy_states=strategy_states, + ) plan = build_planned_order( - candidate=candidate, portfolio_state=portfolio_state, + candidate=candidate, portfolio_state=candidate_portfolio_state, open_positions=open_positions, config=self._config, cooldown_remaining=session_st.cooldown_remaining, macro_data=macro_data, @@ -1767,17 +2071,24 @@ class PaperTradingEngine: status="open", ), ) - trade_risk = portfolio_state.equity * ( + trade_risk_state = candidate_portfolio_state.sizing_equity or candidate_portfolio_state.equity + trade_risk = trade_risk_state * ( candidate.engine_per_trade_risk_pct or self._config.risk.per_trade_risk_pct ) if engine_cfg: engine_daily_risk_used[engine_cfg.engine_id] = engine_risk_used + trade_risk session_st.daily_new_risk_used += trade_risk + alpaca_positions = self._broker.list_positions() + strategy_states = { + ss.symbol: ss + for ss in self._state.get_open_strategy_states(session_id) + } open_positions = self._to_open_positions(alpaca_positions, strategy_states) open_positions.append(self._virtual_open_position(candidate, plan, today)) portfolio_state = DailyPortfolioState( date=portfolio_state.date, equity=portfolio_state.equity, + sizing_equity=portfolio_state.sizing_equity, cash_available=max(0.0, portfolio_state.cash_available - plan.entry_price_limit * plan.shares), gross_exposure=portfolio_state.gross_exposure + plan.entry_price_limit * plan.shares, net_exposure=portfolio_state.net_exposure + plan.entry_price_limit * plan.shares, @@ -1979,6 +2290,7 @@ class PaperTradingEngine: return DailyPortfolioState( date=date, equity=session_equity, + sizing_equity=session_equity, cash_available=session_cash, gross_exposure=session_market_value, net_exposure=session_market_value, diff --git a/apps/paper_trader/models.py b/apps/paper_trader/models.py index 308089f..b038716 100644 --- a/apps/paper_trader/models.py +++ b/apps/paper_trader/models.py @@ -12,7 +12,9 @@ CREATE TABLE IF NOT EXISTS sessions ( initial_equity REAL NOT NULL, created_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', - parking_preset TEXT + parking_preset TEXT, + idle_alpha_preset TEXT, + form4_sleeve_preset TEXT ); CREATE TABLE IF NOT EXISTS strategy_states ( @@ -57,6 +59,8 @@ CREATE TABLE IF NOT EXISTS trades ( trade_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, symbol TEXT NOT NULL, + engine_id TEXT, + capital_bucket_id TEXT, entry_date TEXT, exit_date TEXT, entry_price REAL, @@ -124,6 +128,16 @@ def create_schema(db_path: str | Path) -> None: conn.commit() except Exception: pass # column already exists + try: + conn.execute("ALTER TABLE sessions ADD COLUMN idle_alpha_preset TEXT") + conn.commit() + except Exception: + pass # column already exists + try: + conn.execute("ALTER TABLE sessions ADD COLUMN form4_sleeve_preset TEXT") + conn.commit() + except Exception: + pass # column already exists # Migration: add parking_state columns for v2 (top-up, hysteresis, etc.) for col, dtype, default in [ ("peak_price", "REAL", "0"), @@ -139,5 +153,14 @@ def create_schema(db_path: str | Path) -> None: conn.commit() except Exception: pass + for col, dtype in [ + ("engine_id", "TEXT"), + ("capital_bucket_id", "TEXT"), + ]: + try: + conn.execute(f"ALTER TABLE trades ADD COLUMN {col} {dtype}") + conn.commit() + except Exception: + pass finally: conn.close() diff --git a/apps/paper_trader/state.py b/apps/paper_trader/state.py index 4d14827..4ff1e61 100644 --- a/apps/paper_trader/state.py +++ b/apps/paper_trader/state.py @@ -20,6 +20,8 @@ class SessionRow: created_at: str status: str parking_preset: str | None = None + idle_alpha_preset: str | None = None + form4_sleeve_preset: str | None = None @dataclass @@ -87,14 +89,25 @@ class StateManager: config_path: str, initial_equity: float, parking_preset: str | None = None, + idle_alpha_preset: str | None = None, + form4_sleeve_preset: str | None = None, ) -> str: session_id = str(uuid.uuid4())[:8] created_at = dt.datetime.now(tz=dt.timezone.utc).isoformat() with self._connect() as conn: conn.execute( - "INSERT INTO sessions (session_id, session_name, config_path, initial_equity, created_at, status, parking_preset) " - "VALUES (?, ?, ?, ?, ?, 'active', ?)", - (session_id, session_name, config_path, initial_equity, created_at, parking_preset), + "INSERT INTO sessions (session_id, session_name, config_path, initial_equity, created_at, status, parking_preset, idle_alpha_preset, form4_sleeve_preset) " + "VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)", + ( + session_id, + session_name, + config_path, + initial_equity, + created_at, + parking_preset, + idle_alpha_preset, + form4_sleeve_preset, + ), ) conn.execute( "INSERT INTO session_state (session_id) VALUES (?)", @@ -298,6 +311,8 @@ class StateManager: self, session_id: str, symbol: str, + engine_id: str | None, + capital_bucket_id: str | None, entry_date: str | None, exit_date: str, entry_price: float | None, @@ -311,11 +326,11 @@ class StateManager: trade_id = str(uuid.uuid4()) with self._connect() as conn: conn.execute( - "INSERT INTO trades (trade_id, session_id, symbol, entry_date, exit_date, " + "INSERT INTO trades (trade_id, session_id, symbol, engine_id, capital_bucket_id, entry_date, exit_date, " "entry_price, exit_price, exit_reason, shares, net_pnl, r_multiple, holding_days) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( - trade_id, session_id, symbol, entry_date, exit_date, + trade_id, session_id, symbol, engine_id, capital_bucket_id, entry_date, exit_date, entry_price, exit_price, exit_reason, shares, net_pnl, r_multiple, holding_days, ), @@ -445,6 +460,14 @@ class StateManager: sgov_entry_value or entry_value), ) + def list_parking_entries(self, session_id: str) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM parking_state WHERE session_id = ? ORDER BY entry_date", + (session_id,), + ).fetchall() + return [dict(r) for r in rows] + def close_parking_state(self, session_id: str) -> None: with self._connect() as conn: conn.execute( diff --git a/apps/tools/build_form4_pit_cache.py b/apps/tools/build_form4_pit_cache.py new file mode 100644 index 0000000..bb545f5 --- /dev/null +++ b/apps/tools/build_form4_pit_cache.py @@ -0,0 +1,299 @@ +"""Build leakage-safe daily Form 4 cluster parquet from SEC flat files. + +The output is a daily same-day cluster cache keyed by filing date. Runtime code +uses this cache conservatively on the next trading day only. +""" +from __future__ import annotations + +import argparse +import csv +import datetime as dt +import io +import math +import urllib.error +import urllib.request +import zipfile +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pyarrow.parquet as pq + +from apps.backtester.run import _build_merged_snapshot_store, load_manifest, resolve_config +from libs.common.logging import configure_logging + +_DEFAULT_USER_AGENT = "fithia2-form4-cache/1.0 (local research; contact: dev@example.com)" + + +@dataclass(frozen=True) +class RawForm4Transaction: + symbol: str + filing_date: dt.date + transaction_date: dt.date + owner_cik: str + owner_relationship: str + owner_title: str + shares: float + price: float + total_value: float + shares_owned_following: float + purchase_pct_of_holding: float + + +def _parse_date(value: str, *, is_end: bool = False) -> dt.date: + parts = value.split("-") + if len(parts) == 1 and len(value) == 4 and value.isdigit(): + year = int(value) + return dt.date(year, 12, 31) if is_end else dt.date(year, 1, 1) + if len(parts) == 2 and all(part.isdigit() for part in parts): + year = int(parts[0]) + month = int(parts[1]) + if is_end: + next_month = dt.date(year + (month // 12), (month % 12) + 1, 1) + return next_month - dt.timedelta(days=1) + return dt.date(year, month, 1) + return dt.date.fromisoformat(value) + + +def _quarter_range(start_date: dt.date, end_date: dt.date) -> list[tuple[int, int]]: + year = start_date.year + quarter = (start_date.month - 1) // 3 + 1 + end_key = (end_date.year, (end_date.month - 1) // 3 + 1) + quarters: list[tuple[int, int]] = [] + while (year, quarter) <= end_key: + quarters.append((year, quarter)) + quarter += 1 + if quarter == 5: + year += 1 + quarter = 1 + return quarters + + +def _quarter_zip_path(cache_dir: Path, year: int, quarter: int) -> Path: + return cache_dir / f"{year}q{quarter}_form345.zip" + + +def _quarter_zip_url(year: int, quarter: int) -> str: + return ( + "https://www.sec.gov/files/structureddata/data/" + f"insider-transactions-data-sets/{year}q{quarter}_form345.zip" + ) + + +def _ensure_quarter_zip(cache_dir: Path, year: int, quarter: int, *, user_agent: str) -> Path | None: + cache_dir.mkdir(parents=True, exist_ok=True) + path = _quarter_zip_path(cache_dir, year, quarter) + if path.exists() and path.stat().st_size > 0: + return path + request = urllib.request.Request(_quarter_zip_url(year, quarter), headers={"User-Agent": user_agent}) + try: + with urllib.request.urlopen(request, timeout=90) as response: + data = response.read() + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise + path.write_bytes(data) + return path + + +def _parse_sec_date(value: str | None) -> dt.date | None: + if not value: + return None + try: + return dt.datetime.strptime(value, "%d-%b-%Y").date() + except ValueError: + return None + + +def _coerce_float(value: Any) -> float | None: + try: + result = float(value) + except (TypeError, ValueError): + return None + if math.isnan(result) or math.isinf(result): + return None + return result + + +def _is_officer_or_director(relationship: str, title: str) -> bool: + combined = f"{relationship} {title}".strip().lower() + return any(token in combined for token in ("director", "officer", "chief", "ceo", "cfo", "coo", "president", "chair")) + + +def _load_form4_transactions( + *, + cache_dir: Path, + start_date: dt.date, + end_date: dt.date, + allowed_symbols: set[str], + user_agent: str, +) -> tuple[list[RawForm4Transaction], list[str]]: + transactions: list[RawForm4Transaction] = [] + skipped_quarters: list[str] = [] + + for year, quarter in _quarter_range(start_date, end_date): + path = _ensure_quarter_zip(cache_dir, year, quarter, user_agent=user_agent) + if path is None: + skipped_quarters.append(f"{year}Q{quarter}") + continue + with zipfile.ZipFile(path) as zf: + submissions: dict[str, dict[str, Any]] = {} + with zf.open("SUBMISSION.tsv") as handle: + reader = csv.DictReader(io.TextIOWrapper(handle, encoding="utf-8", newline=""), delimiter="\t") + for row in reader: + symbol = str(row.get("ISSUERTRADINGSYMBOL") or "").strip().upper() + if not symbol or symbol not in allowed_symbols: + continue + if str(row.get("DOCUMENT_TYPE") or "").strip().upper() != "4": + continue + filing_date = _parse_sec_date(row.get("FILING_DATE")) + if filing_date is None or filing_date < start_date or filing_date > end_date: + continue + submissions[str(row["ACCESSION_NUMBER"])] = { + "symbol": symbol, + "filing_date": filing_date, + } + + if not submissions: + continue + + relationships: dict[str, list[tuple[str, str, str]]] = defaultdict(list) + with zf.open("REPORTINGOWNER.tsv") as handle: + reader = csv.DictReader(io.TextIOWrapper(handle, encoding="utf-8", newline=""), delimiter="\t") + for row in reader: + accession = str(row["ACCESSION_NUMBER"]) + if accession not in submissions: + continue + owner_cik = str(row.get("RPTOWNERCIK") or "").strip() + relationship = str(row.get("OFFICER_TITLE") or "").strip() + title = str(row.get("OTHER_TEXT") or "").strip() + relationships[accession].append((owner_cik, relationship, title)) + + with zf.open("NONDERIV_TRANS.tsv") as handle: + reader = csv.DictReader(io.TextIOWrapper(handle, encoding="utf-8", newline=""), delimiter="\t") + for row in reader: + accession = str(row["ACCESSION_NUMBER"]) + submission = submissions.get(accession) + if submission is None: + continue + transaction_code = str(row.get("TRANS_CODE") or "").strip().upper() + if transaction_code != "P": + continue + shares = _coerce_float(row.get("TRANS_SHARES")) + price = _coerce_float(row.get("TRANS_PRICEPERSHARE")) + if shares is None or price is None or shares <= 0 or price <= 0: + continue + transaction_date = _parse_sec_date(row.get("TRANS_DATE")) or submission["filing_date"] + shares_owned_following = _coerce_float(row.get("SHRS_OWND_FOLWNG_TRANS")) or 0.0 + purchase_pct = 0.0 + if shares_owned_following > 0: + purchase_pct = min(1.0, shares / shares_owned_following) + rels = relationships.get(accession) or [("", "", "")] + for owner_cik, relationship, title in rels: + transactions.append( + RawForm4Transaction( + symbol=submission["symbol"], + filing_date=submission["filing_date"], + transaction_date=transaction_date, + owner_cik=owner_cik, + owner_relationship=relationship, + owner_title=title, + shares=shares, + price=price, + total_value=shares * price, + shares_owned_following=shares_owned_following, + purchase_pct_of_holding=purchase_pct, + ) + ) + + return transactions, skipped_quarters + + +def _aggregate_daily_events(transactions: list[RawForm4Transaction]) -> list[dict[str, Any]]: + grouped: dict[tuple[dt.date, str], list[RawForm4Transaction]] = defaultdict(list) + for row in transactions: + grouped[(row.filing_date, row.symbol)].append(row) + + events: list[dict[str, Any]] = [] + for (filing_date, symbol), rows in grouped.items(): + owner_keys = {row.owner_cik or f"{symbol}:{idx}" for idx, row in enumerate(rows)} + purchase_values = [row.purchase_pct_of_holding for row in rows if row.purchase_pct_of_holding > 0] + lag_values = [(row.filing_date - row.transaction_date).days for row in rows] + events.append( + { + "symbol": symbol, + "filing_date": filing_date.isoformat(), + "as_of_date": filing_date.isoformat(), + "total_value": round(sum(row.total_value for row in rows), 2), + "owner_count": len(owner_keys), + "transaction_count": len(rows), + "event_day_count": 1, + "max_purchase_pct": max(purchase_values) if purchase_values else 0.0, + "median_purchase_pct": sorted(purchase_values)[len(purchase_values) // 2] if purchase_values else 0.0, + "weighted_purchase_pct": max(purchase_values) if purchase_values else 0.0, + "max_lag_days": max(lag_values) if lag_values else None, + "min_lag_days": min(lag_values) if lag_values else None, + "has_officer_or_director": any( + _is_officer_or_director(row.owner_relationship, row.owner_title) + for row in rows + ), + } + ) + + events.sort(key=lambda row: (row["filing_date"], row["symbol"])) + return events + + +def _allowed_symbols_from_manifest(manifest_path: str, start_date: dt.date, end_date: dt.date) -> set[str]: + manifest = load_manifest(manifest_path) + config = resolve_config(manifest) + store = _build_merged_snapshot_store( + manifest, + config, + snapshot_dir_override=None, + ).slice_by_date_range(start_date, end_date) + return {str(symbol).upper() for symbol in store._bars.keys()} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build daily Form 4 same-day cluster parquet") + parser.add_argument("--config", required=True, help="Experiment manifest JSON path") + parser.add_argument("--start", required=True, help="Start date (YYYY, YYYY-MM, YYYY-MM-DD)") + parser.add_argument("--end", required=True, help="End date (YYYY, YYYY-MM, YYYY-MM-DD)") + parser.add_argument("--cache-dir", default="data/cache/form4", help="SEC zip cache dir") + parser.add_argument("--output", default="data/reference/form4_daily_events_pit.parquet", help="Output parquet path") + parser.add_argument("--user-agent", default=_DEFAULT_USER_AGENT, help="SEC User-Agent header") + args = parser.parse_args() + + configure_logging("INFO") + start_date = _parse_date(args.start) + end_date = _parse_date(args.end, is_end=True) + allowed_symbols = _allowed_symbols_from_manifest(args.config, start_date, end_date) + transactions, skipped_quarters = _load_form4_transactions( + cache_dir=Path(args.cache_dir), + start_date=start_date, + end_date=end_date, + allowed_symbols=allowed_symbols, + user_agent=args.user_agent, + ) + events = _aggregate_daily_events(transactions) + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + table = pa.Table.from_pylist(events) + pq.write_table(table, str(output_path)) + print( + { + "output": str(output_path), + "symbols": len({row["symbol"] for row in events}), + "events": len(events), + "transactions": len(transactions), + "skipped_quarters": skipped_quarters, + } + ) + + +if __name__ == "__main__": + main() diff --git a/apps/web/direct_runner.py b/apps/web/direct_runner.py new file mode 100644 index 0000000..07ba3f1 --- /dev/null +++ b/apps/web/direct_runner.py @@ -0,0 +1,95 @@ +"""Direct backtest subprocess runner. + +Invoked by the web server as a subprocess: + python -m apps.web.direct_runner TASK_ID CONFIG_PATH CAPITAL START_DATE END_DATE RESULT_FILE [--parking PRESET] [--idle-alpha PRESET] [--form4-sleeve PRESET] + +Runs run_backtest_session_sync() and saves the result JSON to RESULT_FILE. +Exits 0 on success, non-zero on failure. +""" +from __future__ import annotations + +import datetime as dt +import json +import sys +from pathlib import Path + + +def _json_default(obj): + if isinstance(obj, (dt.date, dt.datetime)): + return obj.isoformat() + raise TypeError(f"Object of type {type(obj)} is not JSON serializable") + + +def main(): + if len(sys.argv) < 7: + print( + "Usage: direct_runner TASK_ID CONFIG_PATH CAPITAL START_DATE END_DATE RESULT_FILE [--parking PRESET] [--idle-alpha PRESET] [--form4-sleeve PRESET]", + file=sys.stderr, + ) + sys.exit(2) + + task_id = sys.argv[1] + config_path = sys.argv[2] + capital = float(sys.argv[3]) + start_date = dt.date.fromisoformat(sys.argv[4]) + end_date = dt.date.fromisoformat(sys.argv[5]) + result_file = Path(sys.argv[6]) + + # Optional --parking PRESET, --idle-alpha PRESET, --form4-sleeve PRESET, and --snapshot-id ID + parking_preset = None + idle_alpha_preset = None + form4_sleeve_preset = None + snapshot_id_override = None + remaining = sys.argv[7:] + i = 0 + while i < len(remaining): + if remaining[i] == "--parking" and i + 1 < len(remaining): + parking_preset = remaining[i + 1] + i += 2 + elif remaining[i] == "--idle-alpha" and i + 1 < len(remaining): + idle_alpha_preset = remaining[i + 1] + i += 2 + elif remaining[i] == "--form4-sleeve" and i + 1 < len(remaining): + form4_sleeve_preset = remaining[i + 1] + i += 2 + elif remaining[i] == "--snapshot-id" and i + 1 < len(remaining): + snapshot_id_override = remaining[i + 1] + i += 2 + else: + i += 1 + + print(f"[direct] {task_id} · {Path(config_path).stem} · {start_date}→{end_date}" + + (f" · parking={parking_preset}" if parking_preset else "") + + (f" · idle_alpha={idle_alpha_preset}" if idle_alpha_preset else "") + + (f" · form4={form4_sleeve_preset}" if form4_sleeve_preset else "") + + (f" · snapshot={snapshot_id_override}" if snapshot_id_override else "")) + sys.stdout.flush() + + from apps.paper_trader.backtest_sim import run_backtest_session_sync + + result = run_backtest_session_sync( + session_name=Path(config_path).stem, + config_path=config_path, + initial_equity=capital, + start_date=start_date, + end_date=end_date, + parking_preset=parking_preset, + idle_alpha_preset=idle_alpha_preset, + form4_sleeve_preset=form4_sleeve_preset, + snapshot_id_override=snapshot_id_override, + ) + + result_file.parent.mkdir(parents=True, exist_ok=True) + result_file.write_text(json.dumps(result, default=_json_default, indent=2)) + + s = result.get("summary", {}) + print( + f"[direct] done · return={s.get('return_pct', 0):+.2f}% " + f"trades={s.get('trade_count', 0)} " + f"sharpe={s.get('sharpe', 0):.2f}" + ) + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/apps/web/routers/backtest.py b/apps/web/routers/backtest.py index ac35295..02151ad 100644 --- a/apps/web/routers/backtest.py +++ b/apps/web/routers/backtest.py @@ -20,7 +20,11 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from apps.web.dependencies import get_configs_dir, get_project_root, get_runs_dir -from libs.backtest.domain import PARKING_PRESETS +from libs.backtest.domain import ( + FORM4_CAPTURE_SLEEVE_PRESETS, + IDLE_ALPHA_SLEEVE_PRESETS, + PARKING_PRESETS, +) router = APIRouter(prefix="/backtest", tags=["backtest"]) @@ -341,6 +345,8 @@ class BacktestRequest(BaseModel): no_trades: bool = False mode: str = "cli" # "cli" (subprocess) or "direct" (in-process) parking: str | None = None # cash parking preset name + idle_alpha: str | None = None # idle alpha sleeve preset name + form4_sleeve: str | None = None # Form 4 residual-cash sleeve preset name snapshot_id: str | None = None # override dataset_snapshot_id (e.g. for OOT periods) @@ -351,6 +357,9 @@ class BatchBacktestRequest(BaseModel): end: str | None = None year: str | None = None no_trades: bool = False + parking: str | None = None + idle_alpha: str | None = None + form4_sleeve: str | None = None # --------------------------------------------------------------------------- @@ -366,6 +375,8 @@ def _make_task( no_trades: bool = False, mode: str = "cli", parking: str | None = None, + idle_alpha: str | None = None, + form4_sleeve: str | None = None, snapshot_id: str | None = None, ) -> dict[str, Any]: return { @@ -386,6 +397,8 @@ def _make_task( "mode": mode, "has_direct_result": False, "parking": parking, + "idle_alpha": idle_alpha, + "form4_sleeve": form4_sleeve, "snapshot_id": snapshot_id, } @@ -493,7 +506,19 @@ def _watch_process(task_id: str, proc: subprocess.Popen[bytes], output_root: Pat _persist_task(task) -def _build_cmd(config_paths: list[Path], capital: float, start: str | None, end: str | None, year: str | None, no_trades: bool, runs_dir: Path, parking: str | None = None, snapshot_id: str | None = None) -> list[str]: +def _build_cmd( + config_paths: list[Path], + capital: float, + start: str | None, + end: str | None, + year: str | None, + no_trades: bool, + runs_dir: Path, + parking: str | None = None, + idle_alpha: str | None = None, + form4_sleeve: str | None = None, + snapshot_id: str | None = None, +) -> list[str]: """Build the paper backtest CLI command.""" cmd = [ sys.executable, "-m", "apps.paper_trader.cli", "backtest", @@ -512,6 +537,10 @@ def _build_cmd(config_paths: list[Path], capital: float, start: str | None, end: cmd.append("--no-trades") if parking: cmd += ["--parking", parking] + if idle_alpha: + cmd += ["--idle-alpha", idle_alpha] + if form4_sleeve: + cmd += ["--form4-sleeve", form4_sleeve] if snapshot_id: cmd += ["--snapshot-id", snapshot_id] return cmd @@ -526,8 +555,25 @@ def _launch_backtest(req: BacktestRequest) -> dict[str, Any]: config_path = configs_dir / f"{req.experiment_name}.json" if not config_path.exists(): raise HTTPException(status_code=404, detail=f"Experiment config not found: {req.experiment_name}") + if req.parking and req.parking not in PARKING_PRESETS: + raise HTTPException(status_code=400, detail=f"Unknown parking preset: {req.parking}") + if req.idle_alpha and req.idle_alpha not in IDLE_ALPHA_SLEEVE_PRESETS: + raise HTTPException(status_code=400, detail=f"Unknown idle alpha preset: {req.idle_alpha}") + if req.form4_sleeve and req.form4_sleeve not in FORM4_CAPTURE_SLEEVE_PRESETS: + raise HTTPException(status_code=400, detail=f"Unknown Form 4 sleeve preset: {req.form4_sleeve}") - task = _make_task(req.experiment_name, req.capital, req.start, req.end, req.year, req.no_trades, parking=req.parking, snapshot_id=req.snapshot_id) + task = _make_task( + req.experiment_name, + req.capital, + req.start, + req.end, + req.year, + req.no_trades, + parking=req.parking, + idle_alpha=req.idle_alpha, + form4_sleeve=req.form4_sleeve, + snapshot_id=req.snapshot_id, + ) task_id = task["task_id"] # Log directory @@ -544,10 +590,25 @@ def _launch_backtest(req: BacktestRequest) -> dict[str, Any]: "end": req.end, "year": req.year, "no_trades": req.no_trades, + "parking": req.parking, + "idle_alpha": req.idle_alpha, + "form4_sleeve": req.form4_sleeve, "created_at": task["created_at"], }, indent=2)) - cmd = _build_cmd([config_path], req.capital, req.start, req.end, req.year, req.no_trades, runs_dir, req.parking, req.snapshot_id) + cmd = _build_cmd( + [config_path], + req.capital, + req.start, + req.end, + req.year, + req.no_trades, + runs_dir, + req.parking, + req.idle_alpha, + req.form4_sleeve, + req.snapshot_id, + ) with open(log_file, "wb") as log_fp: proc = subprocess.Popen( @@ -571,7 +632,17 @@ def _launch_backtest(req: BacktestRequest) -> dict[str, Any]: return task -def _launch_backtest_multi(names: list[str], capital: float, start: str | None, end: str | None, year: str | None, no_trades: bool) -> dict[str, Any]: +def _launch_backtest_multi( + names: list[str], + capital: float, + start: str | None, + end: str | None, + year: str | None, + no_trades: bool, + parking: str | None = None, + idle_alpha: str | None = None, + form4_sleeve: str | None = None, +) -> dict[str, Any]: """Launch ONE subprocess with multiple --config flags for batch experiments.""" project_root = get_project_root() configs_dir = get_configs_dir() @@ -585,7 +656,17 @@ def _launch_backtest_multi(names: list[str], capital: float, start: str | None, config_paths.append(cp) display_name = ", ".join(names) - task = _make_task(display_name, capital, start, end, year, no_trades) + task = _make_task( + display_name, + capital, + start, + end, + year, + no_trades, + parking=parking, + idle_alpha=idle_alpha, + form4_sleeve=form4_sleeve, + ) task_id = task["task_id"] log_dir = runs_dir / ".backtest_tasks" @@ -603,10 +684,24 @@ def _launch_backtest_multi(names: list[str], capital: float, start: str | None, "end": end, "year": year, "no_trades": no_trades, + "parking": parking, + "idle_alpha": idle_alpha, + "form4_sleeve": form4_sleeve, "created_at": created_at, }, indent=2)) - cmd = _build_cmd(config_paths, capital, start, end, year, no_trades, runs_dir) + cmd = _build_cmd( + config_paths, + capital, + start, + end, + year, + no_trades, + runs_dir, + parking, + idle_alpha, + form4_sleeve, + ) with open(log_file, "wb") as log_fp: proc = subprocess.Popen( @@ -691,7 +786,11 @@ def _launch_direct_backtest(req: BacktestRequest) -> dict[str, Any]: task = _make_task( req.experiment_name, req.capital, req.start, req.end, req.year, req.no_trades, - mode="direct", parking=req.parking, snapshot_id=req.snapshot_id, + mode="direct", + parking=req.parking, + idle_alpha=req.idle_alpha, + form4_sleeve=req.form4_sleeve, + snapshot_id=req.snapshot_id, ) task_id = task["task_id"] @@ -710,6 +809,10 @@ def _launch_direct_backtest(req: BacktestRequest) -> dict[str, Any]: ] if req.parking: cmd += ["--parking", req.parking] + if req.idle_alpha: + cmd += ["--idle-alpha", req.idle_alpha] + if req.form4_sleeve: + cmd += ["--form4-sleeve", req.form4_sleeve] if req.snapshot_id: cmd += ["--snapshot-id", req.snapshot_id] @@ -763,6 +866,9 @@ def submit_batch(req: BatchBacktestRequest) -> dict[str, Any]: end=req.end, year=req.year, no_trades=req.no_trades, + parking=req.parking, + idle_alpha=req.idle_alpha, + form4_sleeve=req.form4_sleeve, ) task = _launch_backtest(single) return {"tasks": [task]} @@ -774,6 +880,9 @@ def submit_batch(req: BatchBacktestRequest) -> dict[str, Any]: end=req.end, year=req.year, no_trades=req.no_trades, + parking=req.parking, + idle_alpha=req.idle_alpha, + form4_sleeve=req.form4_sleeve, ) return {"tasks": [task]} @@ -1024,3 +1133,17 @@ def get_parking_presets() -> dict[str, Any]: groups.setdefault(group, []).append(name) return {"presets": list(PARKING_PRESETS.keys()), "groups": groups} + + +@router.get("/idle-alpha-presets") +def get_idle_alpha_presets() -> dict[str, Any]: + """Return named idle-alpha sleeve presets.""" + groups = {"Residual Event Sleeve": list(IDLE_ALPHA_SLEEVE_PRESETS.keys())} + return {"presets": list(IDLE_ALPHA_SLEEVE_PRESETS.keys()), "groups": groups} + + +@router.get("/form4-capture-presets") +def get_form4_capture_presets() -> dict[str, Any]: + """Return named Form 4 residual-cash sleeve presets.""" + groups = {"Residual Insider Sleeve": list(FORM4_CAPTURE_SLEEVE_PRESETS.keys())} + return {"presets": list(FORM4_CAPTURE_SLEEVE_PRESETS.keys()), "groups": groups} diff --git a/apps/web/routers/paper_trading.py b/apps/web/routers/paper_trading.py index 69aaa08..50a1219 100644 --- a/apps/web/routers/paper_trading.py +++ b/apps/web/routers/paper_trading.py @@ -55,6 +55,8 @@ def _session_summary(session, state) -> dict[str, Any]: "created_at": session.created_at, "status": session.status, "parking_preset": session.parking_preset, + "idle_alpha_preset": session.idle_alpha_preset, + "form4_sleeve_preset": session.form4_sleeve_preset, "current_equity": current, "peak_equity": peak, "total_pnl": total_pnl, @@ -110,6 +112,8 @@ class CreateSessionRequest(BaseModel): config: str # experiment name, numeric ID, or config path capital: float = 10000.0 parking: str | None = None # optional parking preset name + idle_alpha: str | None = None # optional idle alpha sleeve preset name + form4_sleeve: str | None = None # optional Form 4 sleeve preset name @router.post("/sessions") @@ -132,6 +136,20 @@ def create_session(req: CreateSessionRequest) -> dict[str, Any]: raise HTTPException(status_code=400, detail=f"Unknown parking preset: {req.parking}") except ImportError: pass + if req.idle_alpha: + try: + from libs.backtest.domain import IDLE_ALPHA_SLEEVE_PRESETS + if req.idle_alpha not in IDLE_ALPHA_SLEEVE_PRESETS: + raise HTTPException(status_code=400, detail=f"Unknown idle alpha preset: {req.idle_alpha}") + except ImportError: + pass + if req.form4_sleeve: + try: + from libs.backtest.domain import FORM4_CAPTURE_SLEEVE_PRESETS + if req.form4_sleeve not in FORM4_CAPTURE_SLEEVE_PRESETS: + raise HTTPException(status_code=400, detail=f"Unknown Form 4 sleeve preset: {req.form4_sleeve}") + except ImportError: + pass state = _get_state_manager() if state.get_session(req.name) is not None: @@ -142,8 +160,18 @@ def create_session(req: CreateSessionRequest) -> dict[str, Any]: config_path=config_path, initial_equity=req.capital, parking_preset=req.parking or None, + idle_alpha_preset=req.idle_alpha or None, + form4_sleeve_preset=req.form4_sleeve or None, ) - return {"session_id": session_id, "name": req.name, "capital": req.capital, "config": config_path, "parking": req.parking} + return { + "session_id": session_id, + "name": req.name, + "capital": req.capital, + "config": config_path, + "parking": req.parking, + "idle_alpha": req.idle_alpha, + "form4_sleeve": req.form4_sleeve, + } @router.get("/sessions/{session_id}") @@ -295,7 +323,36 @@ def get_trades( session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") + import datetime trades = state.list_trades(session.session_id, limit=last) + + # Include parking entries (active + closed) as parking-sleeve trades + parking_entries = state.list_parking_entries(session.session_id) + today = datetime.date.today() + for p in parking_entries: + entry_date = p.get("entry_date", "") + try: + days = (today - datetime.date.fromisoformat(entry_date)).days + except Exception: + days = 0 + is_active = p.get("status") == "active" + trades.append({ + "trade_id": f"parking_{session_id}_{entry_date}_{p['symbol']}", + "session_id": session_id, + "symbol": p["symbol"], + "entry_date": entry_date, + "exit_date": None if is_active else entry_date, + "entry_price": p.get("avg_price"), + "exit_price": None, + "exit_reason": "active" if is_active else "closed", + "shares": p.get("qty"), + "net_pnl": None, + "r_multiple": None, + "holding_days": days, + "engine_id": "cash_parking", + "capital_bucket_id": "parking", + }) + return {"trades": trades, "total": len(trades)} diff --git a/apps/web/static/assets/index-DS-hUHgy.js b/apps/web/static/assets/index-BxBohZ7I.js similarity index 55% rename from apps/web/static/assets/index-DS-hUHgy.js rename to apps/web/static/assets/index-BxBohZ7I.js index be58b83..79e75d9 100644 --- a/apps/web/static/assets/index-DS-hUHgy.js +++ b/apps/web/static/assets/index-BxBohZ7I.js @@ -8,7 +8,7 @@ Error generating stack: `+e.message+` `+e.stack}}var ye=Object.prototype.hasOwnProperty,be=t.unstable_scheduleCallback,xe=t.unstable_cancelCallback,Se=t.unstable_shouldYield,Ce=t.unstable_requestPaint,we=t.unstable_now,Te=t.unstable_getCurrentPriorityLevel,Ee=t.unstable_ImmediatePriority,De=t.unstable_UserBlockingPriority,Oe=t.unstable_NormalPriority,ke=t.unstable_LowPriority,Ae=t.unstable_IdlePriority,je=t.log,Me=t.unstable_setDisableYieldValue,Ne=null,Pe=null;function Fe(e){if(typeof je==`function`&&Me(e),Pe&&typeof Pe.setStrictMode==`function`)try{Pe.setStrictMode(Ne,e)}catch{}}var Ie=Math.clz32?Math.clz32:ze,Le=Math.log,Re=Math.LN2;function ze(e){return e>>>=0,e===0?32:31-(Le(e)/Re|0)|0}var Be=256,Ve=262144,He=4194304;function Ue(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function We(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Ue(n))):i=Ue(o):i=Ue(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Ue(n))):i=Ue(o)):i=Ue(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Ge(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Ke(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function qe(){var e=He;return He<<=1,!(He&62914560)&&(He=4194304),e}function Je(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Ye(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Xe(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),ln=!1;if(cn)try{var un={};Object.defineProperty(un,`passive`,{get:function(){ln=!0}}),window.addEventListener(`test`,un,un),window.removeEventListener(`test`,un,un)}catch{ln=!1}var dn=null,fn=null,pn=null;function mn(){if(pn)return pn;var e,t=fn,n=t.length,r,i=`value`in dn?dn.value:dn.textContent,a=i.length;for(e=0;e=Kn),Yn=` `,Xn=!1;function Zn(e,t){switch(e){case`keyup`:return Wn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Qn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var $n=!1;function er(e,t){switch(e){case`compositionend`:return Qn(t);case`keypress`:return t.which===32?(Xn=!0,Yn):null;case`textInput`:return e=t.data,e===Yn&&Xn?null:e;default:return null}}function tr(e,t){if($n)return e===`compositionend`||!Gn&&Zn(e,t)?(e=mn(),pn=fn=dn=null,$n=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Cr(n)}}function Tr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Tr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Er(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Ft(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Ft(e.document)}return t}function Dr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Or=cn&&`documentMode`in document&&11>=document.documentMode,kr=null,Ar=null,jr=null,Mr=!1;function Nr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Mr||kr==null||kr!==Ft(r)||(r=kr,`selectionStart`in r&&Dr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),jr&&Sr(jr,r)||(jr=r,r=Md(Ar,`onSelect`),0>=o,i-=o,Ci=1<<32-Ie(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),Mi&&Ti(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),Mi&&Ti(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return Mi&&Ti(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),Mi&&Ti(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===O&&Ta(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ma(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=di(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=ui(o.type,o.key,o.props,null,e.mode,c),Ma(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=mi(o,e.mode,c),c.return=e,e=c}return s(e);case O:return o=Ta(o),b(e,r,o,c)}if(ee(o))return h(e,r,o,c);if(M(o)){if(l=M(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,ja(o),c);if(o.$$typeof===C)return b(e,r,ea(e,o),c);Na(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=fi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Aa=0;var i=b(e,t,n,r);return ka=null,i}catch(t){if(t===ya||t===xa)throw t;var a=oi(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Fa=Pa(!0),Ia=Pa(!1),La=!1;function Ra(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function U(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function W(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function za(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,K&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=ri(e),ni(e,null,n),t}return $r(e,r,t,n),ri(e)}function Ba(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Qe(e,n)}}function Va(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Ha=!1;function Ua(){if(Ha){var e=da;if(e!==null)throw e}}function Wa(e,t,n,r){Ha=!1;var i=e.updateQueue;La=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,m=f!==s.lane;if(m?(Vl&f)===f:(r&f)===f){f!==0&&f===ua&&(Ha=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=p({},d,f);break a;case 2:La=!0}}f=s.callback,f!==null&&(e.flags|=64,m&&(e.flags|=8192),m=i.callbacks,m===null?i.callbacks=[f]:m.push(f))}else m={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=m,c=d):u=u.next=m,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;m=s,s=m.next,m.next=null,i.lastBaseUpdate=m,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Yl|=o,e.lanes=o,e.memoizedState=d}}function Ga(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Ka(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=F.T,s={};F.T=s,Ms(e,!1,t,n);try{var c=i(),l=F.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?js(e,t,ma(c,r),_u(e)):js(e,t,r,_u(e))}catch(n){js(e,t,{then:function(){},status:`rejected`,reason:n},_u())}finally{I.p=a,o!==null&&s.types!==null&&(o.types=s.types),F.T=o}}function xs(){}function Ss(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=Cs(e).queue;bs(e,a,t,te,n===null?xs:function(){return ws(e),n(r)})}function Cs(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:te,baseState:te,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:No,lastRenderedState:te},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:No,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function ws(e){var t=Cs(e);t.next===null&&(t=e.alternate.memoizedState),js(e,t.next.queue,{},_u())}function Ts(){return $i(ap)}function Es(){return Oo().memoizedState}function Ds(){return Oo().memoizedState}function Os(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=_u();e=W(n);var r=za(t,e,n);r!==null&&(yu(r,t,n),Ba(r,t,n)),t={cache:oa()},e.payload=t;return}t=t.return}}function ks(e,t,n){var r=_u();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Ns(e)?Ps(t,n):(n=ei(e,t,n,r),n!==null&&(yu(n,e,r),Fs(n,t,r)))}function As(e,t,n){js(e,t,n,_u())}function js(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ns(e))Ps(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,xr(s,o))return $r(e,t,i,0),zl===null&&B(),!1}catch{}if(n=ei(e,t,i,r),n!==null)return yu(n,e,r),Fs(n,t,r),!0}return!1}function Ms(e,t,n,r){if(r={lane:2,revertLane:gd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Ns(e)){if(t)throw Error(i(479))}else t=ei(e,n,r,2),t!==null&&yu(t,e,2)}function Ns(e){var t=e.alternate;return e===G||t!==null&&t===G}function Ps(e,t){fo=uo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Fs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Qe(e,n)}}var Is={readContext:$i,use:jo,useCallback:vo,useContext:vo,useEffect:vo,useImperativeHandle:vo,useLayoutEffect:vo,useInsertionEffect:vo,useMemo:vo,useReducer:vo,useRef:vo,useState:vo,useDebugValue:vo,useDeferredValue:vo,useTransition:vo,useSyncExternalStore:vo,useId:vo,useHostTransitionStatus:vo,useFormState:vo,useActionState:vo,useOptimistic:vo,useMemoCache:vo,useCacheRefresh:vo};Is.useEffectEvent=vo;var Ls={readContext:$i,use:jo,useCallback:function(e,t){return Do().memoizedState=[e,t===void 0?null:t],e},useContext:$i,useEffect:ss,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),as(4194308,4,ps.bind(null,t,e),n)},useLayoutEffect:function(e,t){return as(4194308,4,e,t)},useInsertionEffect:function(e,t){as(4,2,e,t)},useMemo:function(e,t){var n=Do();t=t===void 0?null:t;var r=e();if(po){Fe(!0);try{e()}finally{Fe(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Do();if(n!==void 0){var i=n(t);if(po){Fe(!0);try{n(t)}finally{Fe(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=ks.bind(null,G,e),[r.memoizedState,e]},useRef:function(e){var t=Do();return e={current:e},t.memoizedState=e},useState:function(e){e=Uo(e);var t=e.queue,n=As.bind(null,G,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:hs,useDeferredValue:function(e,t){return vs(Do(),e,t)},useTransition:function(){var e=Uo(!1);return e=bs.bind(null,G,e.queue,!0,!1),Do().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=G,a=Do();if(Mi){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),zl===null)throw Error(i(349));Vl&127||Ro(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ss(Bo.bind(null,r,o,e),[e]),r.flags|=2048,rs(9,{destroy:void 0},zo.bind(null,r,o,n,t),null),n},useId:function(){var e=Do(),t=zl.identifierPrefix;if(Mi){var n=wi,r=Ci;n=(r&~(1<<32-Ie(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=mo++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[at]=t,o[ot]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Vd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&jc(t)}}return Ic(t),Mc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&jc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=oe.current,zi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Ai,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[at]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Rd(e.nodeValue,n)),e||Ii(t,!0)}else e=qd(e).createTextNode(r),e[at]=t,t.stateNode=e}return Ic(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=zi(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[at]=t}else Bi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ic(t),e=!1}else n=Vi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(io(t),t):(io(t),null);if(t.flags&128)throw Error(i(558))}return Ic(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=zi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[at]=t}else Bi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ic(t),a=!1}else a=Vi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(io(t),t):(io(t),null)}return io(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Pc(t,t.updateQueue),Ic(t),null);case 4:return le(),e===null&&Od(t.stateNode.containerInfo),Ic(t),null;case 10:return qi(t.type),Ic(t),null;case 19:if(R(ao),r=t.memoizedState,r===null)return Ic(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Fc(r,!1);else{if(Jl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=oo(e),o!==null){for(t.flags|=128,Fc(r,!1),e=o.updateQueue,t.updateQueue=e,Pc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)li(n,e),n=n.sibling;return z(ao,ao.current&1|2),Mi&&Ti(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&we()>au&&(t.flags|=128,a=!0,Fc(r,!1),t.lanes=4194304)}else{if(!a)if(e=oo(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Pc(t,e),Fc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!Mi)return Ic(t),null}else 2*we()-r.renderingStartTime>au&&n!==536870912&&(t.flags|=128,a=!0,Fc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(Ic(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=we(),e.sibling=null,n=ao.current,z(ao,a?n&1|2:n&1),Mi&&Ti(t,r.treeForkCount),e);case 22:case 23:return io(t),Za(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Ic(t),t.subtreeFlags&6&&(t.flags|=8192)):Ic(t),n=t.updateQueue,n!==null&&Pc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&R(ha),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),qi(aa),Ic(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Rc(e,t){switch(Oi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return qi(aa),le(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return de(t),null;case 31:if(t.memoizedState!==null){if(io(t),t.alternate===null)throw Error(i(340));Bi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(io(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Bi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return R(ao),null;case 4:return le(),null;case 10:return qi(t.type),null;case 22:case 23:return io(t),Za(),e!==null&&R(ha),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return qi(aa),null;case 25:return null;default:return null}}function zc(e,t){switch(Oi(t),t.tag){case 3:qi(aa),le();break;case 26:case 27:case 5:de(t);break;case 4:le();break;case 31:t.memoizedState!==null&&io(t);break;case 13:io(t);break;case 19:R(ao);break;case 10:qi(t.type);break;case 22:case 23:io(t),Za(),e!==null&&R(ha);break;case 24:qi(aa)}}function Bc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Yu(t,t.return,e)}}function Vc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Yu(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Yu(t,t.return,e)}}function Hc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Ka(t,n)}catch(t){Yu(e,e.return,t)}}}function Uc(e,t,n){n.props=Ws(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Yu(e,t,n)}}function Wc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Yu(e,t,n)}}function Gc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Yu(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Yu(e,t,n)}else n.current=null}function Kc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Yu(e,e.return,t)}}function qc(e,t,n){try{var r=e.stateNode;Hd(r,e.type,n,t),r[ot]=t}catch(t){Yu(e,e.return,t)}}function Jc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&af(e.type)||e.tag===4}function Yc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Jc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&af(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Xc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Qt));else if(r!==4&&(r===27&&af(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Xc(e,t,n),e=e.sibling;e!==null;)Xc(e,t,n),e=e.sibling}function Zc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&af(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Zc(e,t,n),e=e.sibling;e!==null;)Zc(e,t,n),e=e.sibling}function Qc(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Vd(t,r,n),t[at]=e,t[ot]=n}catch(t){Yu(e,e.return,t)}}var $c=!1,el=!1,tl=!1,nl=typeof WeakSet==`function`?WeakSet:Set,rl=null;function il(e,t){if(e=e.containerInfo,Gd=pp,e=Er(e),Dr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Kd={focusedElem:e,selectionRange:n},pp=!1,rl=t;rl!==null;)if(t=rl,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,rl=e;else for(;rl!==null;){switch(t=rl,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Vd(o,r,n),o[at]=e,vt(o),r=o;break a;case`link`:var s=Jf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=wr(s,h),v=wr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,F.T=null,n=pu,pu=null;var o=lu,s=du;if(cu=0,uu=lu=null,du=0,K&6)throw Error(i(331));var c=K;if(K|=4,Pl(o.current),El(o,o.current,s,n),K=c,ld(0,!1),Pe&&typeof Pe.onPostCommitFiberRoot==`function`)try{Pe.onPostCommitFiberRoot(Ne,o)}catch{}return!0}finally{I.p=a,F.T=r,Gu(e,t)}}function Ju(e,t,n){t=V(n,t),t=Xs(e.stateNode,t,2),e=za(e,t,2),e!==null&&(Ye(e,2),cd(e))}function Yu(e,t,n){if(e.tag===3)Ju(e,e,n);else for(;t!==null;){if(t.tag===3){Ju(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(su===null||!su.has(r))){e=V(n,e),n=Zs(2),r=za(t,n,2),r!==null&&(Qs(n,r,t,e),Ye(r,2),cd(r));break}}t=t.return}}function Xu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Rl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Kl=!0,i.add(n),e=Zu.bind(null,e,t,n),t.then(e,e))}function Zu(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,zl===e&&(Vl&n)===n&&(Jl===4||Jl===3&&(Vl&62914560)===Vl&&300>we()-ru?!(K&2)&&Eu(e,0):Zl|=n,$l===Vl&&($l=0)),cd(e)}function Qu(e,t){t===0&&(t=qe()),e=ti(e,t),e!==null&&(Ye(e,t),cd(e))}function $u(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Qu(e,n)}function ed(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),Qu(e,n)}function td(e,t){return be(e,t)}var nd=null,rd=null,id=!1,ad=!1,od=!1,sd=0;function cd(e){e!==rd&&e.next===null&&(rd===null?nd=rd=e:rd=rd.next=e),ad=!0,id||(id=!0,hd())}function ld(e,t){if(!od&&ad){od=!0;do for(var n=!1,r=nd;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ie(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,md(r,a))}else a=Vl,a=We(r,r===zl?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Ge(r,a)||(n=!0,md(r,a));r=r.next}while(n);od=!1}}function ud(){dd()}function dd(){ad=id=!1;var e=0;sd!==0&&Qd()&&(e=sd);for(var t=we(),n=null,r=nd;r!==null;){var i=r.next,a=fd(r,t);a===0?(r.next=null,n===null?nd=i:n.next=i,i===null&&(rd=n)):(n=r,(e!==0||a&3)&&(ad=!0)),r=i}cu!==0&&cu!==5||ld(e,!1),sd!==0&&(sd=0)}function fd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Ud(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function Of(e,t,n){var r=Df;if(r&&typeof t==`string`&&t){var i=Lt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),Sf.has(i)||(Sf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Vd(t,`link`,e),vt(t),r.head.appendChild(t)))}}function kf(e){wf.D(e),Of(`dns-prefetch`,e,null)}function Af(e,t){wf.C(e,t),Of(`preconnect`,e,t)}function jf(e,t,n){wf.L(e,t,n);var r=Df;if(r&&e&&t){var i=`link[rel="preload"][as="`+Lt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Lt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Lt(n.imageSizes)+`"]`)):i+=`[href="`+Lt(e)+`"]`;var a=i;switch(t){case`style`:a=Lf(e);break;case`script`:a=Vf(e)}xf.has(a)||(e=p({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),xf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(Rf(a))||t===`script`&&r.querySelector(Hf(a))||(t=r.createElement(`link`),Vd(t,`link`,e),vt(t),r.head.appendChild(t)))}}function Mf(e,t){wf.m(e,t);var n=Df;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Lt(r)+`"][href="`+Lt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Vf(e)}if(!xf.has(a)&&(e=p({rel:`modulepreload`,href:e},t),xf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Hf(a)))return}r=n.createElement(`link`),Vd(r,`link`,e),vt(r),n.head.appendChild(r)}}}function Nf(e,t,n){wf.S(e,t,n);var r=Df;if(r&&e){var i=_t(r).hoistableStyles,a=Lf(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(Rf(a)))s.loading=5;else{e=p({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=xf.get(a))&&Gf(e,n);var c=o=r.createElement(`link`);vt(c),Vd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Wf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Pf(e,t){wf.X(e,t);var n=Df;if(n&&e){var r=_t(n).hoistableScripts,i=Vf(e),a=r.get(i);a||(a=n.querySelector(Hf(i)),a||(e=p({src:e,async:!0},t),(t=xf.get(i))&&Kf(e,t),a=n.createElement(`script`),vt(a),Vd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Ff(e,t){wf.M(e,t);var n=Df;if(n&&e){var r=_t(n).hoistableScripts,i=Vf(e),a=r.get(i);a||(a=n.querySelector(Hf(i)),a||(e=p({src:e,async:!0,type:`module`},t),(t=xf.get(i))&&Kf(e,t),a=n.createElement(`script`),vt(a),Vd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function If(e,t,n,r){var a=(a=oe.current)?Cf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Lf(n.href),n=_t(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Lf(n.href);var o=_t(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(Rf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),xf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},xf.set(e,n),o||Bf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Vf(n),n=_t(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Lf(e){return`href="`+Lt(e)+`"`}function Rf(e){return`link[rel="stylesheet"][`+e+`]`}function zf(e){return p({},e,{"data-precedence":e.precedence,precedence:null})}function Bf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Vd(t,`link`,n),vt(t),e.head.appendChild(t))}function Vf(e){return`[src="`+Lt(e)+`"]`}function Hf(e){return`script[async]`+e}function Uf(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Lt(n.href)+`"]`);if(r)return t.instance=r,vt(r),r;var a=p({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),vt(r),Vd(r,`style`,a),Wf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Lf(n.href);var o=e.querySelector(Rf(a));if(o)return t.state.loading|=4,t.instance=o,vt(o),o;r=zf(n),(a=xf.get(a))&&Gf(r,a),o=(e.ownerDocument||e).createElement(`link`),vt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Vd(o,`link`,r),t.state.loading|=4,Wf(o,n.precedence,e),t.instance=o;case`script`:return o=Vf(n.src),(a=e.querySelector(Hf(o)))?(t.instance=a,vt(a),a):(r=n,(a=xf.get(o))&&(r=p({},n),Kf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),vt(a),Vd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Wf(r,n.precedence,e));return t.instance}function Wf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Xf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Zf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Qf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Lf(r.href),a=t.querySelector(Rf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=tp.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,vt(a);return}a=t.ownerDocument||t,r=zf(r),(i=xf.get(i))&&Gf(r,i),a=a.createElement(`link`),vt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Vd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=tp.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var $f=0;function ep(e,t){return e.stylesheets&&e.count===0&&rp(e,e.stylesheets),0$f?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function tp(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)rp(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var np=null;function rp(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,np=new Map,t.forEach(ip,e),np=null,tp.call(e))}function ip(e,t){if(!(t.state.loading&4)){var n=np.get(e);if(n)var r=n.get(null);else{n=new Map,np.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=_()})),y=l(f(),1),b=v(),x=`modulepreload`,S=function(e){return`/`+e},C={},w=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=S(t,n),t in C)return;C[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:x,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},T=`popstate`;function E(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function D(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return M(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:N(t)}return ee(t,n,null,e)}function O(e,t){if(e===!1||e==null)throw Error(t)}function k(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function A(){return Math.random().toString(36).substring(2,10)}function j(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.unstable_mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function M(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?P(t):t,state:n,key:t&&t.key||r||A(),unstable_mask:i}}function N({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function P(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function ee(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=E(e)?e:M(h.location,e,t);n&&n(r,e),l=u()+1;let d=j(r,l),f=h.createHref(r.unstable_mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=E(e)?e:M(h.location,e,t);n&&n(r,e),l=u();let i=j(r,l),d=h.createHref(r.unstable_mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return F(e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(T,d),c=e,()=>{i.removeEventListener(T,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function F(e,t=!1){let n=`http://localhost`;typeof window<`u`&&(n=window.location.origin===`null`?window.location.href:window.location.origin),O(n,`No window.location.(origin|href) available to create URL`);let r=typeof e==`string`?e:N(e);return r=r.replace(/ $/,`%20`),!t&&r.startsWith(`//`)&&(r=n+r),new URL(r,n)}function I(e,t,n=`/`){return te(e,t,n,!1)}function te(e,t,n,r){let i=ge((typeof t==`string`?P(t):t).pathname||`/`,n);if(i==null)return null;let a=L(e);R(a);let o=null;for(let e=0;o==null&&e{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;O(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=we([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(O(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),L(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:ue(l,e.index),routesMeta:u})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of re(e.path))a(e,t,!0,n)}),t}function re(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=re(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function R(e){e.sort((e,t)=>e.score===t.score?de(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var z=/^:[\w-]+$/,ie=3,ae=2,oe=1,se=10,ce=-2,le=e=>e===`*`;function ue(e,t){let n=e.split(`/`),r=n.length;return n.some(le)&&(r+=ce),t&&(r+=ae),n.filter(e=>!le(e)).reduce((e,t)=>e+(z.test(t)?ie:t===``?oe:se),r)}function de(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function fe(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function me(e,t=!1,n=!0){k(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function he(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return k(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function ge(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}var _e=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function ve(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?P(e):e,a;return n?(n=n.replace(/\/\/+/g,`/`),a=n.startsWith(`/`)?ye(n.substring(1),`/`):ye(n,t)):a=t,{pathname:a,search:Ee(r),hash:De(i)}}function ye(e,t){let n=t.replace(/\/+$/,``).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function be(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function xe(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function Se(e){let t=xe(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function Ce(e,t,n,r=!1){let i;typeof e==`string`?i=P(e):(i={...e},O(!i.pathname||!i.pathname.includes(`?`),be(`?`,`pathname`,`search`,i)),O(!i.pathname||!i.pathname.includes(`#`),be(`#`,`pathname`,`hash`,i)),O(!i.search||!i.search.includes(`#`),be(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=ve(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var we=e=>e.join(`/`).replace(/\/\/+/g,`/`),Te=e=>e.replace(/\/+$/,``).replace(/^\/*/,`/`),Ee=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,De=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,Oe=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function ke(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function Ae(e){return e.map(e=>e.route.path).filter(Boolean).join(`/`).replace(/\/\/*/g,`/`)||`/`}var je=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function Me(e,t){let n=e;if(typeof n!=`string`||!_e.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(je)try{let e=new URL(window.location.href),r=n.startsWith(`//`)?new URL(e.protocol+n):new URL(n),a=ge(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{k(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var Ne=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(Ne);var Pe=[`GET`,...Ne];new Set(Pe);var Fe=y.createContext(null);Fe.displayName=`DataRouter`;var Ie=y.createContext(null);Ie.displayName=`DataRouterState`;var Le=y.createContext(!1),Re=y.createContext({isTransitioning:!1});Re.displayName=`ViewTransition`;var ze=y.createContext(new Map);ze.displayName=`Fetchers`;var Be=y.createContext(null);Be.displayName=`Await`;var Ve=y.createContext(null);Ve.displayName=`Navigation`;var He=y.createContext(null);He.displayName=`Location`;var Ue=y.createContext({outlet:null,matches:[],isDataRoute:!1});Ue.displayName=`Route`;var We=y.createContext(null);We.displayName=`RouteError`;var Ge=`REACT_ROUTER_ERROR`,Ke=`REDIRECT`,qe=`ROUTE_ERROR_RESPONSE`;function Je(e){if(e.startsWith(`${Ge}:${Ke}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function Ye(e){if(e.startsWith(`${Ge}:${qe}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new Oe(t.status,t.statusText,t.data)}catch{}}function Xe(e,{relative:t}={}){O(Ze(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=y.useContext(Ve),{hash:i,pathname:a,search:o}=it(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:we([n,a])),r.createHref({pathname:s,search:o,hash:i})}function Ze(){return y.useContext(He)!=null}function Qe(){return O(Ze(),`useLocation() may be used only in the context of a component.`),y.useContext(He).location}var $e=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function et(e){y.useContext(Ve).static||y.useLayoutEffect(e)}function tt(){let{isDataRoute:e}=y.useContext(Ue);return e?Ct():nt()}function nt(){O(Ze(),`useNavigate() may be used only in the context of a component.`);let e=y.useContext(Fe),{basename:t,navigator:n}=y.useContext(Ve),{matches:r}=y.useContext(Ue),{pathname:i}=Qe(),a=JSON.stringify(Se(r)),o=y.useRef(!1);return et(()=>{o.current=!0}),y.useCallback((r,s={})=>{if(k(o.current,$e),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=Ce(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:we([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}y.createContext(null);function rt(){let{matches:e}=y.useContext(Ue),t=e[e.length-1];return t?t.params:{}}function it(e,{relative:t}={}){let{matches:n}=y.useContext(Ue),{pathname:r}=Qe(),i=JSON.stringify(Se(n));return y.useMemo(()=>Ce(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function at(e,t){return ot(e,t)}function ot(e,t,n){O(Ze(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=y.useContext(Ve),{matches:i}=y.useContext(Ue),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;Tt(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. -Please change the parent to .`)}let u=Qe(),d;if(t){let e=typeof t==`string`?P(t):t;O(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=I(e,{pathname:p});k(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),k(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let h=pt(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:we([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:we([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?y.createElement(He.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,unstable_mask:void 0,...d},navigationType:`POP`}},h):h}function st(){let e=St(),t=ke(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=y.createElement(y.Fragment,null,y.createElement(`p`,null,`💿 Hey developer 👋`),y.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,y.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,y.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),y.createElement(y.Fragment,null,y.createElement(`h2`,null,`Unexpected Application Error!`),y.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?y.createElement(`pre`,{style:i},n):null,o)}var ct=y.createElement(st,null),lt=class extends y.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=Ye(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:y.createElement(Ue.Provider,{value:this.props.routeContext},y.createElement(We.Provider,{value:e,children:this.props.component}));return this.context?y.createElement(dt,{error:e},t):t}};lt.contextType=Le;var ut=new WeakMap;function dt({children:e,error:t}){let{basename:n}=y.useContext(Ve);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=Je(t.digest);if(e){let r=ut.get(t);if(r)throw r;let i=Me(e.location,n);if(je&&!ut.get(t))if(i.isExternal||e.reloadDocument)window.location.href=i.absoluteURL||i.to;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw ut.set(t,n),n}return y.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${i.absoluteURL||i.to}`})}}return e}function ft({routeContext:e,match:t,children:n}){let r=y.useContext(Fe);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),y.createElement(Ue.Provider,{value:e},n)}function pt(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);O(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},unstable_pattern:Ae(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||ct,o&&(s<0&&c===0?(Tt(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?y.createElement(n.route.Component,null):n.route.element?n.route.element:e,y.createElement(ft,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?y.createElement(lt,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function mt(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function ht(e){let t=y.useContext(Fe);return O(t,mt(e)),t}function gt(e){let t=y.useContext(Ie);return O(t,mt(e)),t}function _t(e){let t=y.useContext(Ue);return O(t,mt(e)),t}function vt(e){let t=_t(e),n=t.matches[t.matches.length-1];return O(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function yt(){return vt(`useRouteId`)}function bt(){return gt(`useNavigation`).navigation}function xt(){let{matches:e,loaderData:t}=gt(`useMatches`);return y.useMemo(()=>e.map(e=>ne(e,t)),[e,t])}function St(){let e=y.useContext(We),t=gt(`useRouteError`),n=vt(`useRouteError`);return e===void 0?t.errors?.[n]:e}function Ct(){let{router:e}=ht(`useNavigate`),t=vt(`useNavigate`),n=y.useRef(!1);return et(()=>{n.current=!0}),y.useCallback(async(r,i={})=>{k(n.current,$e),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var wt={};function Tt(e,t,n){!t&&!wt[e]&&(wt[e]=!0,k(!1,n))}y.useOptimistic,y.memo(Et);function Et({routes:e,future:t,state:n,isStatic:r,onError:i}){return ot(e,void 0,{state:n,isStatic:r,onError:i,future:t})}function Dt(e){O(!1,`A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .`)}function Ot({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,unstable_useTransitions:o}){O(!Ze(),`You cannot render a inside another . You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=y.useMemo(()=>({basename:s,navigator:i,static:a,unstable_useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=P(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,unstable_mask:m}=n,h=y.useMemo(()=>{let e=ge(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,unstable_mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return k(h!=null,` is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the won't render anything.`),h==null?null:y.createElement(Ve.Provider,{value:c},y.createElement(He.Provider,{children:t,value:h}))}function kt({children:e,location:t}){return at(At(e),t)}y.Component;function At(e,t=[]){let n=[];return y.Children.forEach(e,(e,r)=>{if(!y.isValidElement(e))return;let i=[...t,r];if(e.type===y.Fragment){n.push.apply(n,At(e.props.children,i));return}O(e.type===Dt,`[${typeof e.type==`string`?e.type:e.type.name}] is not a component. All component children of must be a or `),O(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=At(e.props.children,i)),n.push(a)}),n}var jt=`get`,Mt=`application/x-www-form-urlencoded`;function Nt(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Pt(e){return Nt(e)&&e.tagName.toLowerCase()===`button`}function Ft(e){return Nt(e)&&e.tagName.toLowerCase()===`form`}function It(e){return Nt(e)&&e.tagName.toLowerCase()===`input`}function Lt(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Rt(e,t){return e.button===0&&(!t||t===`_self`)&&!Lt(e)}function zt(e=``){return new URLSearchParams(typeof e==`string`||Array.isArray(e)||e instanceof URLSearchParams?e:Object.keys(e).reduce((t,n)=>{let r=e[n];return t.concat(Array.isArray(r)?r.map(e=>[n,e]):[[n,r]])},[]))}function Bt(e,t){let n=zt(e);return t&&t.forEach((e,r)=>{n.has(r)||t.getAll(r).forEach(e=>{n.append(r,e)})}),n}var Vt=null;function Ht(){if(Vt===null)try{new FormData(document.createElement(`form`),0),Vt=!1}catch{Vt=!0}return Vt}var Ut=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function Wt(e){return e!=null&&!Ut.has(e)?(k(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Mt}"`),null):e}function Gt(e,t){let n,r,i,a,o;if(Ft(e)){let o=e.getAttribute(`action`);r=o?ge(o,t):null,n=e.getAttribute(`method`)||jt,i=Wt(e.getAttribute(`enctype`))||Mt,a=new FormData(e)}else if(Pt(e)||It(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a