You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2291 lines
100 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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,
)