"""ORB Paper Trading Engine. One engine instance is created per session per trading day. The scheduler calls phase methods in order: 1. run_pre_screen() — 09:20 ET (daily bars + enrichment + quality filter) 2. run_orb_detection() — 09:40 ET (intraday bars → candidates) └─ run_pre_screen 미실행 시 full fallback (daily bars도 자체 fetch) 3. run_breakout_check() — every sim_bar_minutes from orb_end until order_timeout 4. run_stop_check() — every sim_bar_minutes from orb_end until 15:55 ET 5. run_eod_exit() — 15:55 ET 6. run_post_close() — 16:00 ET Stop management logic mirrors orb_simulator.py:477-580 exactly. """ from __future__ import annotations import copy import datetime as dt import logging import time import uuid from pathlib import Path from typing import Any from zoneinfo import ZoneInfo from apps.orb_trader.models import ( ORBCandidateRow, ORBDailySnapshotRow, ORBPositionRow, ORBTradeRow, ) from apps.orb_trader.screener import ( bars_to_enrichment_format, intraday_bars_to_format, live_pre_screen, load_universe, ) from apps.orb_trader.state import ORBStateManager from libs.intraday.features import enrich_daily_bars from libs.intraday.orb_simulator import ( _aggregate_bars, _bar_close_location, _bar_return_pct, _first_regular_bar, _linear_range_scaler, _linear_scaler, _opening_breadth_stats, compute_orb_candidates, ) from libs.intraday.simulator import _parse_ts, filter_market_hours from libs.oracle_client.alpaca import get_snapshots log = logging.getLogger(__name__) # Structured logger — events flow to journal/events.db via libs.common.logging # sink processor (configured by the daemon at startup). Used at trade/error # sites so the Logs/Health UI gets ticker, fill_price, qty, etc. as fields, # not embedded in a free-text message. try: from libs.common.logging import get_logger as _get_struct_logger structured_log = _get_struct_logger("apps.orb_trader.engine") except Exception: # pragma: no cover — defensive; structlog should always import structured_log = None # type: ignore[assignment] def _emit(event: str, **fields: Any) -> None: """Emit a structured event if structlog is available; no-op otherwise.""" if structured_log is None: return level = fields.pop("_level", "info") try: getattr(structured_log, level)(event, **fields) except Exception: pass _ET = ZoneInfo("America/New_York") _ACCOUNT_CIRCUIT_BREAKER_PCT = 25.0 # halt if equity drops >25% from peak class ORBTradingEngine: """Intraday paper trading engine for the ORB strategy. Holds per-day state (enrichment, candidates, date_str) as instance variables. On server restart mid-day, state is reconstructed from the DB. """ def __init__( self, session: Any, broker: Any, state: ORBStateManager, params: Any, log_callback: Any = None, ) -> None: self._session = session self._broker = broker self._state = state # Keep live execution overrides local to this engine instance. Strategy # filters must remain identical to the backtest config; otherwise a # session named "v49.86" can make different candidate decisions live. self._params = params.model_copy(deep=True) if hasattr(params, "model_copy") else copy.copy(params) self._log_callback = log_callback # optional scheduler._log for UI visibility # Live execution details. These affect paper/live order handling, not # strategy candidate selection. self._params.settlement_days = 0 # paper trading; no real T+1 settlement self._params.slippage_bps = 0.0 # real fills, no simulated slippage # Per-day in-memory state (reset each day) self._date_str: str = "" self._enrichment: dict[str, dict[str, dict]] = {} self._daily_bars: dict[str, list[dict]] = {} self._candidates: list[dict] = [] # computed by run_orb_detection self._pending_cands: list[dict] = [] # not yet filled (for breakout checks) # Pre-screened tickers: None = pre_screen not yet run, [] = ran but nothing passed self._pre_screened_tickers: list[str] | None = None self._day_size_scale: float = 1.0 self._soft_day_reason: str | None = None self._market_orb_quality_max_trades: int | None = None self._market_orb_quality_reason: str | None = None self._market_thrust_breadth_override_active: bool = False self._market_thrust_index_breadth_override_active: bool = False self._market_thrust_opening_breadth_override_active: bool = False def _get_equity(self) -> float: """Current equity = last snapshot equity, or initial if no snapshots.""" eq = self._state.get_equity(self._session.session_id) return eq if eq is not None else self._session.initial_equity @staticmethod def _last_trading_day(ref: dt.date) -> dt.date: """Return the most recent weekday strictly before ref. Used for daily-bar end_date: today's bar is incomplete during market hours, and weekend dates cause Alpaca to return 502 Bad Gateway. """ d = ref - dt.timedelta(days=1) while d.weekday() >= 5: # 5=Sat, 6=Sun d -= dt.timedelta(days=1) return d def _log(self, msg: str) -> None: log.info("[ORB:%s] %s", self._session.session_name, msg) if self._log_callback is not None: try: self._log_callback(f" [engine] {msg}") except Exception: pass def _market_context_tickers(self) -> list[str]: """Tickers needed for live regime/quality gates even if not trade candidates.""" tickers: list[str] = [] regime_ticker = getattr(self._params, "market_regime_ticker", None) or "QQQ" tickers.append(regime_ticker) for attr in ( "market_regime_gap_ticker", "market_orb_quality_ticker", "market_orb_quality_secondary_ticker", ): ticker = getattr(self._params, attr, None) if ticker: tickers.append(str(ticker)) return list(dict.fromkeys(t.upper() for t in tickers if t)) def _with_market_context_tickers(self, tickers: list[str]) -> list[str]: """Fetch market context bars without expanding the tradable universe.""" return list(dict.fromkeys([*self._market_context_tickers(), *tickers])) def _reset_day_context(self) -> None: self._day_size_scale = 1.0 self._soft_day_reason = None self._market_orb_quality_max_trades = None self._market_orb_quality_reason = None self._market_thrust_breadth_override_active = False self._market_thrust_index_breadth_override_active = False self._market_thrust_opening_breadth_override_active = False @staticmethod def _copy_params_with_updates(params: Any, updates: dict[str, Any]) -> Any: """Return a params copy with updates for parity scans.""" if not updates: return params if hasattr(params, "model_copy"): return params.model_copy(update=updates) copied = copy.copy(params) for key, value in updates.items(): setattr(copied, key, value) return copied @staticmethod def _meets_min(value: float | None, threshold: Any) -> bool: return threshold is None or (value is not None and value >= float(threshold)) def _apply_market_thrust_breadth_override( self, bars_by_ticker: dict[str, list[dict]], date_str: str, *, soft_day_reason_parts: list[str], regime_gap_pct: float | None, breadth_ratio: float | None, breadth_scaler: float, ) -> tuple[float, list[str]]: """Mirror the backtest gate that enables market-thrust auxiliary sleeves.""" index_override_enabled = bool( getattr(self._params, "market_thrust_breadth_override_enabled", False) ) opening_breadth_override_enabled = bool( getattr(self._params, "market_thrust_opening_breadth_override_enabled", False) ) if not (index_override_enabled or opening_breadth_override_enabled): self._market_thrust_breadth_override_active = False self._market_thrust_index_breadth_override_active = False self._market_thrust_opening_breadth_override_active = False return breadth_scaler, soft_day_reason_parts market_open = dt.datetime.fromisoformat(f"{date_str}T09:30:00").replace(tzinfo=_ET) primary_ticker = ( getattr(self._params, "market_orb_quality_ticker", None) or getattr(self._params, "market_regime_ticker", None) or "SPY" ) secondary_ticker = getattr(self._params, "market_orb_quality_secondary_ticker", None) primary_bar = _first_regular_bar(bars_by_ticker.get(str(primary_ticker), []), market_open) secondary_bar = ( _first_regular_bar(bars_by_ticker.get(str(secondary_ticker), []), market_open) if secondary_ticker else None ) primary_close_loc = _bar_close_location(primary_bar) secondary_close_loc = _bar_close_location(secondary_bar) primary_ret = _bar_return_pct(primary_bar) secondary_ret = _bar_return_pct(secondary_bar) quality_blocks_thrust = self._market_quality_blocks_thrust( primary_close_loc, secondary_close_loc, ) active = ( index_override_enabled and "breadth" in soft_day_reason_parts and "hard_breadth" not in soft_day_reason_parts and not quality_blocks_thrust and self._meets_min( primary_close_loc, getattr( self._params, "market_thrust_breadth_override_min_primary_close_location", None, ), ) and self._meets_min( secondary_close_loc, getattr( self._params, "market_thrust_breadth_override_min_secondary_close_location", None, ), ) and self._meets_min( primary_ret, getattr( self._params, "market_thrust_breadth_override_min_primary_return_pct", None, ), ) and self._meets_min( secondary_ret, getattr( self._params, "market_thrust_breadth_override_min_secondary_return_pct", None, ), ) and self._meets_min( regime_gap_pct, getattr(self._params, "market_thrust_breadth_override_min_regime_gap_pct", None), ) and self._meets_min( breadth_ratio, getattr(self._params, "market_thrust_breadth_override_min_breadth_ratio", None), ) ) opening_breadth_active = False if opening_breadth_override_enabled: opening_reason_allowed = bool(soft_day_reason_parts) and ( ( "market_regime" in soft_day_reason_parts and bool( getattr( self._params, "market_thrust_opening_breadth_override_allow_regime_soft_day", True, ) ) ) or ( "breadth" in soft_day_reason_parts and bool( getattr( self._params, "market_thrust_opening_breadth_override_allow_breadth_soft_day", True, ) ) ) or ( "hard_breadth" in soft_day_reason_parts and bool( getattr( self._params, "market_thrust_opening_breadth_override_allow_hard_breadth", False, ) ) ) ) opening_stats = _opening_breadth_stats( bars_by_ticker, date_str, min_first_bar_dollar_vol=getattr( self._params, "market_thrust_opening_breadth_override_min_first_bar_dollar_vol", None, ), strong_close_location=float( getattr( self._params, "market_thrust_opening_breadth_override_strong_close_location", 0.65, ) or 0.65 ), ) opening_total_count = int(opening_stats.get("total_count") or 0) opening_positive_ratio = opening_stats.get("positive_ratio") opening_avg_return = opening_stats.get("avg_return_pct") opening_strong_ratio = opening_stats.get("strong_close_location_ratio") opening_breadth_active = ( opening_reason_allowed and opening_total_count >= int( getattr( self._params, "market_thrust_opening_breadth_override_min_total_count", 100, ) or 0 ) and self._meets_min( opening_positive_ratio, getattr( self._params, "market_thrust_opening_breadth_override_min_positive_ratio", None, ), ) and self._meets_min( opening_avg_return, getattr( self._params, "market_thrust_opening_breadth_override_min_avg_return_pct", None, ), ) and self._meets_min( opening_strong_ratio, getattr( self._params, "market_thrust_opening_breadth_override_min_strong_close_location_ratio", None, ), ) ) self._log( "Opening-breadth thrust override: " f"active={opening_breadth_active}, count={opening_total_count}, " f"positive={opening_positive_ratio if opening_positive_ratio is not None else 'NA'}, " f"avg_ret={opening_avg_return if opening_avg_return is not None else 'NA'}, " f"strong_close={opening_strong_ratio if opening_strong_ratio is not None else 'NA'}" ) self._market_thrust_index_breadth_override_active = active self._market_thrust_opening_breadth_override_active = opening_breadth_active self._market_thrust_breadth_override_active = active or opening_breadth_active if active: floor = max( 0.0, float( getattr(self._params, "market_thrust_breadth_override_size_scale_floor", 1.0) or 0.0 ), ) breadth_scaler = max(breadth_scaler, floor) if ( bool(getattr(self._params, "market_thrust_breadth_override_clear_soft_day", False)) and breadth_scaler >= getattr(self._params, "soft_day_scaler_threshold", 1.0) ): soft_day_reason_parts = [ reason for reason in soft_day_reason_parts if reason != "breadth" ] if opening_breadth_active: regime_floor = max( 0.0, float( getattr( self._params, "market_thrust_opening_breadth_override_regime_size_scale_floor", 1.0, ) or 0.0 ), ) breadth_floor = max( 0.0, float( getattr( self._params, "market_thrust_opening_breadth_override_breadth_size_scale_floor", 1.0, ) or 0.0 ), ) # The caller has already applied regime_scaler, so live can only lift # breadth sizing here. Backtest parity for regime-floor lifting is # handled before combined sizing in the simulator. if regime_floor > 0: breadth_scaler = max(breadth_scaler, min(regime_floor, breadth_floor)) else: breadth_scaler = max(breadth_scaler, breadth_floor) if getattr(self._params, "market_thrust_opening_breadth_override_clear_soft_day", False): soft_day_reason_parts = [ reason for reason in soft_day_reason_parts if not ( ( reason == "market_regime" and getattr( self._params, "market_thrust_opening_breadth_override_allow_regime_soft_day", True, ) ) or ( reason == "breadth" and getattr( self._params, "market_thrust_opening_breadth_override_allow_breadth_soft_day", True, ) ) or ( reason == "hard_breadth" and getattr( self._params, "market_thrust_opening_breadth_override_allow_hard_breadth", False, ) ) ) ] self._log( "Market thrust breadth override: " f"active={active}, {primary_ticker} close_loc={primary_close_loc if primary_close_loc is not None else 'NA'} " f"ret={primary_ret if primary_ret is not None else 'NA'}, " f"{secondary_ticker or '-'} close_loc={secondary_close_loc if secondary_close_loc is not None else 'NA'} " f"ret={secondary_ret if secondary_ret is not None else 'NA'}, " f"breadth={breadth_ratio if breadth_ratio is not None else 'NA'}, " f"regime_gap={regime_gap_pct if regime_gap_pct is not None else 'NA'}" ) return breadth_scaler, soft_day_reason_parts def _market_quality_blocks_thrust( self, primary_close_loc: float | None, secondary_close_loc: float | None, ) -> bool: """Backtest market-thrust override is blocked by defensive quality states.""" primary_strong_above = getattr(self._params, "market_orb_quality_primary_strong_above", None) secondary_weak_below = getattr(self._params, "market_orb_quality_secondary_weak_below", None) secondary_weak_above = getattr(self._params, "market_orb_quality_secondary_weak_above", None) divergence = ( primary_strong_above is not None and secondary_weak_below is not None and primary_close_loc is not None and secondary_close_loc is not None and primary_close_loc >= primary_strong_above and secondary_close_loc <= secondary_weak_below and (secondary_weak_above is None or secondary_close_loc >= secondary_weak_above) ) primary_weak_below = getattr(self._params, "market_orb_quality_primary_weak_below", None) primary_weak_above = getattr(self._params, "market_orb_quality_primary_weak_above", None) secondary_strong_above = getattr(self._params, "market_orb_quality_secondary_strong_above", None) primary_weak_secondary_strong = ( primary_weak_below is not None and secondary_strong_above is not None and primary_close_loc is not None and secondary_close_loc is not None and primary_close_loc <= primary_weak_below and (primary_weak_above is None or primary_close_loc >= primary_weak_above) and secondary_close_loc >= secondary_strong_above ) primary_lag_above = getattr(self._params, "market_orb_quality_primary_lag_above", None) primary_lag_below = getattr(self._params, "market_orb_quality_primary_lag_below", None) secondary_lead_above = getattr(self._params, "market_orb_quality_secondary_lead_above", None) secondary_lead_below = getattr(self._params, "market_orb_quality_secondary_lead_below", None) primary_lag_secondary_lead = ( primary_lag_below is not None and secondary_lead_above is not None and primary_close_loc is not None and secondary_close_loc is not None and primary_close_loc <= primary_lag_below and (primary_lag_above is None or primary_close_loc >= primary_lag_above) and secondary_close_loc >= secondary_lead_above and (secondary_lead_below is None or secondary_close_loc <= secondary_lead_below) ) joint_weak_primary_below = getattr(self._params, "market_orb_quality_joint_weak_primary_below", None) joint_weak_primary_above = getattr(self._params, "market_orb_quality_joint_weak_primary_above", None) joint_weak_secondary_below = getattr(self._params, "market_orb_quality_joint_weak_secondary_below", None) joint_weak_secondary_above = getattr(self._params, "market_orb_quality_joint_weak_secondary_above", None) joint_weak = ( joint_weak_primary_below is not None and joint_weak_secondary_below is not None and primary_close_loc is not None and secondary_close_loc is not None and primary_close_loc <= joint_weak_primary_below and (joint_weak_primary_above is None or primary_close_loc >= joint_weak_primary_above) and secondary_close_loc <= joint_weak_secondary_below and (joint_weak_secondary_above is None or secondary_close_loc >= joint_weak_secondary_above) ) joint_panic_primary_below = getattr(self._params, "market_orb_quality_joint_panic_primary_below", None) joint_panic_secondary_below = getattr(self._params, "market_orb_quality_joint_panic_secondary_below", None) joint_panic = ( joint_panic_primary_below is not None and joint_panic_secondary_below is not None and primary_close_loc is not None and secondary_close_loc is not None and primary_close_loc <= joint_panic_primary_below and secondary_close_loc <= joint_panic_secondary_below ) return bool( divergence or primary_weak_secondary_strong or primary_lag_secondary_lead or joint_weak or joint_panic ) def _apply_market_orb_quality( self, bars_by_ticker: dict[str, list[dict]], date_str: str, ) -> None: """Mirror the backtest market-ORB-quality day scaler in live trading.""" use_quality = any( getattr(self._params, attr, None) is not None for attr in ( "market_orb_quality_size_scale_low", "market_orb_quality_size_scale_high", "market_orb_quality_primary_strong_above", "market_orb_quality_secondary_weak_above", "market_orb_quality_secondary_weak_below", "market_orb_quality_divergence_scale", "market_orb_quality_primary_weak_below", "market_orb_quality_primary_weak_above", "market_orb_quality_secondary_strong_above", "market_orb_quality_primary_weak_secondary_strong_scale", "market_orb_quality_primary_lag_above", "market_orb_quality_primary_lag_below", "market_orb_quality_secondary_lead_above", "market_orb_quality_secondary_lead_below", "market_orb_quality_primary_lag_secondary_lead_scale", "market_orb_quality_joint_weak_primary_below", "market_orb_quality_joint_weak_primary_above", "market_orb_quality_joint_weak_secondary_below", "market_orb_quality_joint_weak_secondary_above", "market_orb_quality_joint_weak_scale", "market_orb_quality_joint_panic_primary_below", "market_orb_quality_joint_panic_secondary_below", "market_orb_quality_joint_panic_scale", ) ) if not use_quality: return market_open = dt.datetime.fromisoformat(f"{date_str}T09:30:00").replace(tzinfo=_ET) primary_ticker = ( getattr(self._params, "market_orb_quality_ticker", None) or getattr(self._params, "market_regime_ticker", None) or "SPY" ) secondary_ticker = getattr(self._params, "market_orb_quality_secondary_ticker", None) primary_bar = _first_regular_bar(bars_by_ticker.get(str(primary_ticker), []), market_open) secondary_bar = ( _first_regular_bar(bars_by_ticker.get(str(secondary_ticker), []), market_open) if secondary_ticker else None ) primary_close_loc = _bar_close_location(primary_bar) secondary_close_loc = _bar_close_location(secondary_bar) primary_ret = _bar_return_pct(primary_bar) secondary_ret = _bar_return_pct(secondary_bar) scaler = 1.0 reasons: list[str] = [] max_trades: int | None = None low = getattr(self._params, "market_orb_quality_size_scale_low", None) high = getattr(self._params, "market_orb_quality_size_scale_high", None) if low is not None and high is not None: scaler *= _linear_range_scaler( primary_close_loc, low, high, getattr(self._params, "market_orb_quality_size_scale_min", 1.0), getattr(self._params, "market_orb_quality_size_scale_max", 1.0), ) def _cap_trades(raw: Any) -> None: nonlocal max_trades if raw is None: return cap = max(0, int(raw)) max_trades = cap if max_trades is None else min(max_trades, cap) primary_strong_above = getattr(self._params, "market_orb_quality_primary_strong_above", None) secondary_weak_above = getattr(self._params, "market_orb_quality_secondary_weak_above", None) secondary_weak_below = getattr(self._params, "market_orb_quality_secondary_weak_below", None) divergence = ( primary_strong_above is not None and secondary_weak_above is not None and primary_close_loc is not None and secondary_close_loc is not None and primary_close_loc >= primary_strong_above and secondary_close_loc >= secondary_weak_above and (secondary_weak_below is None or secondary_close_loc <= secondary_weak_below) ) if divergence: scale = getattr(self._params, "market_orb_quality_divergence_scale", None) if scale is not None: scaler *= max(0.0, float(scale)) _cap_trades(getattr(self._params, "market_orb_quality_divergence_max_trades", None)) reasons.append("divergence") primary_weak_below = getattr(self._params, "market_orb_quality_primary_weak_below", None) primary_weak_above = getattr(self._params, "market_orb_quality_primary_weak_above", None) secondary_strong_above = getattr(self._params, "market_orb_quality_secondary_strong_above", None) primary_weak_secondary_strong = ( primary_weak_below is not None and secondary_strong_above is not None and primary_close_loc is not None and secondary_close_loc is not None and primary_close_loc <= primary_weak_below and (primary_weak_above is None or primary_close_loc >= primary_weak_above) and secondary_close_loc >= secondary_strong_above ) if primary_weak_secondary_strong: scale = getattr(self._params, "market_orb_quality_primary_weak_secondary_strong_scale", None) if scale is not None: scaler *= max(0.0, float(scale)) _cap_trades(getattr(self._params, "market_orb_quality_primary_weak_secondary_strong_max_trades", None)) reasons.append("primary_weak_secondary_strong") primary_lag_above = getattr(self._params, "market_orb_quality_primary_lag_above", None) primary_lag_below = getattr(self._params, "market_orb_quality_primary_lag_below", None) secondary_lead_above = getattr(self._params, "market_orb_quality_secondary_lead_above", None) secondary_lead_below = getattr(self._params, "market_orb_quality_secondary_lead_below", None) primary_lag_secondary_lead = ( primary_lag_below is not None and secondary_lead_above is not None and primary_close_loc is not None and secondary_close_loc is not None and primary_close_loc <= primary_lag_below and (primary_lag_above is None or primary_close_loc >= primary_lag_above) and secondary_close_loc >= secondary_lead_above and (secondary_lead_below is None or secondary_close_loc <= secondary_lead_below) ) if primary_lag_secondary_lead: scale = getattr(self._params, "market_orb_quality_primary_lag_secondary_lead_scale", None) if scale is not None: scaler *= max(0.0, float(scale)) _cap_trades(getattr(self._params, "market_orb_quality_primary_lag_secondary_lead_max_trades", None)) reasons.append("primary_lag_secondary_lead") joint_weak_primary_below = getattr(self._params, "market_orb_quality_joint_weak_primary_below", None) joint_weak_primary_above = getattr(self._params, "market_orb_quality_joint_weak_primary_above", None) joint_weak_secondary_below = getattr(self._params, "market_orb_quality_joint_weak_secondary_below", None) joint_weak_secondary_above = getattr(self._params, "market_orb_quality_joint_weak_secondary_above", None) joint_weak = ( joint_weak_primary_below is not None and joint_weak_secondary_below is not None and primary_close_loc is not None and secondary_close_loc is not None and primary_close_loc <= joint_weak_primary_below and (joint_weak_primary_above is None or primary_close_loc >= joint_weak_primary_above) and secondary_close_loc <= joint_weak_secondary_below and (joint_weak_secondary_above is None or secondary_close_loc >= joint_weak_secondary_above) ) if joint_weak: scale = getattr(self._params, "market_orb_quality_joint_weak_scale", None) if scale is not None: scaler *= max(0.0, float(scale)) _cap_trades(getattr(self._params, "market_orb_quality_joint_weak_max_trades", None)) reasons.append("joint_weak") joint_panic_primary_below = getattr(self._params, "market_orb_quality_joint_panic_primary_below", None) joint_panic_secondary_below = getattr(self._params, "market_orb_quality_joint_panic_secondary_below", None) joint_panic = ( joint_panic_primary_below is not None and joint_panic_secondary_below is not None and primary_close_loc is not None and secondary_close_loc is not None and primary_close_loc <= joint_panic_primary_below and secondary_close_loc <= joint_panic_secondary_below ) if joint_panic: scale = getattr(self._params, "market_orb_quality_joint_panic_scale", None) if scale is not None: scaler *= max(0.0, float(scale)) _cap_trades(getattr(self._params, "market_orb_quality_joint_panic_max_trades", None)) reasons.append("joint_panic") self._day_size_scale *= max(0.0, scaler) self._market_orb_quality_max_trades = max_trades self._market_orb_quality_reason = "+".join(reasons) if reasons else None self._log( "Market ORB quality: " f"{primary_ticker} close_loc={primary_close_loc if primary_close_loc is not None else 'NA'} " f"ret={primary_ret if primary_ret is not None else 'NA'}, " f"{secondary_ticker or '-'} close_loc={secondary_close_loc if secondary_close_loc is not None else 'NA'} " f"ret={secondary_ret if secondary_ret is not None else 'NA'}, " f"scale={scaler:.3f}, max_trades={max_trades}, " f"reason={self._market_orb_quality_reason or '-'}" ) # ── Phase 1: Pre-market Screening (daily bars + enrichment + quality filter) ─ def run_pre_screen(self, date_str: str) -> dict[str, Any]: """Pre-market screening: fetch daily bars, compute enrichment, filter universe. Called at ~9:20 ET (before market open). Narrows the universe from ~971 to ~250-350 tickers using quality filters (price, ATR, dollar volume). The expensive intraday bar fetch in run_orb_detection() then only fetches data for pre-screened tickers. If this method is never called (late start, failure), run_orb_detection() falls back to the full pipeline automatically. Returns summary dict. """ self._date_str = date_str self._state.update_daily_state( self._session.session_id, date_str, phase="pre_screen" ) # Cheap Oracle health probe — warns early so the operator can restart Oracle # before the bar-fetch loop (5 chunks × 15s timeout) burns 75s silently. try: import httpx as _httpx from libs.oracle_client.alpaca import _base_url as _oracle_base_url _oracle_health_url = _oracle_base_url() + "/api/v1/health" _r = _httpx.get(_oracle_health_url, timeout=3.0) _ok = _r.status_code < 400 except Exception: _ok = False if not _ok: self._log( "CRITICAL: Oracle is unreachable — bar fetches will likely fail. " "Start Oracle before 09:20 ET on live trading days." ) _emit( "orb_engine_oracle_unreachable", _level="error", session_id=self._session.session_id, session_name=self._session.session_name, ) universe_source = getattr(self._params, "_universe_source", "midlarge") universe_symbols_file = getattr(self._params, "_universe_symbols_file", None) tickers = load_universe(universe_source, universe_symbols_file) # Always include the regime ticker so regime/breadth filters have data regime_ticker = getattr(self._params, "market_regime_ticker", None) or "QQQ" fetch_tickers = list(dict.fromkeys([regime_ticker] + tickers)) # deduplicate, regime first self._log(f"Pre-screen: {len(tickers)} tickers (+{regime_ticker}) — fetching daily bars") today = dt.date.fromisoformat(date_str) bars_end = self._last_trading_day(today) start = bars_end - dt.timedelta(days=65) raw_bars: dict[str, list] = {} chunk_size = 200 for i in range(0, len(fetch_tickers), chunk_size): chunk = fetch_tickers[i : i + chunk_size] try: raw_bars.update(self._broker.get_bars(chunk, start, bars_end)) except Exception as e: self._log(f" WARNING: daily bars chunk {i//chunk_size+1} failed ({e}) — skipping") _emit( "orb_engine_bars_chunk_failed", _level="warning", session_id=self._session.session_id, chunk_kind="daily", chunk_idx=i // chunk_size + 1, error=str(e), ) daily_bars_dict = bars_to_enrichment_format(raw_bars) # Add synthetic today row so enrich_daily_bars() produces entries for date_str for sym, bars in daily_bars_dict.items(): if bars: last = bars[-1] if last["date"] < date_str: daily_bars_dict[sym] = bars + [{ "date": date_str, "open": last["close"], "high": last["close"], "low": last["close"], "close": last["close"], "volume": 0, }] self._enrichment = enrich_daily_bars(daily_bars_dict, [date_str]) self._daily_bars = daily_bars_dict qualified = live_pre_screen(self._enrichment, date_str, self._params) self._pre_screened_tickers = qualified daily_bars_count = len([s for s, b in raw_bars.items() if b]) self._log( f"Pre-screen 완료: {daily_bars_count} daily bars → " f"{len(qualified)} qualified (전체 {len(tickers)}개 중)" ) return { "universe_size": len(tickers), "daily_bars": daily_bars_count, "pre_screened": len(qualified), } # ── Phase 2: ORB Detection (intraday bars → candidates) ────────────────── def run_orb_detection(self, date_str: str) -> dict[str, Any]: """Fetch 5-min ORB bars for (pre-screened or full) universe, then compute candidates. Called once at 9:30 + orb_minutes (e.g., 9:40 for a 10-min ORB). If run_pre_screen() was called earlier, uses cached enrichment and pre-screened ticker list (skips daily bars fetch). Otherwise runs the full pipeline as a fallback. Returns summary dict. """ self._date_str = date_str self._reset_day_context() self._state.update_daily_state( self._session.session_id, date_str, phase="orb_detection" ) # Rolling loss filter: skip day if recent N-day equity return is below threshold roll_days = getattr(self._params, "rolling_loss_days", None) roll_thresh = getattr(self._params, "rolling_loss_threshold", None) if roll_days is not None and roll_thresh is not None: snapshots = self._state.list_snapshots(self._session.session_id) past = [s for s in snapshots if s["date"] < date_str] if len(past) >= roll_days: window = past[-roll_days:] rolling_pnl = sum(s["daily_pnl"] for s in window) sizing_base = self._session.initial_equity # daily_budget_reset mode if sizing_base > 0 and rolling_pnl / sizing_base < roll_thresh: self._log( f"Rolling loss filter triggered ({rolling_pnl/sizing_base:.2%} " f"< {roll_thresh:.2%}) — skipping today" ) self._state.update_daily_state( self._session.session_id, date_str, phase="done" ) return { "universe_size": 0, "daily_bars": 0, "intraday_bars": 0, "orb_candidates": 0, "long": 0, "short": 0, "skip_reason": "rolling_loss", } # Account-level circuit breaker: halt if equity has dropped >25% from peak equity_now = self._get_equity() peak_eq = self._state.get_peak_equity( self._session.session_id, self._session.initial_equity ) if peak_eq > 0: account_dd_pct = (peak_eq - equity_now) / peak_eq * 100 if account_dd_pct >= _ACCOUNT_CIRCUIT_BREAKER_PCT: self._log( f"CIRCUIT BREAKER: account drawdown {account_dd_pct:.1f}% " f">= {_ACCOUNT_CIRCUIT_BREAKER_PCT}% — session halted" ) _emit( "orb_engine_circuit_breaker", _level="error", session_id=self._session.session_id, drawdown_pct=round(float(account_dd_pct), 2), threshold_pct=_ACCOUNT_CIRCUIT_BREAKER_PCT, peak_equity=round(float(peak_eq), 2), current_equity=round(float(equity_now), 2), ) self._state.set_session_status(self._session.session_id, "paused") self._state.update_daily_state( self._session.session_id, date_str, phase="done" ) return { "universe_size": 0, "daily_bars": 0, "intraday_bars": 0, "orb_candidates": 0, "long": 0, "short": 0, "skip_reason": "circuit_breaker", } # ── Determine intraday_tickers: use pre-screen cache or fetch daily bars ─ if self._enrichment and self._pre_screened_tickers is not None: # Pre-screen already ran — skip daily bars fetch candidate_tickers = list(self._pre_screened_tickers) candidate_ticker_set = set(candidate_tickers) intraday_tickers = self._with_market_context_tickers(candidate_tickers) daily_bars_count = len([s for s, b in self._daily_bars.items() if b]) self._log( f"Pre-screened universe 사용: {len(candidate_tickers)} tickers " f"(daily bars 캐시됨)" ) else: # Fallback: full pipeline (pre_screen missed or failed) universe_source = getattr(self._params, "_universe_source", "midlarge") universe_symbols_file = getattr(self._params, "_universe_symbols_file", None) tickers = load_universe(universe_source, universe_symbols_file) regime_ticker = getattr(self._params, "market_regime_ticker", None) or "QQQ" fetch_tickers = list(dict.fromkeys([regime_ticker] + tickers)) self._log(f"Universe: {len(tickers)} tickers (+{regime_ticker}) — fetching daily bars") today = dt.date.fromisoformat(date_str) bars_end = self._last_trading_day(today) start = bars_end - dt.timedelta(days=65) raw_bars: dict[str, list] = {} chunk_size = 200 for i in range(0, len(fetch_tickers), chunk_size): chunk = fetch_tickers[i : i + chunk_size] try: raw_bars.update(self._broker.get_bars(chunk, start, bars_end)) except Exception as e: self._log(f" WARNING: daily bars chunk {i//chunk_size+1} failed ({e}) — skipping") daily_bars_dict = bars_to_enrichment_format(raw_bars) # Add synthetic today row (yesterday's close as placeholder) so # enrich_daily_bars() produces a keyed entry for date_str for sym, bars in daily_bars_dict.items(): if bars: last = bars[-1] if last["date"] < date_str: daily_bars_dict[sym] = bars + [{ "date": date_str, "open": last["close"], "high": last["close"], "low": last["close"], "close": last["close"], "volume": 0, }] self._enrichment = enrich_daily_bars(daily_bars_dict, [date_str]) self._daily_bars = daily_bars_dict daily_bars_count = len([s for s, b in raw_bars.items() if b]) candidate_tickers = list(tickers) candidate_ticker_set = set(candidate_tickers) intraday_tickers = self._with_market_context_tickers(candidate_tickers) context_added = len(intraday_tickers) - len(candidate_ticker_set) if context_added > 0: self._log( f"Market context bars 포함: {context_added} tickers " f"({', '.join(self._market_context_tickers())})" ) # ── Fetch 5-min intraday bars for (pre-screened or full) universe ────── today = dt.date.fromisoformat(date_str) market_open = dt.datetime(today.year, today.month, today.day, 9, 30, tzinfo=_ET) orb_end = market_open + dt.timedelta(minutes=self._params.orb_minutes + 5) fetch_end = dt.datetime.now(_ET).replace(second=0, microsecond=0) if fetch_end < orb_end: fetch_end = orb_end intraday_raw: dict[str, list[dict]] = {} chunk_size = 100 for i in range(0, len(intraday_tickers), chunk_size): chunk = intraday_tickers[i : i + chunk_size] try: chunk_bars = self._broker.get_intraday_bars( chunk, start=market_open, end=fetch_end, timeframe_minutes=5, ) intraday_raw.update(chunk_bars) except Exception as e: self._log(f" WARNING: intraday bars chunk {i//chunk_size+1} failed ({e}) — skipping") _emit( "orb_engine_bars_chunk_failed", _level="warning", session_id=self._session.session_id, chunk_kind="intraday", chunk_idx=i // chunk_size + 1, error=str(e), ) bars_by_ticker = intraday_bars_to_format(intraday_raw) market_bars_by_ticker = { ticker: mkt_bars for ticker, bars in bars_by_ticker.items() if (mkt_bars := filter_market_hours(bars)) } intraday_count = len(market_bars_by_ticker) self._log( f"Daily bars: {daily_bars_count} tickers | " f"Intraday bars: {intraday_count} tickers" ) if daily_bars_count == 0: self._log("WARNING: no daily bars fetched — enrichment will be empty; check Oracle/Alpaca connection") _emit( "orb_engine_no_daily_bars", _level="error", session_id=self._session.session_id, ) if intraday_count == 0: self._log("WARNING: no intraday bars fetched — zero candidates will be produced") _emit( "orb_engine_no_intraday_bars", _level="error", session_id=self._session.session_id, ) # Patch today_open in enrichment with actual first-bar open from intraday data. # The pre_screen synthetic row uses prev_close as today_open (gap=0), which breaks # market_regime_spy_threshold and breadth filters. Overwrite with real opening price. for ticker, ticker_bars in market_bars_by_ticker.items(): if not ticker_bars: continue first_bar = ticker_bars[0] real_open = first_bar.get("open") if real_open and ticker in self._enrichment: if date_str in self._enrichment[ticker]: self._enrichment[ticker][date_str]["today_open"] = real_open else: # Fallback: find the entry that was created for this date for d in sorted(self._enrichment[ticker].keys(), reverse=True): if d <= date_str: # Create a date_str entry inheriting from latest self._enrichment[ticker][date_str] = copy.copy( self._enrichment[ticker][d] ) self._enrichment[ticker][date_str]["today_open"] = real_open break # Market regime / breadth checks mirror simulate_orb_day's soft-day fallback. regime_scaler = 1.0 breadth_scaler = 1.0 regime_gap_pct: float | None = None breadth_ratio: float | None = None soft_day_reason_parts: list[str] = [] regime_thresh = getattr(self._params, "market_regime_spy_threshold", None) if regime_thresh is not None or getattr(self._params, "regime_size_scale_low", None) is not None: regime_ticker = getattr(self._params, "market_regime_ticker", None) or "QQQ" regime_enrich = self._enrichment.get(regime_ticker, {}).get(date_str, {}) regime_prev_close = regime_enrich.get("prev_close") regime_today_open = regime_enrich.get("today_open") if regime_prev_close and regime_today_open and regime_prev_close > 0: regime_gap = (regime_today_open - regime_prev_close) / regime_prev_close regime_gap_pct = regime_gap regime_skip_below = getattr(self._params, "regime_skip_below", None) if regime_skip_below is not None and regime_gap < regime_skip_below: self._log( f"Regime filter: {regime_ticker} gap {regime_gap:.3%} " f"< hard floor {regime_skip_below:.3%} — skipping today" ) self._state.update_daily_state( self._session.session_id, date_str, phase="done" ) return { "universe_size": intraday_count, "daily_bars": daily_bars_count, "intraday_bars": intraday_count, "orb_candidates": 0, "long": 0, "short": 0, "skip_reason": "market_regime", } if ( getattr(self._params, "regime_size_scale_low", None) is None and regime_thresh is not None and regime_gap < regime_thresh ): if getattr(self._params, "soft_day_fallback_on_regime_skip", False): regime_scaler = max( 0.0, float( getattr( self._params, "soft_day_regime_skip_size_scale", 1.0, ) or 0.0 ), ) soft_day_reason_parts.append("market_regime") self._log( f"Regime soft fallback: {regime_ticker} gap {regime_gap:.3%} " f"< {regime_thresh:.3%}; size_scale={regime_scaler:.3f}" ) else: self._log( f"Regime filter: {regime_ticker} gap {regime_gap:.3%} " f"< {regime_thresh:.3%} — skipping today" ) self._state.update_daily_state( self._session.session_id, date_str, phase="done" ) return { "universe_size": intraday_count, "daily_bars": daily_bars_count, "intraday_bars": intraday_count, "orb_candidates": 0, "long": 0, "short": 0, "skip_reason": "market_regime", } regime_low = getattr(self._params, "regime_size_scale_low", None) regime_high = getattr(self._params, "regime_size_scale_high", None) if regime_low is not None and regime_high is not None: regime_scaler = _linear_scaler( regime_gap, regime_low, regime_high, getattr(self._params, "regime_size_scale_min", 0.0), invert=True, ) # Breadth filter min_breadth = getattr(self._params, "min_candidate_breadth", None) if min_breadth is not None or getattr(self._params, "breadth_size_scale_low", None) is not None: pos_gap_count = 0 total_with_data = 0 for ticker in bars_by_ticker: t_enrich = self._enrichment.get(ticker, {}).get(date_str, {}) prev_c = t_enrich.get("prev_close") today_o = t_enrich.get("today_open") if prev_c and today_o and prev_c > 0: total_with_data += 1 if today_o > prev_c: pos_gap_count += 1 if total_with_data > 0: breadth_ratio = pos_gap_count / total_with_data hard_breadth_fallback_active = False breadth_skip_below = getattr(self._params, "breadth_skip_below", None) if breadth_skip_below is not None and breadth_ratio < breadth_skip_below: fallback_min = getattr( self._params, "hard_breadth_soft_fallback_min_breadth", None, ) if ( getattr(self._params, "hard_breadth_soft_fallback_enabled", False) and (fallback_min is None or breadth_ratio >= float(fallback_min)) ): breadth_scaler = max( 0.0, float( getattr( self._params, "hard_breadth_soft_fallback_size_scale", 0.03, ) or 0.0 ), ) soft_day_reason_parts.append("hard_breadth") hard_breadth_fallback_active = True self._log( f"Hard breadth soft fallback: {breadth_ratio:.1%} " f"< {breadth_skip_below:.1%}; size_scale={breadth_scaler:.3f}" ) else: self._log( f"Breadth filter: {breadth_ratio:.1%} positive gaps " f"< hard floor {breadth_skip_below:.1%} — skipping today" ) self._state.update_daily_state( self._session.session_id, date_str, phase="done" ) return { "universe_size": intraday_count, "daily_bars": daily_bars_count, "intraday_bars": intraday_count, "orb_candidates": 0, "long": 0, "short": 0, "skip_reason": "breadth", } if ( getattr(self._params, "breadth_size_scale_low", None) is None and min_breadth is not None and breadth_ratio < min_breadth and not hard_breadth_fallback_active ): if getattr(self._params, "soft_day_fallback_on_breadth_skip", False): breadth_scaler = max( 0.0, float( getattr( self._params, "soft_day_breadth_skip_size_scale", 1.0, ) or 0.0 ), ) soft_day_reason_parts.append("breadth") self._log( f"Breadth soft fallback: {breadth_ratio:.1%} " f"< {min_breadth:.1%}; size_scale={breadth_scaler:.3f}" ) else: self._log( f"Breadth filter: {breadth_ratio:.1%} positive gaps " f"< {min_breadth:.1%} — skipping today" ) self._state.update_daily_state( self._session.session_id, date_str, phase="done" ) return { "universe_size": intraday_count, "daily_bars": daily_bars_count, "intraday_bars": intraday_count, "orb_candidates": 0, "long": 0, "short": 0, "skip_reason": "breadth", } breadth_low = getattr(self._params, "breadth_size_scale_low", None) breadth_high = getattr(self._params, "breadth_size_scale_high", None) if breadth_low is not None and breadth_high is not None: breadth_scaler = _linear_scaler( breadth_ratio, breadth_low, breadth_high, getattr(self._params, "breadth_size_scale_min", 0.0), invert=True, ) breadth_scaler, soft_day_reason_parts = self._apply_market_thrust_breadth_override( market_bars_by_ticker, date_str, soft_day_reason_parts=soft_day_reason_parts, regime_gap_pct=regime_gap_pct, breadth_ratio=breadth_ratio, breadth_scaler=breadth_scaler, ) self._day_size_scale = max(0.0, regime_scaler * breadth_scaler) if ( soft_day_reason_parts and getattr(self._params, "soft_day_combined_size_scale_floor", None) is not None ): self._day_size_scale = max( self._day_size_scale, max( 0.0, float(getattr(self._params, "soft_day_combined_size_scale_floor")), ), ) if soft_day_reason_parts: self._soft_day_reason = "+".join(soft_day_reason_parts) self._log( f"Soft-day active: reason={self._soft_day_reason}, " f"day_size_scale={self._day_size_scale:.3f}" ) self._apply_market_orb_quality(market_bars_by_ticker, date_str) if self._day_size_scale <= 0: self._log("Day sizing disabled by market ORB quality — candidates may be recorded, but no entries will be placed") broad_gapup_fallback_mode = bool( getattr(self._params, "broad_gapup_continuation_enabled", False) ) market_thrust_liquid_mode = bool( getattr(self._params, "market_thrust_liquid_continuation_enabled", False) ) market_thrust_opening_impulse_mode = bool( getattr(self._params, "market_thrust_opening_impulse_reclaim_enabled", False) ) base_candidate_updates: dict[str, Any] = {} if broad_gapup_fallback_mode: base_candidate_updates["broad_gapup_continuation_enabled"] = False if market_thrust_liquid_mode: base_candidate_updates["market_thrust_liquid_continuation_enabled"] = False if market_thrust_opening_impulse_mode: base_candidate_updates["market_thrust_opening_impulse_reclaim_enabled"] = False candidate_params = self._copy_params_with_updates( self._params, base_candidate_updates, ) computed_candidates = compute_orb_candidates( bars_by_ticker=bars_by_ticker, date_str=date_str, params=candidate_params, enrichment=self._enrichment, ) normal_candidates = [ cand for cand in computed_candidates if cand["ticker"] in candidate_ticker_set ] broad_candidates: list[dict[str, Any]] = [] allow_broad_candidates = ( broad_gapup_fallback_mode and ( normal_candidates == [] or not getattr( self._params, "broad_gapup_continuation_only_when_no_primary_entries", False, ) ) ) if allow_broad_candidates: broad_scan_params = self._copy_params_with_updates( self._params, { "max_candidates": max( int(getattr(self._params, "max_candidates", 0) or 0), len(candidate_ticker_set), ), "max_candidates_per_sector": None, "market_thrust_liquid_continuation_enabled": False, "market_thrust_opening_impulse_reclaim_enabled": False, }, ) broad_candidates = [ cand for cand in compute_orb_candidates( bars_by_ticker=bars_by_ticker, date_str=date_str, params=broad_scan_params, enrichment=self._enrichment, ) if cand["ticker"] in candidate_ticker_set and cand.get("broad_gapup_continuation") ] used_tickers = {cand["ticker"] for cand in normal_candidates + broad_candidates} market_thrust_liquid_candidates: list[dict[str, Any]] = [] opening_breadth_only_thrust = ( self._market_thrust_opening_breadth_override_active and not self._market_thrust_index_breadth_override_active ) liquid_allowed_on_opening_breadth = bool( getattr( self._params, "market_thrust_opening_breadth_override_activate_liquid_continuation", True, ) ) market_thrust_liquid_scan_active = ( market_thrust_liquid_mode and self._market_thrust_breadth_override_active and ( not opening_breadth_only_thrust or liquid_allowed_on_opening_breadth ) ) if market_thrust_liquid_scan_active: market_thrust_scan_params = self._copy_params_with_updates( self._params, { "max_candidates": max( int(getattr(self._params, "max_candidates", 0) or 0), len(candidate_ticker_set), ), "max_candidates_per_sector": None, "broad_gapup_continuation_enabled": False, "market_thrust_opening_impulse_reclaim_enabled": False, }, ) market_thrust_liquid_candidates = [ cand for cand in compute_orb_candidates( bars_by_ticker=bars_by_ticker, date_str=date_str, params=market_thrust_scan_params, enrichment=self._enrichment, ) if cand["ticker"] in candidate_ticker_set and cand.get("market_thrust_liquid_continuation") and cand["ticker"] not in used_tickers ] max_market_thrust_candidates = getattr( self._params, "market_thrust_liquid_continuation_max_candidates", None, ) if max_market_thrust_candidates is not None: market_thrust_liquid_candidates = market_thrust_liquid_candidates[ : max(0, int(max_market_thrust_candidates)) ] used_tickers.update(cand["ticker"] for cand in market_thrust_liquid_candidates) market_thrust_opening_impulse_candidates: list[dict[str, Any]] = [] if market_thrust_opening_impulse_mode and self._market_thrust_breadth_override_active: market_thrust_impulse_params = self._copy_params_with_updates( self._params, { "max_candidates": max( int(getattr(self._params, "max_candidates", 0) or 0), len(candidate_ticker_set), ), "max_candidates_per_sector": None, "broad_gapup_continuation_enabled": False, "market_thrust_liquid_continuation_enabled": False, }, ) market_thrust_opening_impulse_candidates = [ cand for cand in compute_orb_candidates( bars_by_ticker=bars_by_ticker, date_str=date_str, params=market_thrust_impulse_params, enrichment=self._enrichment, ) if cand["ticker"] in candidate_ticker_set and cand.get("market_thrust_opening_impulse_reclaim") and cand["ticker"] not in used_tickers ] max_market_thrust_impulse_candidates = getattr( self._params, "market_thrust_opening_impulse_reclaim_max_candidates", None, ) if max_market_thrust_impulse_candidates is not None: market_thrust_opening_impulse_candidates = market_thrust_opening_impulse_candidates[ : max(0, int(max_market_thrust_impulse_candidates)) ] self._candidates = ( normal_candidates + broad_candidates + market_thrust_liquid_candidates + market_thrust_opening_impulse_candidates ) # Save candidates to DB for cand in self._candidates: orb_bar = cand["orb_bar"] direction = cand["direction"] breakout_level = orb_bar["high"] if direction == "bullish" else orb_bar["low"] row = ORBCandidateRow( session_id=self._session.session_id, date=date_str, ticker=cand["ticker"], direction=direction, orb_high=orb_bar["high"], orb_low=orb_bar["low"], breakout_level=breakout_level, atr=cand["atr"], rvol=cand["rvol"], gap_pct=cand["gap_pct"], composite_score=cand["score"], ) self._state.save_candidate(row) # Keep as pending (not yet filled) self._pending_cands = list(self._candidates) n_long = sum(1 for c in self._candidates if c["direction"] == "bullish") n_short = sum(1 for c in self._candidates if c["direction"] == "bearish") self._log( f"ORB candidates: {len(self._candidates)} " f"(long={n_long}, short={n_short})" ) self._state.update_daily_state( self._session.session_id, date_str, phase="breakout" ) return { "universe_size": len(intraday_tickers), "daily_bars": daily_bars_count, "intraday_bars": intraday_count, "orb_candidates": len(self._candidates), "long": n_long, "short": n_short, } # ── Phase 3: Breakout Check ─────────────────────────────────────────────── def run_breakout_check(self, date_str: str) -> dict[str, Any]: """Check for breakouts and place orders for unfilled candidates. Called every sim_bar_minutes from orb_end until order_timeout elapses. """ self._date_str = date_str # Reload state if engine was recreated (e.g., server restart) if not self._pending_cands and not self._candidates: self._pending_cands = self._rebuild_pending_candidates(date_str) if not self._pending_cands: return {"checked": 0, "filled": 0, "remaining": 0} daily_state = self._state.get_daily_state( self._session.session_id, date_str ) if daily_state.kill_switch: self._log("Kill switch active — skipping breakout check") _emit( "orb_engine_kill_switch_active", _level="warning", session_id=self._session.session_id, phase="breakout", ) return {"checked": 0, "filled": 0, "remaining": 0, "kill_switch": True} equity = self._get_equity() # Fetch real-time snapshots for pending candidates via Oracle API tickers = [c["ticker"] for c in self._pending_cands] snapshots = get_snapshots(tickers) filled_count = 0 still_pending = [] for cand in self._pending_cands: ticker = cand["ticker"] direction = cand["direction"] orb_bar = cand["orb_bar"] atr = cand["atr"] score = cand["score"] rvol = cand["rvol"] breakout_level = orb_bar["high"] if direction == "bullish" else orb_bar["low"] # Check if already traded today or at max simultaneous positions open_positions = self._state.get_open_positions( self._session.session_id, date_str ) if any(p.ticker == ticker for p in open_positions): self._state.update_candidate_status( self._session.session_id, date_str, ticker, "filled" ) continue quality_max_trades = getattr(self, "_market_orb_quality_max_trades", None) if quality_max_trades is not None and len(open_positions) >= quality_max_trades: self._log( f" {ticker}: market ORB quality max_trades={quality_max_trades} " f"reached ({self._market_orb_quality_reason or 'quality_gate'}) — cancelling" ) self._state.update_candidate_status( self._session.session_id, date_str, ticker, "cancelled" ) continue max_sim = getattr(self._params, "max_simultaneous_entries", None) if max_sim is not None and len(open_positions) >= max_sim: still_pending.append(cand) continue # Check breakout using real-time snapshot price snap = snapshots.get(ticker) if snap is None or snap.price is None: still_pending.append(cand) continue current_price = snap.price broke_out = ( (direction == "bullish" and current_price >= breakout_level) or (direction == "bearish" and current_price <= breakout_level) ) if not broke_out: still_pending.append(cand) continue # Breakout detected — compute position size and place order stop_distance = atr * self._params.atr_stop_multiplier if stop_distance <= 0: still_pending.append(cand) continue day_size_scale = max(0.0, float(getattr(self, "_day_size_scale", 1.0) or 0.0)) candidate_size_scale = self._candidate_size_scale(cand) sizing_capital = ( self._compute_sizing_capital(equity) * day_size_scale * candidate_size_scale ) if sizing_capital <= 0: self._log( f" {ticker}: day_size_scale={day_size_scale:.3f}, " f"candidate_size_scale={candidate_size_scale:.3f}; sizing disabled" ) self._state.update_candidate_status( self._session.session_id, date_str, ticker, "cancelled" ) continue risk_dollars = sizing_capital * self._params.risk_per_trade_pct shares_from_risk = risk_dollars / stop_distance entry_price_est = max(breakout_level, current_price) max_shares_by_capital = (sizing_capital * self._params.max_position_pct) / entry_price_est shares = int(min(shares_from_risk, max_shares_by_capital)) if shares <= 0: self._log(f" {ticker}: shares=0 after sizing — skipping") self._state.update_candidate_status( self._session.session_id, date_str, ticker, "cancelled" ) continue # Check buying power try: acct = self._broker.get_account() if acct.buying_power < shares * entry_price_est: self._log(f" {ticker}: insufficient buying power — skipping") self._state.update_candidate_status( self._session.session_id, date_str, ticker, "cancelled" ) continue except Exception as e: self._log(f" {ticker}: account check error: {e}") # Place order try: if direction == "bullish": order = self._broker.submit_market_buy(ticker, shares) else: order = self._broker.submit_market_sell(ticker, shares) self._log( f" {ticker}: {direction} breakout → {shares} shares " f"(order {order.id})" ) _emit( "orb_engine_buy_submitted", session_id=self._session.session_id, ticker=ticker, direction=direction, qty=shares, entry_price_est=round(entry_price_est, 4), order_id=order.id, category="order", ) except Exception as e: self._log(f" {ticker}: order failed: {e}") _emit( "orb_engine_buy_rejected", _level="error", session_id=self._session.session_id, ticker=ticker, direction=direction, qty=shares, error=str(e), category="order", ) still_pending.append(cand) continue # Wait for fill (poll up to 30s) fill_price = entry_price_est order_rejected = False reject_status: str | None = None for _ in range(6): time.sleep(5) try: filled_order = self._broker.get_order(order.id) if filled_order.status == "filled" and filled_order.filled_avg_price: fill_price = filled_order.filled_avg_price break if filled_order.status in ("cancelled", "rejected", "expired"): self._log(f" {ticker}: order {filled_order.status} — no position created") order_rejected = True reject_status = filled_order.status break except Exception: pass if order_rejected: _emit( "orb_engine_buy_rejected", _level="error", session_id=self._session.session_id, ticker=ticker, qty=shares, order_id=order.id, status=reject_status or "unknown", category="order", ) self._state.update_candidate_status( self._session.session_id, date_str, ticker, "cancelled" ) continue _emit( "orb_engine_buy_filled", session_id=self._session.session_id, ticker=ticker, direction=direction, qty=shares, fill_price=round(float(fill_price), 4), order_id=order.id, category="order", ) # Record position initial_stop = ( fill_price - stop_distance if direction == "bullish" else fill_price + stop_distance ) pos = ORBPositionRow( session_id=self._session.session_id, date=date_str, ticker=ticker, direction="long" if direction == "bullish" else "short", entry_price=fill_price, entry_time=dt.datetime.now(_ET).isoformat(), shares=shares, orb_high=orb_bar["high"], orb_low=orb_bar["low"], atr_at_entry=atr, stop_distance=stop_distance, current_stop=initial_stop, peak_price=fill_price, rvol=rvol, composite_score=score, order_id=order.id, ) self._state.save_position(pos) self._state.update_candidate_status( self._session.session_id, date_str, ticker, "filled" ) filled_count += 1 # Check kill switches daily_state = self._state.get_daily_state( self._session.session_id, date_str ) if daily_state.kill_switch: self._log("Kill switch triggered — stopping breakout monitoring") _emit( "orb_engine_kill_switch_triggered", _level="error", session_id=self._session.session_id, phase="breakout", ) break self._pending_cands = still_pending self._log( f"Breakout check: filled={filled_count}, remaining={len(still_pending)}" ) return { "checked": len(tickers), "filled": filled_count, "remaining": len(still_pending), } # ── Phase 4: Stop Check (sim_bar_minutes checkpoints) ──────────────────── def run_stop_check(self, date_str: str) -> dict[str, Any]: """Evaluate stops for all open positions using aggregated bars. Called every sim_bar_minutes from orb_end until 15:55 ET. Stop logic mirrors orb_simulator.py:477-580 exactly. """ self._date_str = date_str positions = self._state.get_open_positions(self._session.session_id, date_str) if not positions: return {"positions_checked": 0, "stops_hit": 0} daily_state = self._state.get_daily_state( self._session.session_id, date_str ) if daily_state.kill_switch: return {"positions_checked": 0, "stops_hit": 0, "kill_switch": True} today = dt.date.fromisoformat(date_str) market_open = dt.datetime(today.year, today.month, today.day, 9, 30, tzinfo=_ET) now_et = dt.datetime.now(_ET) tickers = [p.ticker for p in positions] bars_raw = self._broker.get_intraday_bars( tickers, start=market_open, end=now_et, timeframe_minutes=5, ) group_size = self._params.sim_bar_minutes // 5 stops_hit = 0 equity = self._get_equity() for pos in positions: ticker = pos.ticker all_bars = bars_raw.get(ticker, []) mkt_bars = filter_market_hours(all_bars) if not mkt_bars: continue # Filter bars after entry time entry_ts = _parse_ts(pos.entry_time) post_entry = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > entry_ts] if not post_entry: continue # Aggregate to sim_bar_minutes (e.g., 90-min) agg_bars = _aggregate_bars(post_entry, group_size) # Run stop management on each aggregated bar current_stop = pos.current_stop peak_price = pos.peak_price trailing_active = pos.trailing_active stop_distance = pos.stop_distance atr = pos.atr_at_entry exit_bar = None exit_reason = "close" use_atr_trail = self._params.trailing_stop_atr_multiplier > 0 for bar in agg_bars: bar_high = bar["high"] bar_low = bar["low"] if pos.direction == "long": peak_price = max(peak_price, bar_high) current_r = (bar_high - pos.entry_price) / stop_distance if stop_distance > 0 else 0 if current_r >= self._params.breakeven_at_r and current_stop < pos.entry_price: current_stop = pos.entry_price if current_r >= self._params.trailing_at_r: trailing_active = True # Check stop hit BEFORE updating trailing if bar_low <= current_stop: exit_bar = bar exit_reason = "trailing_stop" if trailing_active else "stop_loss" break # Update trailing AFTER stop check if trailing_active: if use_atr_trail: tighten_r = getattr(self._params, "trailing_tighten_at_r", None) tight_mult = getattr(self._params, "trailing_stop_atr_multiplier_tight", 0.0) if (tighten_r is not None and current_r >= tighten_r and tight_mult > 0): atr_mult = tight_mult else: atr_mult = self._params.trailing_stop_atr_multiplier candidate = peak_price - atr * atr_mult else: candidate = max(bar_low, current_stop) if candidate > current_stop: current_stop = candidate else: # short peak_price = min(peak_price, bar_low) current_r = (pos.entry_price - bar_low) / stop_distance if stop_distance > 0 else 0 if current_r >= self._params.breakeven_at_r and current_stop > pos.entry_price: current_stop = pos.entry_price if current_r >= self._params.trailing_at_r: trailing_active = True if bar_high >= current_stop: exit_bar = bar exit_reason = "trailing_stop" if trailing_active else "stop_loss" break if trailing_active: if use_atr_trail: tighten_r = getattr(self._params, "trailing_tighten_at_r", None) tight_mult = getattr(self._params, "trailing_stop_atr_multiplier_tight", 0.0) if (tighten_r is not None and current_r >= tighten_r and tight_mult > 0): atr_mult = tight_mult else: atr_mult = self._params.trailing_stop_atr_multiplier candidate = peak_price + atr * atr_mult else: candidate = min(bar_high, current_stop) if candidate < current_stop: current_stop = candidate # Update DB stop levels self._state.update_position_stop( self._session.session_id, date_str, ticker, current_stop, peak_price, trailing_active, ) if exit_bar: # Close position — use qty so only this session's shares are closed # (other sessions may hold the same ticker in the same Alpaca account). exit_price = current_stop # fallback if fill poll fails try: close_order = self._broker.close_position(ticker, qty=int(pos.shares)) # Poll for actual broker fill price (captures gap-through losses) for _ in range(4): time.sleep(3) try: o = self._broker.get_order(close_order.id) if o.filled_avg_price: exit_price = o.filled_avg_price break except Exception: pass self._log( f" {ticker}: stop hit ({exit_reason}) @ {exit_price:.2f}" ) _emit( "orb_engine_close_filled", session_id=self._session.session_id, ticker=ticker, qty=int(pos.shares), exit_price=round(float(exit_price), 4), entry_price=round(float(pos.entry_price), 4), reason="stop", exit_subreason=exit_reason, order_id=close_order.id, category="order", ) except Exception as e: self._log(f" {ticker}: close error: {e}") _emit( "orb_engine_close_failed", _level="error", session_id=self._session.session_id, ticker=ticker, qty=int(pos.shares), reason="stop", exit_subreason=exit_reason, error=str(e), category="order", ) self._record_trade(pos, exit_price, exit_bar["timestamp"], exit_reason, equity) stops_hit += 1 # Update daily kill switches loss = (exit_price - pos.entry_price) * pos.shares if pos.direction == "short": loss = (pos.entry_price - exit_price) * pos.shares if loss < 0: new_cum_loss = daily_state.cumulative_loss + abs(loss) new_stops = daily_state.stops_hit + 1 kill = ( new_cum_loss >= equity * self._params.daily_max_loss_pct or new_stops >= self._params.max_stops_per_day ) self._state.update_daily_state( self._session.session_id, date_str, cumulative_loss=new_cum_loss, stops_hit=new_stops, kill_switch=kill, ) daily_state = self._state.get_daily_state( self._session.session_id, date_str ) if kill: self._log("Kill switch triggered!") _emit( "orb_engine_kill_switch_triggered", _level="error", session_id=self._session.session_id, phase="stop_check", cumulative_loss=round(float(new_cum_loss), 2), stops_hit=int(new_stops), ) break self._log(f"Stop check: {len(positions)} positions, {stops_hit} stops hit") return {"positions_checked": len(positions), "stops_hit": stops_hit} # ── Phase 5: EOD Exit ───────────────────────────────────────────────────── def run_eod_exit(self, date_str: str) -> dict[str, Any]: """Close all remaining open positions at 15:55 ET.""" self._date_str = date_str self._state.update_daily_state( self._session.session_id, date_str, phase="eod_exit" ) # Cancel any unfilled breakout candidates — must run unconditionally so # stale pending records are cleaned up even when there are no open positions # (e.g., server restarted after ORB detection but before any breakout). for cand in self._pending_cands: self._state.update_candidate_status( self._session.session_id, date_str, cand["ticker"], "timeout" ) self._pending_cands = [] db_cands = self._state.list_candidates(self._session.session_id, date_str) for c in db_cands: if c["status"] == "pending": self._state.update_candidate_status( self._session.session_id, date_str, c["ticker"], "timeout" ) positions = self._state.get_open_positions(self._session.session_id, date_str) if not positions: self._log("EOD: no open positions") return {"closed": 0} equity = self._get_equity() closed = 0 now_str = dt.datetime.now(_ET).isoformat() for pos in positions: try: # Use qty so only this session's shares are closed close_order = self._broker.close_position(pos.ticker, qty=int(pos.shares)) exit_price = pos.entry_price for _ in range(4): time.sleep(3) try: o = self._broker.get_order(close_order.id) if o.filled_avg_price: exit_price = o.filled_avg_price break except Exception: pass self._record_trade(pos, exit_price, now_str, "close", equity) self._log(f" EOD close: {pos.ticker} @ {exit_price:.2f}") _emit( "orb_engine_close_filled", session_id=self._session.session_id, ticker=pos.ticker, qty=int(pos.shares), exit_price=round(float(exit_price), 4), entry_price=round(float(pos.entry_price), 4), reason="eod", order_id=close_order.id, category="order", ) closed += 1 except Exception as e: self._log(f" EOD close error {pos.ticker}: {e}") _emit( "orb_engine_close_failed", _level="error", session_id=self._session.session_id, ticker=pos.ticker, qty=int(pos.shares), reason="eod", error=str(e), category="order", ) # Mark as closed in DB anyway to prevent zombie positions self._state.close_position_record( self._session.session_id, date_str, pos.ticker ) return {"closed": closed} # ── Phase 6: Post-close ─────────────────────────────────────────────────── def run_post_close(self, date_str: str) -> dict[str, Any]: """Record daily equity snapshot and finalize day.""" self._date_str = date_str self._state.update_daily_state( self._session.session_id, date_str, phase="done" ) trades_today = self._state.list_trades(self._session.session_id) today_trades = [t for t in trades_today if t["date"] == date_str] daily_pnl = sum(t["pnl"] for t in today_trades) stops_hit = sum(1 for t in today_trades if t["exit_reason"] in ("stop_loss", "trailing_stop")) prev_equity = self._get_equity() if prev_equity is None: prev_equity = self._session.initial_equity new_equity = max(prev_equity + daily_pnl, 0.01) # Drawdown peak_equity = self._state.get_peak_equity( self._session.session_id, self._session.initial_equity ) drawdown_pct = ((new_equity - peak_equity) / peak_equity * 100) if peak_equity > 0 else 0.0 snap = ORBDailySnapshotRow( session_id=self._session.session_id, date=date_str, equity=new_equity, daily_pnl=daily_pnl, total_pnl=new_equity - self._session.initial_equity, trades_taken=len(today_trades), stops_hit=stops_hit, drawdown_pct=drawdown_pct, ) self._state.save_daily_snapshot(snap) self._log( f"Post-close: equity={new_equity:.2f}, pnl={daily_pnl:+.2f}, " f"trades={len(today_trades)}, stops={stops_hit}" ) return { "equity": new_equity, "daily_pnl": daily_pnl, "trades": len(today_trades), "stops_hit": stops_hit, "drawdown_pct": drawdown_pct, } # ── Helpers ─────────────────────────────────────────────────────────────── def _record_trade( self, pos: ORBPositionRow, exit_price: float, exit_time: str, exit_reason: str, equity: float, ) -> None: """Record a completed trade in the DB and close the position record.""" if pos.direction == "long": pnl = (exit_price - pos.entry_price) * pos.shares else: pnl = (pos.entry_price - exit_price) * pos.shares r_multiple = ( pnl / (pos.stop_distance * pos.shares) if pos.stop_distance > 0 and pos.shares > 0 else 0.0 ) trade = ORBTradeRow( trade_id=str(uuid.uuid4())[:12], session_id=self._session.session_id, date=pos.date, ticker=pos.ticker, direction=pos.direction, entry_price=pos.entry_price, exit_price=exit_price, entry_time=pos.entry_time, exit_time=exit_time, shares=pos.shares, pnl=round(pnl, 4), r_multiple=round(r_multiple, 3), exit_reason=exit_reason, atr_at_entry=pos.atr_at_entry, rvol=pos.rvol, composite_score=pos.composite_score, ) self._state.save_trade(trade) self._state.close_position_record( self._session.session_id, pos.date, pos.ticker ) def _compute_sizing_capital(self, equity: float) -> float: """Replicate backtest sizing_capital formula: governor + streak multiplier. Mirrors libs/intraday/orb_simulator.py:2141-2187. V23 uses daily_budget_reset=True: base sizing = initial_equity (not equity). This matches the backtest 단리 mode where each day starts from $10k. """ # daily_budget_reset: fixed daily budget matches V23 backtest 단리 mode daily_reset = getattr(self._params, "daily_budget_reset", False) sizing = self._session.initial_equity if daily_reset else equity # Drawdown governor: scale down when equity drops below peak gov_thresh = getattr(self._params, "drawdown_governor_threshold", None) gov_min = getattr(self._params, "drawdown_governor_min_scale", 0.30) if gov_thresh is not None: peak_equity = self._state.get_peak_equity( self._session.session_id, self._session.initial_equity ) if peak_equity > 0: dd_pct = (peak_equity - equity) / peak_equity if dd_pct > gov_thresh: dd_excess = dd_pct - gov_thresh governor_scale = max( gov_min, 1.0 - (1.0 - gov_min) * min(dd_excess / gov_thresh, 1.0), ) sizing = sizing * governor_scale # Streak sizing: amplify after consecutive wins, reduce after consecutive losses. # list_trades returns DESC (newest first) — outcomes[0] = most recent trade. win_bonus = getattr(self._params, "streak_sizing_win_bonus", None) loss_penalty = getattr(self._params, "streak_sizing_loss_penalty", None) streak_max = getattr(self._params, "streak_sizing_max", 2.5) streak_min = getattr(self._params, "streak_sizing_min", 0.5) if win_bonus is not None or loss_penalty is not None: trades = self._state.list_trades(self._session.session_id) if trades: outcomes = [t["pnl"] > 0 for t in trades] # newest first is_winning = outcomes[0] # most recent outcome streak_len = 0 for o in outcomes: # count from newest if o == is_winning: streak_len += 1 else: break streak_mult = 1.0 if is_winning and win_bonus is not None: streak_mult = 1.0 + streak_len * win_bonus elif not is_winning and loss_penalty is not None: streak_mult = 1.0 - streak_len * loss_penalty streak_mult = max(streak_min, min(streak_max, streak_mult)) sizing = sizing * streak_mult return sizing def _candidate_size_scale(self, cand: dict[str, Any]) -> float: """Per-candidate live sizing scale for sleeves that survive in memory/DB.""" if not bool(getattr(self._params, "broad_gapup_continuation_enabled", False)): return 1.0 is_broad_gapup = bool(cand.get("broad_gapup_continuation")) if not is_broad_gapup: max_gap = getattr(self._params, "max_gap_pct", None) gap_pct = cand.get("gap_pct") is_broad_gapup = ( max_gap is not None and gap_pct is not None and float(gap_pct) > float(max_gap) ) if not is_broad_gapup: return 1.0 raw_scale = getattr(self._params, "broad_gapup_continuation_size_scale", 1.0) return max(0.0, min(1.0, float(raw_scale or 0.0))) def _rebuild_pending_candidates(self, date_str: str) -> list[dict]: """Reconstruct pending candidates from DB (after server restart).""" db_cands = self._state.list_candidates(self._session.session_id, date_str) open_positions = self._state.get_open_positions( self._session.session_id, date_str ) filled_tickers = {p.ticker for p in open_positions} result = [] for c in db_cands: if c["status"] != "pending": continue if c["ticker"] in filled_tickers: continue # Reconstruct minimal candidate dict for breakout check result.append({ "ticker": c["ticker"], "direction": c["direction"], "orb_bar": { "high": c["orb_high"], "low": c["orb_low"], "timestamp": "", "open": 0, "close": 0, "volume": 0, }, "atr": c["atr"], "rvol": c["rvol"], "gap_pct": c["gap_pct"], "score": c["composite_score"], }) return result # ── Engine factory ──────────────────────────────────────────────────────────── def make_orb_engine( session: Any, db_path: str | None = None, broker_override: Any = None, log_callback: Any = None, ) -> ORBTradingEngine: """Create an ORBTradingEngine for the given session. broker_override: pass a MockORBBroker (or any duck-typed broker) to avoid real Alpaca API calls during testing. """ from apps.orb_trader.state import ORBStateManager from libs.intraday.domain import IntradayConfig from apps.intraday_bt.run import _load_config_yaml if broker_override is not None: broker = broker_override else: from apps.paper_trader.alpaca_broker import AlpacaBroker broker = AlpacaBroker.from_env() state = ORBStateManager(db_path) # Load strategy params from YAML config (resolves `extends` inheritance) raw = _load_config_yaml(Path(session.config_path)) or {} # Strip _meta and other non-model keys config_data = {k: v for k, v in raw.items() if not k.startswith("_")} config = IntradayConfig(**config_data) params = config.orb_strategy if params is None: from libs.intraday.domain import ORBStrategyParams params = ORBStrategyParams() # Store universe source on params for runtime use params._universe_source = config.universe.source params._universe_symbols_file = config.universe.symbols_file return ORBTradingEngine( session=session, broker=broker, state=state, params=params, log_callback=log_callback, )