|
|
"""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 json
|
|
|
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,
|
|
|
_find_late_breakout_time,
|
|
|
_first_regular_bar,
|
|
|
_find_vwap_reclaim_time,
|
|
|
_linear_range_scaler,
|
|
|
_linear_scaler,
|
|
|
_opening_breadth_stats,
|
|
|
_orb_soft_day_sector_confirmation_override_allows,
|
|
|
_orb_soft_day_setup_profile_allows,
|
|
|
_orb_soft_day_vwap_min_score_pct,
|
|
|
_orb_soft_day_vwap_reason_allowed,
|
|
|
_orb_soft_day_vwap_reason_param,
|
|
|
_orb_soft_day_vwap_size_scale,
|
|
|
compute_orb_candidates,
|
|
|
)
|
|
|
from libs.intraday.simulator import _market_open_ts, _parse_ts, filter_market_hours
|
|
|
from libs.oracle_client.alpaca import get_multi_intraday_bars_today, 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
|
|
|
_AUXILIARY_TRIGGER_TYPES = {
|
|
|
"vwap_reclaim",
|
|
|
"soft_day_vwap_reclaim",
|
|
|
"late_breakout",
|
|
|
"intraday_continuation_reclaim",
|
|
|
}
|
|
|
_MARKET_THRUST_LIQUID_TRIGGER_TYPES = {
|
|
|
"market_thrust_liquid_continuation",
|
|
|
"market_thrust_opening_burst",
|
|
|
"market_thrust_opening_followthrough",
|
|
|
}
|
|
|
_MARKET_THRUST_IMPULSE_TRIGGER_TYPES = {
|
|
|
"market_thrust_opening_impulse_reclaim",
|
|
|
}
|
|
|
# Mirrors orb_simulator.py:9947-9954. Aux + market-thrust triggers bypass the
|
|
|
# soft-day score/profile/ret5/rvol gates because they have their own gating.
|
|
|
_SOFT_DAY_PRIMARY_FILTER_EXEMPT_TRIGGER_TYPES = {
|
|
|
"soft_day_vwap_reclaim",
|
|
|
"broad_gapup_continuation",
|
|
|
"market_thrust_liquid_continuation",
|
|
|
"market_thrust_opening_burst",
|
|
|
"market_thrust_opening_followthrough",
|
|
|
"market_thrust_opening_impulse_reclaim",
|
|
|
"intraday_continuation_reclaim",
|
|
|
}
|
|
|
# Mirrors orb_simulator.py:9935-9941. soft_day_max_trades caps total day trades
|
|
|
# but exempts these trigger types from being subject to the cap themselves.
|
|
|
_SOFT_DAY_MAX_TRADES_EXEMPT_TRIGGER_TYPES = {
|
|
|
"broad_gapup_continuation",
|
|
|
"market_thrust_opening_burst",
|
|
|
"market_thrust_opening_followthrough",
|
|
|
"market_thrust_opening_impulse_reclaim",
|
|
|
"intraday_continuation_reclaim",
|
|
|
}
|
|
|
|
|
|
|
|
|
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 _metadata_value(value: Any) -> Any:
|
|
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
|
return value
|
|
|
if isinstance(value, (dt.date, dt.datetime)):
|
|
|
return value.isoformat()
|
|
|
try:
|
|
|
json.dumps(value)
|
|
|
return value
|
|
|
except TypeError:
|
|
|
return str(value)
|
|
|
|
|
|
def _candidate_metadata_json(self, cand: dict[str, Any]) -> str:
|
|
|
keys = (
|
|
|
"body_ratio",
|
|
|
"broad_gapup_continuation",
|
|
|
"close_location",
|
|
|
"filter_first_bar_dollar_vol",
|
|
|
"filter_premarket_dollar_vol",
|
|
|
"filter_rvol",
|
|
|
"first_bar_dollar_vol",
|
|
|
"gap_up_fill_exit_active",
|
|
|
"market_thrust_liquid_continuation",
|
|
|
"market_thrust_opening_impulse_reclaim",
|
|
|
"orb_return",
|
|
|
"premarket_dollar_vol",
|
|
|
"ret_5d",
|
|
|
"rvol_rank_pct",
|
|
|
"score_rank_pct",
|
|
|
"sector_confirmation_active",
|
|
|
"trigger_type",
|
|
|
)
|
|
|
metadata: dict[str, Any] = {}
|
|
|
for key in keys:
|
|
|
if key in cand:
|
|
|
metadata[key] = self._metadata_value(cand.get(key))
|
|
|
|
|
|
orb_bar = cand.get("orb_bar")
|
|
|
if isinstance(orb_bar, dict):
|
|
|
metadata["orb_bar"] = {
|
|
|
key: self._metadata_value(orb_bar.get(key))
|
|
|
for key in ("timestamp", "open", "high", "low", "close", "volume")
|
|
|
if key in orb_bar
|
|
|
}
|
|
|
|
|
|
metadata["live_day_context"] = {
|
|
|
"day_size_scale": self._metadata_value(self._day_size_scale),
|
|
|
"soft_day_reason": self._metadata_value(self._soft_day_reason),
|
|
|
"market_orb_quality_max_trades": self._metadata_value(
|
|
|
self._market_orb_quality_max_trades
|
|
|
),
|
|
|
"market_orb_quality_reason": self._metadata_value(
|
|
|
self._market_orb_quality_reason
|
|
|
),
|
|
|
"market_thrust_breadth_override_active": self._metadata_value(
|
|
|
self._market_thrust_breadth_override_active
|
|
|
),
|
|
|
"market_thrust_index_breadth_override_active": self._metadata_value(
|
|
|
self._market_thrust_index_breadth_override_active
|
|
|
),
|
|
|
"market_thrust_opening_breadth_override_active": self._metadata_value(
|
|
|
self._market_thrust_opening_breadth_override_active
|
|
|
),
|
|
|
}
|
|
|
return json.dumps(metadata, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
@staticmethod
|
|
|
def _candidate_metadata_from_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
|
raw = row.get("metadata_json")
|
|
|
if not raw:
|
|
|
return {}
|
|
|
try:
|
|
|
parsed = json.loads(str(raw))
|
|
|
except (TypeError, ValueError):
|
|
|
return {}
|
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
|
|
|
|
def _restore_day_context_from_metadata(self, metadata: dict[str, Any]) -> None:
|
|
|
context = metadata.get("live_day_context")
|
|
|
if not isinstance(context, dict):
|
|
|
return
|
|
|
if context.get("day_size_scale") is not None:
|
|
|
try:
|
|
|
self._day_size_scale = float(context["day_size_scale"])
|
|
|
except (TypeError, ValueError):
|
|
|
pass
|
|
|
if context.get("soft_day_reason") is not None:
|
|
|
self._soft_day_reason = str(context["soft_day_reason"])
|
|
|
if context.get("market_orb_quality_max_trades") is not None:
|
|
|
try:
|
|
|
self._market_orb_quality_max_trades = int(
|
|
|
context["market_orb_quality_max_trades"]
|
|
|
)
|
|
|
except (TypeError, ValueError):
|
|
|
pass
|
|
|
if context.get("market_orb_quality_reason") is not None:
|
|
|
self._market_orb_quality_reason = str(context["market_orb_quality_reason"])
|
|
|
self._market_thrust_breadth_override_active = bool(
|
|
|
context.get("market_thrust_breadth_override_active", False)
|
|
|
)
|
|
|
self._market_thrust_index_breadth_override_active = bool(
|
|
|
context.get("market_thrust_index_breadth_override_active", False)
|
|
|
)
|
|
|
self._market_thrust_opening_breadth_override_active = bool(
|
|
|
context.get("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 _iex_live_intraday_threshold(self, threshold: Any) -> float | None:
|
|
|
"""Convert a SIP-calibrated intraday volume threshold to raw IEX units."""
|
|
|
if threshold is None:
|
|
|
return None
|
|
|
multiplier = max(
|
|
|
1.0,
|
|
|
float(getattr(self._params, "iex_live_intraday_volume_multiplier", 1.0) or 1.0),
|
|
|
)
|
|
|
return float(threshold) / multiplier
|
|
|
|
|
|
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=self._iex_live_intraday_threshold(
|
|
|
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,
|
|
|
iex_live_mode=True,
|
|
|
)
|
|
|
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,
|
|
|
iex_live_mode=True,
|
|
|
)
|
|
|
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,
|
|
|
iex_live_mode=True,
|
|
|
)
|
|
|
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,
|
|
|
iex_live_mode=True,
|
|
|
)
|
|
|
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"]
|
|
|
cand["trigger_type"] = self._candidate_trigger_type(cand)
|
|
|
size_scale = self._candidate_size_scale(cand)
|
|
|
cand["size_scale"] = size_scale
|
|
|
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"],
|
|
|
size_scale=size_scale,
|
|
|
metadata_json=self._candidate_metadata_json(cand),
|
|
|
)
|
|
|
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 _post_orb_bar_high_breakout(
|
|
|
self,
|
|
|
bars: list[dict],
|
|
|
direction: str,
|
|
|
breakout_level: float,
|
|
|
date_str: str,
|
|
|
) -> bool:
|
|
|
"""Return True if any post-ORB bar inside the order timeout crossed the breakout level.
|
|
|
|
|
|
Mirrors libs.intraday.orb_simulator._find_breakout_time semantics: iterate
|
|
|
bars after the ORB candle (ts > market_open, since 5-min bar timestamps
|
|
|
denote bar start and the ORB bar starts at 09:30:00 ET), stop at
|
|
|
order_timeout_minutes from market open, and use bar high (low for short)
|
|
|
as the trigger. Independent of the scheduler's timing — the timeout is
|
|
|
enforced internally so a manually triggered late check cannot enter a
|
|
|
candidate whose only crossing was post-timeout. Used only when
|
|
|
params.live_breakout_use_bar_high is True.
|
|
|
"""
|
|
|
timeout_minutes = int(getattr(self._params, "order_timeout_minutes", 45) or 45)
|
|
|
market_open = _market_open_ts(date_str)
|
|
|
timeout_ts = market_open + dt.timedelta(minutes=timeout_minutes)
|
|
|
for b in bars:
|
|
|
try:
|
|
|
ts = _parse_ts(b["timestamp"])
|
|
|
except Exception:
|
|
|
continue
|
|
|
if ts <= market_open:
|
|
|
continue
|
|
|
if ts > timeout_ts:
|
|
|
break
|
|
|
if direction == "bullish":
|
|
|
bar_high = float(b.get("high") or 0.0)
|
|
|
if bar_high >= breakout_level:
|
|
|
return True
|
|
|
else:
|
|
|
bar_low = float(b.get("low") or 0.0)
|
|
|
if bar_low > 0 and bar_low <= breakout_level:
|
|
|
return True
|
|
|
return False
|
|
|
|
|
|
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:
|
|
|
rebuilt = self._rebuild_pending_candidates(date_str)
|
|
|
self._pending_cands = rebuilt
|
|
|
self._candidates = list(rebuilt)
|
|
|
|
|
|
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)
|
|
|
|
|
|
# Optional: also fetch today's 5-min bars to detect spike-and-retrace
|
|
|
# breakouts that snapshot polling missed (live_breakout_use_bar_high).
|
|
|
use_bar_high = bool(getattr(self._params, "live_breakout_use_bar_high", False))
|
|
|
intraday_bars_today: dict[str, list[dict]] = {}
|
|
|
if use_bar_high:
|
|
|
try:
|
|
|
intraday_bars_today = get_multi_intraday_bars_today(
|
|
|
tickers, interval="5min"
|
|
|
)
|
|
|
except Exception as e:
|
|
|
self._log(f" bar-high intraday fetch failed: {e}")
|
|
|
intraday_bars_today = {}
|
|
|
|
|
|
filled_count = 0
|
|
|
still_pending = []
|
|
|
|
|
|
# Build ticker→rank map from the master candidate list so soft-day
|
|
|
# score_rank_pct matches the simulator (which ranks against the full
|
|
|
# day's candidates, not the shrinking pending list).
|
|
|
master_candidates = self._candidates or self._pending_cands
|
|
|
n_master = len(master_candidates)
|
|
|
ticker_rank_map: dict[str, int] = {
|
|
|
str(c.get("ticker") or ""): i for i, c in enumerate(master_candidates)
|
|
|
}
|
|
|
|
|
|
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"]
|
|
|
trigger_type = self._candidate_trigger_type(cand)
|
|
|
|
|
|
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
|
|
|
opening_burst_entry = self._candidate_uses_opening_burst_entry(cand)
|
|
|
if opening_burst_entry and not self._opening_burst_live_entry_allowed(date_str):
|
|
|
self._state.update_candidate_status(
|
|
|
self._session.session_id, date_str, ticker, "timeout"
|
|
|
)
|
|
|
continue
|
|
|
broke_out = opening_burst_entry or (
|
|
|
(direction == "bullish" and current_price >= breakout_level)
|
|
|
or (direction == "bearish" and current_price <= breakout_level)
|
|
|
)
|
|
|
triggered_by_bar_high = False
|
|
|
if not broke_out and use_bar_high:
|
|
|
bars = intraday_bars_today.get(ticker) or []
|
|
|
if bars and self._post_orb_bar_high_breakout(
|
|
|
bars, direction, breakout_level, date_str
|
|
|
):
|
|
|
broke_out = True
|
|
|
triggered_by_bar_high = True
|
|
|
if not broke_out:
|
|
|
still_pending.append(cand)
|
|
|
continue
|
|
|
|
|
|
# Soft-day primary filter (mirrors orb_simulator.py:9932-9983).
|
|
|
# Without this gate, live would trade primary ORB candidates on
|
|
|
# regime/breadth soft days that the backtest skips by design,
|
|
|
# producing systematic divergence from backtest expectations.
|
|
|
if getattr(self, "_soft_day_reason", None):
|
|
|
cand_rank = ticker_rank_map.get(ticker, n_master - 1)
|
|
|
score_rank_pct = self._score_rank_pct(cand_rank, n_master)
|
|
|
ticker_enrich = self._enrichment.get(ticker, {}).get(date_str, {})
|
|
|
todays_trades = [
|
|
|
t for t in self._state.list_trades(self._session.session_id)
|
|
|
if str(t.get("date")) == date_str
|
|
|
]
|
|
|
total_trades_today = len(open_positions) + len(todays_trades)
|
|
|
reject_reason = self._evaluate_soft_day_primary_filter(
|
|
|
cand,
|
|
|
trigger_type=trigger_type,
|
|
|
score_rank_pct=score_rank_pct,
|
|
|
ticker_enrich=ticker_enrich,
|
|
|
total_trades_today=total_trades_today,
|
|
|
)
|
|
|
if reject_reason is not None:
|
|
|
self._log(
|
|
|
f" {ticker}: {trigger_type} rejected by {reject_reason} "
|
|
|
f"(soft_day={self._soft_day_reason}, "
|
|
|
f"score_rank={score_rank_pct:.2f})"
|
|
|
)
|
|
|
_emit(
|
|
|
"orb_engine_soft_day_reject",
|
|
|
session_id=self._session.session_id,
|
|
|
ticker=ticker,
|
|
|
trigger_type=trigger_type,
|
|
|
reject_reason=reject_reason,
|
|
|
soft_day_reason=self._soft_day_reason,
|
|
|
score_rank_pct=round(float(score_rank_pct), 4),
|
|
|
)
|
|
|
self._state.update_candidate_status(
|
|
|
self._session.session_id, date_str, ticker, "cancelled"
|
|
|
)
|
|
|
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 = (
|
|
|
float(current_price)
|
|
|
if opening_burst_entry
|
|
|
else 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}: {trigger_type} → {shares} shares (order {order.id})"
|
|
|
)
|
|
|
_emit(
|
|
|
"orb_engine_buy_submitted",
|
|
|
session_id=self._session.session_id,
|
|
|
ticker=ticker, direction=direction, qty=shares,
|
|
|
trigger_type=trigger_type,
|
|
|
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
|
|
|
if triggered_by_bar_high:
|
|
|
gap = float(breakout_level) - float(fill_price)
|
|
|
self._log(
|
|
|
f" {ticker}: bar-high trigger (snap retrace) — "
|
|
|
f"breakout_level={breakout_level:.4f}, fill={float(fill_price):.4f}, "
|
|
|
f"gap={gap:.4f}"
|
|
|
)
|
|
|
_emit(
|
|
|
"orb_engine_bar_high_trigger",
|
|
|
session_id=self._session.session_id,
|
|
|
ticker=ticker, direction=direction,
|
|
|
breakout_level=round(float(breakout_level), 4),
|
|
|
fill_price=round(float(fill_price), 4),
|
|
|
gap=round(gap, 4),
|
|
|
category="order",
|
|
|
)
|
|
|
_emit(
|
|
|
"orb_engine_buy_filled",
|
|
|
session_id=self._session.session_id,
|
|
|
ticker=ticker, direction=direction, qty=shares,
|
|
|
fill_price=round(float(fill_price), 4),
|
|
|
trigger_type=trigger_type,
|
|
|
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,
|
|
|
trigger_type=trigger_type,
|
|
|
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),
|
|
|
}
|
|
|
|
|
|
def _now_et(self) -> dt.datetime:
|
|
|
return dt.datetime.now(_ET)
|
|
|
|
|
|
@staticmethod
|
|
|
def _score_rank_pct(rank: int, count: int) -> float:
|
|
|
if count <= 1:
|
|
|
return 1.0
|
|
|
return 1.0 - (rank / max(count - 1, 1))
|
|
|
|
|
|
@staticmethod
|
|
|
def _candidate_direction(cand: dict[str, Any]) -> str | None:
|
|
|
direction = cand.get("direction")
|
|
|
if direction == "bullish":
|
|
|
return "long"
|
|
|
if direction == "bearish":
|
|
|
return "short"
|
|
|
if direction in {"long", "short"}:
|
|
|
return str(direction)
|
|
|
return None
|
|
|
|
|
|
def _candidate_trigger_type(self, cand: dict[str, Any]) -> str:
|
|
|
"""Return the backtest trigger family that this live candidate represents."""
|
|
|
if cand.get("broad_gapup_continuation"):
|
|
|
return "broad_gapup_continuation"
|
|
|
if cand.get("market_thrust_liquid_continuation"):
|
|
|
entry_mode = str(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"market_thrust_liquid_continuation_entry_mode",
|
|
|
"breakout",
|
|
|
)
|
|
|
or "breakout"
|
|
|
).lower()
|
|
|
if entry_mode == "opening_burst":
|
|
|
return "market_thrust_opening_burst"
|
|
|
if entry_mode in {"opening_followthrough", "followthrough_burst"}:
|
|
|
return "market_thrust_opening_followthrough"
|
|
|
return "market_thrust_liquid_continuation"
|
|
|
if cand.get("market_thrust_opening_impulse_reclaim"):
|
|
|
return "market_thrust_opening_impulse_reclaim"
|
|
|
return str(cand.get("trigger_type") or "orb")
|
|
|
|
|
|
def _candidate_uses_opening_burst_entry(self, cand: dict[str, Any]) -> bool:
|
|
|
if cand.get("broad_gapup_continuation"):
|
|
|
entry_mode = str(
|
|
|
getattr(self._params, "broad_gapup_continuation_entry_mode", "breakout")
|
|
|
or "breakout"
|
|
|
).lower()
|
|
|
return entry_mode == "opening_burst"
|
|
|
if cand.get("market_thrust_liquid_continuation"):
|
|
|
entry_mode = str(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"market_thrust_liquid_continuation_entry_mode",
|
|
|
"breakout",
|
|
|
)
|
|
|
or "breakout"
|
|
|
).lower()
|
|
|
return entry_mode == "opening_burst"
|
|
|
if cand.get("market_thrust_opening_impulse_reclaim"):
|
|
|
entry_mode = str(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"market_thrust_opening_impulse_reclaim_entry_mode",
|
|
|
"vwap_reclaim",
|
|
|
)
|
|
|
or "vwap_reclaim"
|
|
|
).lower()
|
|
|
return entry_mode == "opening_burst"
|
|
|
return False
|
|
|
|
|
|
def _opening_burst_live_entry_allowed(self, date_str: str) -> bool:
|
|
|
"""Allow live opening-burst entries only near the intended opening window."""
|
|
|
now_et = self._now_et()
|
|
|
market_open = _market_open_ts(date_str)
|
|
|
max_minutes = getattr(
|
|
|
self._params,
|
|
|
"opening_burst_liquid_max_entry_minutes_after_open",
|
|
|
None,
|
|
|
)
|
|
|
if max_minutes is None:
|
|
|
max_minutes = max(
|
|
|
int(getattr(self._params, "orb_minutes", 5) or 5)
|
|
|
+ int(getattr(self._params, "sim_bar_minutes", 5) or 5),
|
|
|
int(getattr(self._params, "order_timeout_minutes", 25) or 25),
|
|
|
)
|
|
|
return now_et <= market_open + dt.timedelta(minutes=float(max_minutes))
|
|
|
|
|
|
@staticmethod
|
|
|
def _trade_trigger_type(row: Any) -> str:
|
|
|
if isinstance(row, dict):
|
|
|
return str(row.get("trigger_type") or "orb")
|
|
|
return str(getattr(row, "trigger_type", None) or "orb")
|
|
|
|
|
|
def _live_primary_trade_exists(
|
|
|
self,
|
|
|
trades: list[Any],
|
|
|
*,
|
|
|
allowed_primary_families: set[str] | None = None,
|
|
|
) -> bool:
|
|
|
allowed = set(_AUXILIARY_TRIGGER_TYPES)
|
|
|
if allowed_primary_families:
|
|
|
allowed.update(allowed_primary_families)
|
|
|
return any(self._trade_trigger_type(trade) not in allowed for trade in trades)
|
|
|
|
|
|
def _live_trigger_count_today(self, trigger_type: str) -> int:
|
|
|
if not getattr(self, "_date_str", None):
|
|
|
return 0
|
|
|
open_positions = self._state.get_open_positions(
|
|
|
self._session.session_id,
|
|
|
self._date_str,
|
|
|
)
|
|
|
open_count = sum(
|
|
|
1 for pos in open_positions
|
|
|
if self._trade_trigger_type(pos) == trigger_type
|
|
|
)
|
|
|
todays_trades = [
|
|
|
t for t in self._state.list_trades(self._session.session_id)
|
|
|
if str(t.get("date")) == self._date_str
|
|
|
]
|
|
|
closed_count = sum(
|
|
|
1 for trade in todays_trades
|
|
|
if self._trade_trigger_type(trade) == trigger_type
|
|
|
)
|
|
|
return open_count + closed_count
|
|
|
|
|
|
def _nofill_vwap_live_allowed(self, score_rank_pct: float) -> bool:
|
|
|
min_score = getattr(self._params, "nofill_vwap_reclaim_min_score_pct", None)
|
|
|
return min_score is None or score_rank_pct >= float(min_score)
|
|
|
|
|
|
def _candidate_decision_premarket_dollar_vol(self, cand: dict[str, Any]) -> float:
|
|
|
raw = cand.get("filter_premarket_dollar_vol", cand.get("premarket_dollar_vol"))
|
|
|
try:
|
|
|
return float(raw or 0.0)
|
|
|
except (TypeError, ValueError):
|
|
|
return 0.0
|
|
|
|
|
|
def _soft_day_vwap_live_allowed(
|
|
|
self,
|
|
|
cand: dict[str, Any],
|
|
|
score_rank_pct: float,
|
|
|
todays_trades: list[dict[str, Any]],
|
|
|
) -> bool:
|
|
|
if not bool(getattr(self._params, "soft_day_vwap_reclaim_enabled", False)):
|
|
|
return False
|
|
|
soft_day_reason = getattr(self, "_soft_day_reason", None)
|
|
|
if not soft_day_reason:
|
|
|
return False
|
|
|
if not _orb_soft_day_vwap_reason_allowed(self._params, soft_day_reason):
|
|
|
return False
|
|
|
if getattr(self._params, "soft_day_vwap_reclaim_only_when_no_existing_trades", False):
|
|
|
if self._state.get_open_positions(self._session.session_id, self._date_str):
|
|
|
return False
|
|
|
todays_trades = [
|
|
|
t for t in self._state.list_trades(self._session.session_id)
|
|
|
if str(t.get("date")) == self._date_str
|
|
|
]
|
|
|
if todays_trades:
|
|
|
return False
|
|
|
if (
|
|
|
getattr(self._params, "soft_day_vwap_reclaim_only_when_no_primary_trades", False)
|
|
|
and self._live_primary_trade_exists(todays_trades)
|
|
|
):
|
|
|
return False
|
|
|
min_score = _orb_soft_day_vwap_min_score_pct(self._params, soft_day_reason)
|
|
|
if min_score is not None and score_rank_pct < float(min_score):
|
|
|
return False
|
|
|
min_pm = _orb_soft_day_vwap_reason_param(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
"soft_day_vwap_reclaim_min_premarket_dollar_vol",
|
|
|
)
|
|
|
if min_pm is not None and self._candidate_decision_premarket_dollar_vol(cand) < min_pm:
|
|
|
return False
|
|
|
min_ret5 = _orb_soft_day_vwap_reason_param(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
"soft_day_vwap_reclaim_min_ret_5d",
|
|
|
)
|
|
|
ret_5d = cand.get("ret_5d")
|
|
|
try:
|
|
|
ret_5d_float = None if ret_5d is None else float(ret_5d)
|
|
|
except (TypeError, ValueError):
|
|
|
ret_5d_float = None
|
|
|
if min_ret5 is not None and (ret_5d_float is None or ret_5d_float < min_ret5):
|
|
|
return False
|
|
|
gap_down_max_ret5 = _orb_soft_day_vwap_reason_param(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
"soft_day_vwap_reclaim_gap_down_max_ret_5d",
|
|
|
)
|
|
|
gap_pct = cand.get("gap_pct")
|
|
|
try:
|
|
|
gap_pct_float = None if gap_pct is None else float(gap_pct)
|
|
|
except (TypeError, ValueError):
|
|
|
gap_pct_float = None
|
|
|
if (
|
|
|
gap_down_max_ret5 is not None
|
|
|
and gap_pct_float is not None
|
|
|
and gap_pct_float < 0
|
|
|
and ret_5d_float is not None
|
|
|
and ret_5d_float > gap_down_max_ret5
|
|
|
):
|
|
|
return False
|
|
|
weak_rank = _orb_soft_day_vwap_reason_param(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
"soft_day_vwap_reclaim_weak_participation_max_rvol_rank_pct",
|
|
|
)
|
|
|
weak_body = _orb_soft_day_vwap_reason_param(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
"soft_day_vwap_reclaim_weak_participation_max_body_ratio",
|
|
|
)
|
|
|
if weak_rank is not None and weak_body is not None:
|
|
|
try:
|
|
|
rvol_rank = float(cand.get("rvol_rank_pct"))
|
|
|
body_ratio = float(cand.get("body_ratio"))
|
|
|
except (TypeError, ValueError):
|
|
|
rvol_rank = None
|
|
|
body_ratio = None
|
|
|
if (
|
|
|
rvol_rank is not None
|
|
|
and body_ratio is not None
|
|
|
and rvol_rank <= weak_rank
|
|
|
and body_ratio <= weak_body
|
|
|
):
|
|
|
return False
|
|
|
min_rvol = _orb_soft_day_vwap_reason_param(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
"soft_day_vwap_reclaim_min_rvol",
|
|
|
)
|
|
|
max_rvol = _orb_soft_day_vwap_reason_param(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
"soft_day_vwap_reclaim_max_rvol",
|
|
|
)
|
|
|
try:
|
|
|
rvol = float(cand.get("filter_rvol", cand.get("rvol")))
|
|
|
except (TypeError, ValueError):
|
|
|
rvol = None
|
|
|
if min_rvol is not None and (rvol is None or rvol < min_rvol):
|
|
|
return False
|
|
|
if max_rvol is not None and (rvol is None or rvol > max_rvol):
|
|
|
return False
|
|
|
min_body = _orb_soft_day_vwap_reason_param(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
"soft_day_vwap_reclaim_min_body_ratio",
|
|
|
)
|
|
|
if min_body is not None:
|
|
|
try:
|
|
|
body_ratio = float(cand.get("body_ratio"))
|
|
|
except (TypeError, ValueError):
|
|
|
body_ratio = None
|
|
|
if body_ratio is None or body_ratio < min_body:
|
|
|
return False
|
|
|
min_close = _orb_soft_day_vwap_reason_param(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
"soft_day_vwap_reclaim_min_close_location",
|
|
|
)
|
|
|
if min_close is not None:
|
|
|
try:
|
|
|
close_location = float(cand.get("close_location"))
|
|
|
except (TypeError, ValueError):
|
|
|
close_location = None
|
|
|
if close_location is None or close_location < min_close:
|
|
|
return False
|
|
|
return True
|
|
|
|
|
|
def _evaluate_soft_day_primary_filter(
|
|
|
self,
|
|
|
cand: dict[str, Any],
|
|
|
trigger_type: str,
|
|
|
score_rank_pct: float,
|
|
|
ticker_enrich: dict[str, Any],
|
|
|
total_trades_today: int,
|
|
|
) -> str | None:
|
|
|
"""Mirror orb_simulator.py:9932-9983 soft-day gates for primary triggers.
|
|
|
|
|
|
Returns a rejection reason string or None if the candidate passes.
|
|
|
Only applies when soft_day_reason is set; aux/market-thrust triggers are
|
|
|
exempt because they have their own gating earlier in the pipeline.
|
|
|
"""
|
|
|
soft_day_reason = getattr(self, "_soft_day_reason", None)
|
|
|
if not soft_day_reason:
|
|
|
return None
|
|
|
|
|
|
# soft_day_max_trades caps total day trade count for non-exempt triggers.
|
|
|
max_trades = getattr(self._params, "soft_day_max_trades", None)
|
|
|
if (
|
|
|
trigger_type not in _SOFT_DAY_MAX_TRADES_EXEMPT_TRIGGER_TYPES
|
|
|
and max_trades is not None
|
|
|
and total_trades_today >= int(max_trades)
|
|
|
):
|
|
|
return "soft_day_max_trades"
|
|
|
|
|
|
# Score / profile / ret5 / rvol gates only apply to primary breakout
|
|
|
# triggers; aux + market-thrust families are exempt.
|
|
|
if trigger_type in _SOFT_DAY_PRIMARY_FILTER_EXEMPT_TRIGGER_TYPES:
|
|
|
return None
|
|
|
|
|
|
# Sector confirmation override can bypass the score/profile gates.
|
|
|
sector_override_active = _orb_soft_day_sector_confirmation_override_allows(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
cand,
|
|
|
score_rank_pct=score_rank_pct,
|
|
|
trigger_type=trigger_type,
|
|
|
)
|
|
|
max_sector_override = getattr(
|
|
|
self._params,
|
|
|
"soft_day_sector_confirmation_override_max_trades",
|
|
|
1,
|
|
|
)
|
|
|
if (
|
|
|
sector_override_active
|
|
|
and max_sector_override is not None
|
|
|
and self._live_trigger_count_today("sector_confirmation_override")
|
|
|
>= max(0, int(max_sector_override))
|
|
|
):
|
|
|
sector_override_active = False
|
|
|
if sector_override_active:
|
|
|
return None
|
|
|
|
|
|
min_score_pct = getattr(self._params, "soft_day_min_score_pct", None)
|
|
|
if min_score_pct is not None and score_rank_pct < float(min_score_pct):
|
|
|
return "soft_day_score"
|
|
|
|
|
|
min_cand_score = getattr(self._params, "soft_day_min_candidate_score", None)
|
|
|
if min_cand_score is not None:
|
|
|
try:
|
|
|
cand_score = float(cand.get("score") or 0.0)
|
|
|
except (TypeError, ValueError):
|
|
|
cand_score = 0.0
|
|
|
if cand_score < float(min_cand_score):
|
|
|
return "soft_day_candidate_score"
|
|
|
|
|
|
min_ret_5d = getattr(self._params, "soft_day_min_ret_5d", None)
|
|
|
if min_ret_5d is not None:
|
|
|
ret_5d_raw = cand.get("ret_5d", ticker_enrich.get("ret_5d"))
|
|
|
try:
|
|
|
ret_5d_val = None if ret_5d_raw is None else float(ret_5d_raw)
|
|
|
except (TypeError, ValueError):
|
|
|
ret_5d_val = None
|
|
|
if ret_5d_val is None or ret_5d_val < float(min_ret_5d):
|
|
|
return "soft_day_ret5"
|
|
|
|
|
|
max_rvol = getattr(self._params, "soft_day_max_rvol", None)
|
|
|
if max_rvol is not None:
|
|
|
cand_rvol_raw = cand.get("rvol")
|
|
|
try:
|
|
|
cand_rvol = None if cand_rvol_raw is None else float(cand_rvol_raw)
|
|
|
except (TypeError, ValueError):
|
|
|
cand_rvol = None
|
|
|
if cand_rvol is None or cand_rvol > float(max_rvol):
|
|
|
return "soft_day_rvol"
|
|
|
|
|
|
if not _orb_soft_day_setup_profile_allows(
|
|
|
self._params,
|
|
|
soft_day_reason,
|
|
|
cand,
|
|
|
ticker_enrich,
|
|
|
):
|
|
|
return "soft_day_profile"
|
|
|
|
|
|
return None
|
|
|
|
|
|
def _reclaim_trade_slot_available(self, trigger_type: str) -> bool:
|
|
|
counts = getattr(self, "_live_reclaim_trade_counts", {})
|
|
|
if not isinstance(counts, dict):
|
|
|
counts = {}
|
|
|
if trigger_type == "vwap_reclaim":
|
|
|
max_trades = getattr(self._params, "nofill_vwap_reclaim_max_trades", 1)
|
|
|
elif trigger_type == "market_thrust_opening_impulse_reclaim":
|
|
|
max_trades = getattr(
|
|
|
self._params,
|
|
|
"market_thrust_opening_impulse_reclaim_max_trades",
|
|
|
1,
|
|
|
)
|
|
|
if not getattr(self, "_market_thrust_breadth_override_active", False):
|
|
|
no_thrust_max = getattr(
|
|
|
self._params,
|
|
|
"market_thrust_opening_impulse_reclaim_no_thrust_max_trades",
|
|
|
None,
|
|
|
)
|
|
|
if no_thrust_max is not None:
|
|
|
max_trades = no_thrust_max
|
|
|
else:
|
|
|
max_trades = getattr(self._params, "soft_day_vwap_reclaim_max_trades", 1)
|
|
|
live_count = max(
|
|
|
int(counts.get(trigger_type, 0)),
|
|
|
self._live_trigger_count_today(trigger_type),
|
|
|
)
|
|
|
return max_trades is None or live_count < int(max_trades)
|
|
|
|
|
|
def _increment_reclaim_trade_count(self, trigger_type: str) -> None:
|
|
|
counts = getattr(self, "_live_reclaim_trade_counts", {})
|
|
|
if not isinstance(counts, dict):
|
|
|
counts = {}
|
|
|
counts[trigger_type] = int(counts.get(trigger_type, 0)) + 1
|
|
|
self._live_reclaim_trade_counts = counts
|
|
|
|
|
|
def _reclaim_live_size_scale(self, trigger_type: str) -> float:
|
|
|
if trigger_type == "vwap_reclaim":
|
|
|
raw = getattr(self._params, "nofill_vwap_reclaim_size_scale", 1.0)
|
|
|
elif trigger_type == "market_thrust_opening_impulse_reclaim":
|
|
|
raw = getattr(
|
|
|
self._params,
|
|
|
"market_thrust_opening_impulse_reclaim_size_scale",
|
|
|
1.0,
|
|
|
)
|
|
|
if not getattr(self, "_market_thrust_breadth_override_active", False):
|
|
|
no_thrust_raw = getattr(
|
|
|
self._params,
|
|
|
"market_thrust_opening_impulse_reclaim_no_thrust_size_scale",
|
|
|
None,
|
|
|
)
|
|
|
if no_thrust_raw is not None:
|
|
|
raw = no_thrust_raw
|
|
|
else:
|
|
|
raw = _orb_soft_day_vwap_size_scale(
|
|
|
self._params,
|
|
|
getattr(self, "_soft_day_reason", None),
|
|
|
)
|
|
|
return max(0.0, min(1.0, float(raw or 0.0)))
|
|
|
|
|
|
def _market_thrust_impulse_late_live_enabled(self) -> bool:
|
|
|
if not bool(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"market_thrust_opening_impulse_reclaim_enabled",
|
|
|
False,
|
|
|
)
|
|
|
):
|
|
|
return False
|
|
|
entry_mode = str(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"market_thrust_opening_impulse_reclaim_entry_mode",
|
|
|
"vwap_reclaim",
|
|
|
)
|
|
|
or "vwap_reclaim"
|
|
|
).lower()
|
|
|
return entry_mode == "late_breakout"
|
|
|
|
|
|
def _market_thrust_impulse_late_live_allowed(
|
|
|
self,
|
|
|
cand: dict[str, Any],
|
|
|
score_rank_pct: float,
|
|
|
todays_trades: list[dict[str, Any]],
|
|
|
) -> bool:
|
|
|
if not self._market_thrust_impulse_late_live_enabled():
|
|
|
return False
|
|
|
if not bool(cand.get("market_thrust_opening_impulse_reclaim")):
|
|
|
return False
|
|
|
if (
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"market_thrust_opening_impulse_reclaim_only_when_no_primary_trades",
|
|
|
False,
|
|
|
)
|
|
|
and not getattr(self, "_market_thrust_breadth_override_active", False)
|
|
|
and self._live_primary_trade_exists(
|
|
|
todays_trades,
|
|
|
allowed_primary_families=(
|
|
|
_MARKET_THRUST_LIQUID_TRIGGER_TYPES
|
|
|
| _MARKET_THRUST_IMPULSE_TRIGGER_TYPES
|
|
|
),
|
|
|
)
|
|
|
):
|
|
|
return False
|
|
|
min_score = getattr(
|
|
|
self._params,
|
|
|
"market_thrust_opening_impulse_reclaim_min_score_pct",
|
|
|
None,
|
|
|
)
|
|
|
return min_score is None or score_rank_pct >= float(min_score)
|
|
|
|
|
|
def _submit_live_entry(
|
|
|
self,
|
|
|
cand: dict[str, Any],
|
|
|
date_str: str,
|
|
|
*,
|
|
|
current_price: float,
|
|
|
entry_price_est: float,
|
|
|
trigger_label: str,
|
|
|
) -> bool:
|
|
|
ticker = cand["ticker"]
|
|
|
direction = cand["direction"]
|
|
|
orb_bar = cand["orb_bar"]
|
|
|
atr = cand["atr"]
|
|
|
score = cand["score"]
|
|
|
rvol = cand.get("rvol")
|
|
|
|
|
|
open_positions = self._state.get_open_positions(self._session.session_id, date_str)
|
|
|
if any(p.ticker == ticker for p in open_positions):
|
|
|
return False
|
|
|
max_sim = getattr(self._params, "max_simultaneous_entries", None)
|
|
|
if max_sim is not None and len(open_positions) >= int(max_sim):
|
|
|
return False
|
|
|
|
|
|
stop_distance = atr * self._params.atr_stop_multiplier
|
|
|
if stop_distance <= 0:
|
|
|
return False
|
|
|
|
|
|
equity = self._get_equity()
|
|
|
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}: {trigger_label} sizing disabled "
|
|
|
f"(day={day_size_scale:.3f}, candidate={candidate_size_scale:.3f})"
|
|
|
)
|
|
|
return False
|
|
|
|
|
|
risk_dollars = sizing_capital * self._params.risk_per_trade_pct
|
|
|
shares_from_risk = risk_dollars / stop_distance
|
|
|
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:
|
|
|
return False
|
|
|
|
|
|
try:
|
|
|
acct = self._broker.get_account()
|
|
|
if acct.buying_power < shares * entry_price_est:
|
|
|
self._log(f" {ticker}: insufficient buying power for {trigger_label}")
|
|
|
return False
|
|
|
except Exception as exc:
|
|
|
self._log(f" {ticker}: account check error during {trigger_label}: {exc}")
|
|
|
|
|
|
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}: {trigger_label} → {shares} shares "
|
|
|
f"(order {order.id})"
|
|
|
)
|
|
|
_emit(
|
|
|
"orb_engine_reclaim_buy_submitted",
|
|
|
session_id=self._session.session_id,
|
|
|
ticker=ticker,
|
|
|
direction=direction,
|
|
|
qty=shares,
|
|
|
trigger_type=trigger_label,
|
|
|
entry_price_est=round(entry_price_est, 4),
|
|
|
current_price=round(current_price, 4),
|
|
|
order_id=order.id,
|
|
|
category="order",
|
|
|
)
|
|
|
except Exception as exc:
|
|
|
self._log(f" {ticker}: {trigger_label} order failed: {exc}")
|
|
|
return False
|
|
|
|
|
|
fill_price = entry_price_est
|
|
|
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"):
|
|
|
return False
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
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=self._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,
|
|
|
trigger_type=trigger_label,
|
|
|
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",
|
|
|
)
|
|
|
_emit(
|
|
|
"orb_engine_reclaim_buy_filled",
|
|
|
session_id=self._session.session_id,
|
|
|
ticker=ticker,
|
|
|
direction=direction,
|
|
|
qty=shares,
|
|
|
fill_price=round(float(fill_price), 4),
|
|
|
trigger_type=trigger_label,
|
|
|
order_id=order.id,
|
|
|
category="order",
|
|
|
)
|
|
|
return True
|
|
|
|
|
|
def run_reclaim_check(self, date_str: str) -> dict[str, Any]:
|
|
|
"""Live timed-entry check for fallback sleeves.
|
|
|
|
|
|
Backtests generate VWAP reclaim and market-thrust late-breakout entries
|
|
|
as timed pass-2 entries. Live needs an explicit polling path because
|
|
|
normal breakout checks end at `order_timeout_minutes`, while these
|
|
|
windows usually start later.
|
|
|
"""
|
|
|
self._date_str = date_str
|
|
|
vwap_reclaim_enabled = (
|
|
|
bool(getattr(self._params, "nofill_vwap_reclaim_enabled", False))
|
|
|
or bool(getattr(self._params, "soft_day_vwap_reclaim_enabled", False))
|
|
|
)
|
|
|
impulse_late_enabled = self._market_thrust_impulse_late_live_enabled()
|
|
|
if not (vwap_reclaim_enabled or impulse_late_enabled):
|
|
|
return {"reclaim_checked": 0, "reclaim_filled": 0, "skip_reason": "disabled"}
|
|
|
daily_state = self._state.get_daily_state(self._session.session_id, date_str)
|
|
|
if bool(getattr(daily_state, "kill_switch", False)):
|
|
|
return {"reclaim_checked": 0, "reclaim_filled": 0, "skip_reason": "kill_switch"}
|
|
|
|
|
|
now_et = self._now_et()
|
|
|
market_open = _market_open_ts(date_str)
|
|
|
windows: list[tuple[int, int]] = []
|
|
|
if vwap_reclaim_enabled:
|
|
|
windows.append(
|
|
|
(
|
|
|
int(getattr(self._params, "vwap_reclaim_window_start_min", 30) or 30),
|
|
|
int(getattr(self._params, "vwap_reclaim_window_end_min", 150) or 150),
|
|
|
)
|
|
|
)
|
|
|
if impulse_late_enabled:
|
|
|
windows.append(
|
|
|
(
|
|
|
int(getattr(self._params, "late_breakout_window_start_min", 30) or 30),
|
|
|
int(getattr(self._params, "late_breakout_window_end_min", 150) or 150),
|
|
|
)
|
|
|
)
|
|
|
window_start = market_open + dt.timedelta(minutes=min(start for start, _ in windows))
|
|
|
window_end = market_open + dt.timedelta(minutes=max(end for _, end in windows))
|
|
|
if now_et < window_start:
|
|
|
return {"reclaim_checked": 0, "reclaim_filled": 0, "skip_reason": "not_started"}
|
|
|
grace_minutes = max(5, int(getattr(self._params, "sim_bar_minutes", 5) or 5))
|
|
|
if now_et > window_end + dt.timedelta(minutes=grace_minutes):
|
|
|
return {"reclaim_checked": 0, "reclaim_filled": 0, "skip_reason": "ended"}
|
|
|
|
|
|
candidates = list(getattr(self, "_candidates", []) or [])
|
|
|
if not candidates:
|
|
|
rebuilt = self._rebuild_pending_candidates(date_str)
|
|
|
self._pending_cands = rebuilt
|
|
|
self._candidates = list(rebuilt)
|
|
|
candidates = list(rebuilt)
|
|
|
if not candidates:
|
|
|
return {"reclaim_checked": 0, "reclaim_filled": 0, "skip_reason": "no_live_candidates"}
|
|
|
|
|
|
open_positions = self._state.get_open_positions(self._session.session_id, date_str)
|
|
|
open_tickers = {p.ticker for p in open_positions}
|
|
|
todays_trades = [
|
|
|
t for t in self._state.list_trades(self._session.session_id)
|
|
|
if str(t.get("date")) == date_str
|
|
|
]
|
|
|
traded_tickers = {str(t.get("ticker")) for t in todays_trades}
|
|
|
unavailable_tickers = open_tickers | traded_tickers
|
|
|
|
|
|
max_trades = getattr(self._params, "max_trades_per_day", None)
|
|
|
if max_trades is not None and len(open_positions) + len(todays_trades) >= int(max_trades):
|
|
|
return {"reclaim_checked": 0, "reclaim_filled": 0, "skip_reason": "max_trades"}
|
|
|
|
|
|
tickers = [
|
|
|
str(c.get("ticker"))
|
|
|
for c in candidates
|
|
|
if c.get("ticker") and str(c.get("ticker")) not in unavailable_tickers
|
|
|
]
|
|
|
if not tickers:
|
|
|
return {"reclaim_checked": 0, "reclaim_filled": 0, "skip_reason": "no_available_candidates"}
|
|
|
|
|
|
try:
|
|
|
bars_raw = self._broker.get_intraday_bars(
|
|
|
tickers,
|
|
|
start=market_open,
|
|
|
end=now_et.replace(second=0, microsecond=0),
|
|
|
timeframe_minutes=5,
|
|
|
)
|
|
|
except Exception as exc:
|
|
|
self._log(f" reclaim intraday fetch failed: {exc}")
|
|
|
return {"reclaim_checked": len(tickers), "reclaim_filled": 0, "skip_reason": "bars_failed"}
|
|
|
|
|
|
bars_by_ticker = intraday_bars_to_format(bars_raw)
|
|
|
snapshots = get_snapshots(tickers)
|
|
|
seen_signals: set[tuple[str, str, str]] = getattr(
|
|
|
self,
|
|
|
"_live_reclaim_seen_signals",
|
|
|
set(),
|
|
|
)
|
|
|
self._live_reclaim_seen_signals = seen_signals
|
|
|
|
|
|
signals: list[tuple[dt.datetime, float, dict[str, Any], str, float]] = []
|
|
|
for rank, base_cand in enumerate(candidates):
|
|
|
ticker = str(base_cand.get("ticker") or "")
|
|
|
if not ticker or ticker in unavailable_tickers:
|
|
|
continue
|
|
|
mkt_bars = filter_market_hours(bars_by_ticker.get(ticker, []))
|
|
|
if not mkt_bars:
|
|
|
continue
|
|
|
cand = dict(base_cand)
|
|
|
cand["mkt_bars"] = mkt_bars
|
|
|
score_rank_pct = self._score_rank_pct(rank, len(candidates))
|
|
|
direction = self._candidate_direction(cand)
|
|
|
if direction is None:
|
|
|
continue
|
|
|
|
|
|
if bool(getattr(self._params, "nofill_vwap_reclaim_enabled", False)):
|
|
|
nofill = _find_vwap_reclaim_time(
|
|
|
mkt_bars,
|
|
|
cand["orb_bar"],
|
|
|
direction,
|
|
|
self._params,
|
|
|
date_str,
|
|
|
)
|
|
|
if nofill is not None and self._nofill_vwap_live_allowed(score_rank_pct):
|
|
|
key = (ticker, "vwap_reclaim", nofill[0].isoformat())
|
|
|
if key not in seen_signals:
|
|
|
signals.append((nofill[0], nofill[1], cand, "vwap_reclaim", score_rank_pct))
|
|
|
|
|
|
if self._soft_day_vwap_live_allowed(cand, score_rank_pct, todays_trades):
|
|
|
soft = _find_vwap_reclaim_time(
|
|
|
mkt_bars,
|
|
|
cand["orb_bar"],
|
|
|
direction,
|
|
|
self._params,
|
|
|
date_str,
|
|
|
)
|
|
|
if soft is not None:
|
|
|
key = (ticker, "soft_day_vwap_reclaim", soft[0].isoformat())
|
|
|
if key not in seen_signals:
|
|
|
signals.append((soft[0], soft[1], cand, "soft_day_vwap_reclaim", score_rank_pct))
|
|
|
|
|
|
if self._market_thrust_impulse_late_live_allowed(
|
|
|
cand,
|
|
|
score_rank_pct,
|
|
|
todays_trades,
|
|
|
):
|
|
|
late = _find_late_breakout_time(
|
|
|
mkt_bars,
|
|
|
cand["orb_bar"],
|
|
|
direction,
|
|
|
self._params,
|
|
|
date_str,
|
|
|
)
|
|
|
if late is not None:
|
|
|
key = (
|
|
|
ticker,
|
|
|
"market_thrust_opening_impulse_reclaim",
|
|
|
late[0].isoformat(),
|
|
|
)
|
|
|
if key not in seen_signals:
|
|
|
signals.append(
|
|
|
(
|
|
|
late[0],
|
|
|
late[1],
|
|
|
cand,
|
|
|
"market_thrust_opening_impulse_reclaim",
|
|
|
score_rank_pct,
|
|
|
)
|
|
|
)
|
|
|
|
|
|
if not signals:
|
|
|
return {"reclaim_checked": len(tickers), "reclaim_filled": 0}
|
|
|
|
|
|
signals.sort(key=lambda item: (item[0], -float(item[2].get("score") or 0.0)))
|
|
|
filled = 0
|
|
|
for signal_ts, signal_price, cand, trigger_type, _score_rank_pct in signals:
|
|
|
if signal_ts > now_et:
|
|
|
continue
|
|
|
key = (cand["ticker"], trigger_type, signal_ts.isoformat())
|
|
|
if key in seen_signals:
|
|
|
continue
|
|
|
if not self._reclaim_trade_slot_available(trigger_type):
|
|
|
continue
|
|
|
snap = snapshots.get(cand["ticker"])
|
|
|
if snap is None or snap.price is None:
|
|
|
continue
|
|
|
sleeve_scale = self._reclaim_live_size_scale(trigger_type)
|
|
|
base_scale = self._candidate_size_scale(cand)
|
|
|
cand["size_scale"] = max(0.0, min(1.0, base_scale * sleeve_scale))
|
|
|
if self._submit_live_entry(
|
|
|
cand,
|
|
|
date_str,
|
|
|
current_price=float(snap.price),
|
|
|
entry_price_est=max(float(snap.price), float(signal_price)),
|
|
|
trigger_label=trigger_type,
|
|
|
):
|
|
|
seen_signals.add(key)
|
|
|
self._increment_reclaim_trade_count(trigger_type)
|
|
|
filled += 1
|
|
|
if max_trades is not None and len(open_positions) + len(todays_trades) + filled >= int(max_trades):
|
|
|
break
|
|
|
|
|
|
if filled:
|
|
|
self._log(f"Reclaim check: filled={filled}, checked={len(tickers)}")
|
|
|
return {"reclaim_checked": len(tickers), "reclaim_filled": filled}
|
|
|
|
|
|
# ── 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
|
|
|
reclaim_summary = self.run_reclaim_check(date_str)
|
|
|
|
|
|
positions = self._state.get_open_positions(self._session.session_id, date_str)
|
|
|
if not positions:
|
|
|
return {"positions_checked": 0, "stops_hit": 0, **reclaim_summary}
|
|
|
|
|
|
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, **reclaim_summary}
|
|
|
|
|
|
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, **reclaim_summary}
|
|
|
|
|
|
# ── 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,
|
|
|
trigger_type=pos.trigger_type,
|
|
|
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."""
|
|
|
stored_scale = cand.get("size_scale")
|
|
|
if stored_scale is not None:
|
|
|
return max(0.0, min(1.0, float(stored_scale)))
|
|
|
|
|
|
def _float(value: Any) -> float | None:
|
|
|
try:
|
|
|
return None if value is None else float(value)
|
|
|
except (TypeError, ValueError):
|
|
|
return None
|
|
|
|
|
|
def _trigger_allowed(allowed: Any, trigger: str) -> bool:
|
|
|
return not allowed or trigger in {str(item) for item in allowed}
|
|
|
|
|
|
def _ticker_enrichment() -> dict[str, Any]:
|
|
|
enrichment = getattr(self, "_enrichment", {}) or {}
|
|
|
if not isinstance(enrichment, dict):
|
|
|
return {}
|
|
|
raw = enrichment.get(str(cand.get("ticker") or ""))
|
|
|
return raw if isinstance(raw, dict) else {}
|
|
|
|
|
|
scale = 1.0
|
|
|
trigger_type = self._candidate_trigger_type(cand)
|
|
|
gap_pct = _float(cand.get("gap_pct"))
|
|
|
enrich = _ticker_enrichment()
|
|
|
ret_5d = _float(cand.get("ret_5d", enrich.get("ret_5d")))
|
|
|
raw_premarket_dollar_vol = _float(
|
|
|
cand.get("premarket_dollar_vol", enrich.get("premarket_dollar_vol"))
|
|
|
)
|
|
|
filter_premarket_dollar_vol = _float(cand.get("filter_premarket_dollar_vol"))
|
|
|
decision_premarket_dollar_vol = (
|
|
|
filter_premarket_dollar_vol
|
|
|
if filter_premarket_dollar_vol is not None
|
|
|
else raw_premarket_dollar_vol
|
|
|
)
|
|
|
premarket_dollar_vol = decision_premarket_dollar_vol or 0.0
|
|
|
rvol = _float(cand.get("filter_rvol"))
|
|
|
if rvol is None:
|
|
|
rvol = _float(cand.get("rvol"))
|
|
|
first_bar_dollar_vol = _float(cand.get("filter_first_bar_dollar_vol"))
|
|
|
if first_bar_dollar_vol is None:
|
|
|
first_bar_dollar_vol = _float(cand.get("first_bar_dollar_vol"))
|
|
|
body_ratio = _float(cand.get("body_ratio"))
|
|
|
close_location = _float(cand.get("close_location"))
|
|
|
orb_return = _float(cand.get("orb_return"))
|
|
|
|
|
|
if bool(getattr(self._params, "broad_gapup_continuation_enabled", False)):
|
|
|
is_broad_gapup = bool(cand.get("broad_gapup_continuation"))
|
|
|
if not is_broad_gapup:
|
|
|
max_gap = getattr(self._params, "max_gap_pct", None)
|
|
|
is_broad_gapup = (
|
|
|
max_gap is not None
|
|
|
and gap_pct is not None
|
|
|
and gap_pct > float(max_gap)
|
|
|
)
|
|
|
if is_broad_gapup:
|
|
|
raw_scale = getattr(
|
|
|
self._params,
|
|
|
"broad_gapup_continuation_size_scale",
|
|
|
1.0,
|
|
|
)
|
|
|
scale *= max(0.0, min(1.0, float(raw_scale or 0.0)))
|
|
|
|
|
|
is_bullish = cand.get("direction") in {"bullish", "long"}
|
|
|
sector_confirmed = bool(cand.get("sector_confirmation_active"))
|
|
|
overextended_min_gap = getattr(
|
|
|
self._params,
|
|
|
"overextended_downside_reclaim_min_abs_gap_pct",
|
|
|
None,
|
|
|
)
|
|
|
overextended_min_ret = getattr(
|
|
|
self._params,
|
|
|
"overextended_downside_reclaim_min_ret_5d",
|
|
|
None,
|
|
|
)
|
|
|
if (
|
|
|
is_bullish
|
|
|
and gap_pct is not None
|
|
|
and overextended_min_gap is not None
|
|
|
and overextended_min_ret is not None
|
|
|
and gap_pct <= -float(overextended_min_gap)
|
|
|
and ret_5d is not None
|
|
|
and ret_5d >= float(overextended_min_ret)
|
|
|
and _trigger_allowed(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"overextended_downside_reclaim_allowed_trigger_types",
|
|
|
None,
|
|
|
),
|
|
|
trigger_type,
|
|
|
)
|
|
|
and (
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"overextended_downside_reclaim_min_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
is None
|
|
|
or premarket_dollar_vol >= float(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"overextended_downside_reclaim_min_premarket_dollar_vol",
|
|
|
)
|
|
|
)
|
|
|
)
|
|
|
):
|
|
|
raw_scale = getattr(
|
|
|
self._params,
|
|
|
"overextended_downside_reclaim_size_scale",
|
|
|
1.0,
|
|
|
)
|
|
|
scale *= max(0.0, min(1.0, float(raw_scale or 0.0)))
|
|
|
|
|
|
mid_attention_scale = getattr(
|
|
|
self._params,
|
|
|
"mid_attention_exhaustion_size_scale",
|
|
|
1.0,
|
|
|
)
|
|
|
if (
|
|
|
is_bullish
|
|
|
and mid_attention_scale is not None
|
|
|
and float(mid_attention_scale) < 1.0
|
|
|
and rvol is not None
|
|
|
and getattr(self._params, "mid_attention_exhaustion_min_rvol", None) is not None
|
|
|
and getattr(self._params, "mid_attention_exhaustion_max_rvol", None) is not None
|
|
|
and getattr(
|
|
|
self._params,
|
|
|
"mid_attention_exhaustion_min_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
is not None
|
|
|
and getattr(
|
|
|
self._params,
|
|
|
"mid_attention_exhaustion_max_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
is not None
|
|
|
and _trigger_allowed(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"mid_attention_exhaustion_allowed_trigger_types",
|
|
|
None,
|
|
|
),
|
|
|
trigger_type,
|
|
|
)
|
|
|
and float(getattr(self._params, "mid_attention_exhaustion_min_rvol"))
|
|
|
<= rvol
|
|
|
<= float(getattr(self._params, "mid_attention_exhaustion_max_rvol"))
|
|
|
and float(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"mid_attention_exhaustion_min_premarket_dollar_vol",
|
|
|
)
|
|
|
)
|
|
|
<= premarket_dollar_vol
|
|
|
<= float(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"mid_attention_exhaustion_max_premarket_dollar_vol",
|
|
|
)
|
|
|
)
|
|
|
):
|
|
|
scale *= max(0.0, min(1.0, float(mid_attention_scale)))
|
|
|
|
|
|
mid_liquidity_scale = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_size_scale",
|
|
|
1.0,
|
|
|
)
|
|
|
if (
|
|
|
is_bullish
|
|
|
and mid_liquidity_scale is not None
|
|
|
and float(mid_liquidity_scale) < 1.0
|
|
|
and decision_premarket_dollar_vol is not None
|
|
|
and _trigger_allowed(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_allowed_trigger_types",
|
|
|
None,
|
|
|
),
|
|
|
trigger_type,
|
|
|
)
|
|
|
):
|
|
|
thin_min_pm = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_thin_min_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
thin_max_pm = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_thin_max_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
thin_active = False
|
|
|
if thin_min_pm is not None and thin_max_pm is not None:
|
|
|
thin_profile = (
|
|
|
float(thin_min_pm)
|
|
|
<= decision_premarket_dollar_vol
|
|
|
<= float(thin_max_pm)
|
|
|
)
|
|
|
if thin_profile:
|
|
|
thin_clauses = []
|
|
|
thin_max_rvol = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_thin_max_rvol",
|
|
|
None,
|
|
|
)
|
|
|
if thin_max_rvol is not None:
|
|
|
thin_clauses.append(
|
|
|
rvol is not None and rvol <= float(thin_max_rvol)
|
|
|
)
|
|
|
thin_min_ret = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_thin_min_ret_5d",
|
|
|
None,
|
|
|
)
|
|
|
thin_max_ret = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_thin_max_ret_5d",
|
|
|
None,
|
|
|
)
|
|
|
if thin_min_ret is not None and thin_max_ret is not None:
|
|
|
thin_clauses.append(
|
|
|
ret_5d is not None
|
|
|
and float(thin_min_ret)
|
|
|
<= ret_5d
|
|
|
<= float(thin_max_ret)
|
|
|
)
|
|
|
thin_min_body = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_thin_min_body_ratio",
|
|
|
None,
|
|
|
)
|
|
|
if thin_min_body is not None:
|
|
|
thin_clauses.append(
|
|
|
body_ratio is not None
|
|
|
and body_ratio >= float(thin_min_body)
|
|
|
)
|
|
|
thin_active = any(thin_clauses)
|
|
|
|
|
|
mid_min_pm = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_mid_min_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
mid_max_pm = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_mid_max_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
mid_min_body = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_mid_min_body_ratio",
|
|
|
None,
|
|
|
)
|
|
|
mid_max_body = getattr(
|
|
|
self._params,
|
|
|
"mid_liquidity_fragility_mid_max_body_ratio",
|
|
|
None,
|
|
|
)
|
|
|
mid_active = (
|
|
|
mid_min_pm is not None
|
|
|
and mid_max_pm is not None
|
|
|
and mid_min_body is not None
|
|
|
and mid_max_body is not None
|
|
|
and body_ratio is not None
|
|
|
and float(mid_min_pm)
|
|
|
<= decision_premarket_dollar_vol
|
|
|
<= float(mid_max_pm)
|
|
|
and float(mid_min_body) <= body_ratio <= float(mid_max_body)
|
|
|
)
|
|
|
if thin_active or mid_active:
|
|
|
scale *= max(0.0, min(1.0, float(mid_liquidity_scale)))
|
|
|
|
|
|
orphan_thin_scale = getattr(
|
|
|
self._params,
|
|
|
"orphan_thin_attention_size_scale",
|
|
|
1.0,
|
|
|
)
|
|
|
orphan_thin_max_pm = getattr(
|
|
|
self._params,
|
|
|
"orphan_thin_attention_max_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
if (
|
|
|
is_bullish
|
|
|
and orphan_thin_scale is not None
|
|
|
and float(orphan_thin_scale) < 1.0
|
|
|
and orphan_thin_max_pm is not None
|
|
|
and decision_premarket_dollar_vol is not None
|
|
|
and decision_premarket_dollar_vol <= float(orphan_thin_max_pm)
|
|
|
and not sector_confirmed
|
|
|
and _trigger_allowed(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"orphan_thin_attention_allowed_trigger_types",
|
|
|
None,
|
|
|
),
|
|
|
trigger_type,
|
|
|
)
|
|
|
):
|
|
|
scale *= max(0.0, min(1.0, float(orphan_thin_scale)))
|
|
|
|
|
|
gap_fill_trap_scale = getattr(
|
|
|
self._params,
|
|
|
"gap_up_fill_trap_size_scale",
|
|
|
1.0,
|
|
|
)
|
|
|
gap_fill_trap_max_orb_return = getattr(
|
|
|
self._params,
|
|
|
"gap_up_fill_trap_max_orb_return",
|
|
|
None,
|
|
|
)
|
|
|
if (
|
|
|
is_bullish
|
|
|
and gap_fill_trap_scale is not None
|
|
|
and float(gap_fill_trap_scale) < 1.0
|
|
|
and gap_fill_trap_max_orb_return is not None
|
|
|
and orb_return is not None
|
|
|
and orb_return <= float(gap_fill_trap_max_orb_return)
|
|
|
and _trigger_allowed(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"gap_up_fill_trap_allowed_trigger_types",
|
|
|
None,
|
|
|
),
|
|
|
trigger_type,
|
|
|
)
|
|
|
):
|
|
|
gap_fill_exit_active = bool(cand.get("gap_up_fill_exit_active"))
|
|
|
gap_fill_min_gap = getattr(
|
|
|
self._params,
|
|
|
"gap_up_fill_exit_min_gap_pct",
|
|
|
None,
|
|
|
)
|
|
|
if not gap_fill_exit_active and gap_fill_min_gap is not None:
|
|
|
gap_fill_exit_active = (
|
|
|
gap_pct is not None
|
|
|
and gap_pct >= float(gap_fill_min_gap)
|
|
|
and (
|
|
|
getattr(self._params, "gap_up_fill_exit_max_gap_pct", None)
|
|
|
is None
|
|
|
or gap_pct
|
|
|
<= float(getattr(self._params, "gap_up_fill_exit_max_gap_pct"))
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "gap_up_fill_exit_min_ret_5d", None)
|
|
|
is None
|
|
|
or (
|
|
|
ret_5d is not None
|
|
|
and ret_5d
|
|
|
>= float(getattr(self._params, "gap_up_fill_exit_min_ret_5d"))
|
|
|
)
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "gap_up_fill_exit_max_ret_5d", None)
|
|
|
is None
|
|
|
or (
|
|
|
ret_5d is not None
|
|
|
and ret_5d
|
|
|
<= float(getattr(self._params, "gap_up_fill_exit_max_ret_5d"))
|
|
|
)
|
|
|
)
|
|
|
and (
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"gap_up_fill_exit_max_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
is None
|
|
|
or premarket_dollar_vol
|
|
|
<= float(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"gap_up_fill_exit_max_premarket_dollar_vol",
|
|
|
)
|
|
|
)
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "gap_up_fill_exit_min_body_ratio", None)
|
|
|
is None
|
|
|
or (
|
|
|
body_ratio is not None
|
|
|
and body_ratio
|
|
|
>= float(getattr(self._params, "gap_up_fill_exit_min_body_ratio"))
|
|
|
)
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "gap_up_fill_exit_max_body_ratio", None)
|
|
|
is None
|
|
|
or (
|
|
|
body_ratio is not None
|
|
|
and body_ratio
|
|
|
<= float(getattr(self._params, "gap_up_fill_exit_max_body_ratio"))
|
|
|
)
|
|
|
)
|
|
|
and (
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"gap_up_fill_exit_max_close_location",
|
|
|
None,
|
|
|
)
|
|
|
is None
|
|
|
or (
|
|
|
close_location is not None
|
|
|
and close_location
|
|
|
<= float(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"gap_up_fill_exit_max_close_location",
|
|
|
)
|
|
|
)
|
|
|
)
|
|
|
)
|
|
|
)
|
|
|
if gap_fill_exit_active:
|
|
|
scale *= max(0.0, min(1.0, float(gap_fill_trap_scale)))
|
|
|
|
|
|
low_quality_scale = getattr(
|
|
|
self._params,
|
|
|
"low_candidate_quality_size_scale",
|
|
|
1.0,
|
|
|
)
|
|
|
low_quality_max_score = getattr(
|
|
|
self._params,
|
|
|
"low_candidate_quality_max_score",
|
|
|
None,
|
|
|
)
|
|
|
candidate_score = _float(cand.get("score", cand.get("candidate_score")))
|
|
|
if (
|
|
|
is_bullish
|
|
|
and low_quality_scale is not None
|
|
|
and float(low_quality_scale) < 1.0
|
|
|
and low_quality_max_score is not None
|
|
|
and candidate_score is not None
|
|
|
and candidate_score <= float(low_quality_max_score)
|
|
|
and _trigger_allowed(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"low_candidate_quality_allowed_trigger_types",
|
|
|
None,
|
|
|
),
|
|
|
trigger_type,
|
|
|
)
|
|
|
):
|
|
|
scale *= max(0.0, min(1.0, float(low_quality_scale)))
|
|
|
|
|
|
unsupported_scale = getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_size_scale",
|
|
|
1.0,
|
|
|
)
|
|
|
if (
|
|
|
is_bullish
|
|
|
and unsupported_scale is not None
|
|
|
and float(unsupported_scale) < 1.0
|
|
|
and _trigger_allowed(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_allowed_trigger_types",
|
|
|
None,
|
|
|
),
|
|
|
trigger_type,
|
|
|
)
|
|
|
):
|
|
|
high_conviction_scales = [
|
|
|
_float(cand.get(name)) or 1.0
|
|
|
for name in (
|
|
|
"red_to_green_acceleration_size_scale",
|
|
|
"liquid_leader_conviction_size_scale",
|
|
|
"opening_burst_liquid_size_scale",
|
|
|
"ownership_initial_size_scale",
|
|
|
"form4_size_scale",
|
|
|
)
|
|
|
]
|
|
|
liquid_min_pm = getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_liquid_min_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
liquid_max_score = getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_liquid_max_candidate_score",
|
|
|
None,
|
|
|
)
|
|
|
liquid_active = (
|
|
|
liquid_min_pm is not None
|
|
|
and liquid_max_score is not None
|
|
|
and decision_premarket_dollar_vol is not None
|
|
|
and decision_premarket_dollar_vol >= float(liquid_min_pm)
|
|
|
and candidate_score is not None
|
|
|
and candidate_score <= float(liquid_max_score)
|
|
|
and (
|
|
|
not bool(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_liquid_require_no_sector_confirmation",
|
|
|
True,
|
|
|
)
|
|
|
)
|
|
|
or not sector_confirmed
|
|
|
)
|
|
|
)
|
|
|
|
|
|
thin_max_pm = getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_thin_max_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
thin_min_gap = getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_thin_min_gap_pct",
|
|
|
None,
|
|
|
)
|
|
|
thin_min_rvol = getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_thin_min_rvol",
|
|
|
None,
|
|
|
)
|
|
|
thin_max_first_bar = getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_thin_max_first_bar_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
thin_active = (
|
|
|
thin_max_pm is not None
|
|
|
and thin_min_gap is not None
|
|
|
and thin_min_rvol is not None
|
|
|
and decision_premarket_dollar_vol is not None
|
|
|
and decision_premarket_dollar_vol <= float(thin_max_pm)
|
|
|
and gap_pct is not None
|
|
|
and gap_pct >= float(thin_min_gap)
|
|
|
and rvol is not None
|
|
|
and rvol >= float(thin_min_rvol)
|
|
|
and (
|
|
|
thin_max_first_bar is None
|
|
|
or (
|
|
|
first_bar_dollar_vol is not None
|
|
|
and first_bar_dollar_vol <= float(thin_max_first_bar)
|
|
|
)
|
|
|
)
|
|
|
)
|
|
|
thin_max_rvol = getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_thin_max_rvol",
|
|
|
None,
|
|
|
)
|
|
|
if thin_active and thin_max_rvol is not None:
|
|
|
thin_active = rvol is not None and rvol <= float(thin_max_rvol)
|
|
|
|
|
|
ignore_high_conviction = bool(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"unsupported_attention_ignore_high_conviction",
|
|
|
True,
|
|
|
)
|
|
|
)
|
|
|
if (
|
|
|
(liquid_active or thin_active)
|
|
|
and (
|
|
|
not ignore_high_conviction
|
|
|
or not any(item > 1.0 for item in high_conviction_scales)
|
|
|
)
|
|
|
):
|
|
|
scale *= max(0.0, min(1.0, float(unsupported_scale)))
|
|
|
|
|
|
if is_bullish and gap_pct is not None and not sector_confirmed:
|
|
|
body_ratio = _float(cand.get("body_ratio")) or 0.0
|
|
|
close_location = _float(cand.get("close_location")) or 0.0
|
|
|
orb_return = _float(cand.get("orb_return")) or 0.0
|
|
|
score_rank_pct = _float(cand.get("score_rank_pct"))
|
|
|
|
|
|
isolated_min_gap = getattr(
|
|
|
self._params,
|
|
|
"isolated_downside_loss_cap_min_abs_gap_pct",
|
|
|
None,
|
|
|
)
|
|
|
if (
|
|
|
isolated_min_gap is not None
|
|
|
and gap_pct <= -float(isolated_min_gap)
|
|
|
and _trigger_allowed(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"isolated_downside_loss_cap_allowed_trigger_types",
|
|
|
None,
|
|
|
),
|
|
|
trigger_type,
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "isolated_downside_loss_cap_max_ret_5d", None)
|
|
|
is None
|
|
|
or (ret_5d is not None and ret_5d <= float(getattr(self._params, "isolated_downside_loss_cap_max_ret_5d")))
|
|
|
)
|
|
|
and (
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"isolated_downside_loss_cap_min_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
is None
|
|
|
or premarket_dollar_vol >= float(getattr(self._params, "isolated_downside_loss_cap_min_premarket_dollar_vol"))
|
|
|
)
|
|
|
and (
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"isolated_downside_loss_cap_max_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
is None
|
|
|
or premarket_dollar_vol <= float(getattr(self._params, "isolated_downside_loss_cap_max_premarket_dollar_vol"))
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "isolated_downside_loss_cap_max_body_ratio", None)
|
|
|
is None
|
|
|
or body_ratio <= float(getattr(self._params, "isolated_downside_loss_cap_max_body_ratio"))
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "isolated_downside_loss_cap_min_body_ratio", None)
|
|
|
is None
|
|
|
or body_ratio >= float(getattr(self._params, "isolated_downside_loss_cap_min_body_ratio"))
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "isolated_downside_loss_cap_max_close_location", None)
|
|
|
is None
|
|
|
or close_location <= float(getattr(self._params, "isolated_downside_loss_cap_max_close_location"))
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "isolated_downside_loss_cap_min_close_location", None)
|
|
|
is None
|
|
|
or close_location >= float(getattr(self._params, "isolated_downside_loss_cap_min_close_location"))
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "isolated_downside_loss_cap_min_orb_return", None)
|
|
|
is None
|
|
|
or orb_return >= float(getattr(self._params, "isolated_downside_loss_cap_min_orb_return"))
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "isolated_downside_loss_cap_max_score_rank_pct", None)
|
|
|
is None
|
|
|
or (
|
|
|
score_rank_pct is not None
|
|
|
and score_rank_pct <= float(getattr(self._params, "isolated_downside_loss_cap_max_score_rank_pct"))
|
|
|
)
|
|
|
)
|
|
|
):
|
|
|
raw_scale = getattr(self._params, "isolated_downside_size_scale", 1.0)
|
|
|
scale *= max(0.0, min(1.0, float(raw_scale or 0.0)))
|
|
|
|
|
|
pressure_min_gap = getattr(
|
|
|
self._params,
|
|
|
"isolated_downside_pressure_min_abs_gap_pct",
|
|
|
None,
|
|
|
)
|
|
|
if (
|
|
|
pressure_min_gap is not None
|
|
|
and gap_pct <= -float(pressure_min_gap)
|
|
|
and _trigger_allowed(
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"isolated_downside_pressure_allowed_trigger_types",
|
|
|
None,
|
|
|
),
|
|
|
trigger_type,
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "isolated_downside_pressure_max_ret_5d", None)
|
|
|
is None
|
|
|
or (ret_5d is not None and ret_5d <= float(getattr(self._params, "isolated_downside_pressure_max_ret_5d")))
|
|
|
)
|
|
|
and (
|
|
|
getattr(
|
|
|
self._params,
|
|
|
"isolated_downside_pressure_min_premarket_dollar_vol",
|
|
|
None,
|
|
|
)
|
|
|
is None
|
|
|
or premarket_dollar_vol >= float(getattr(self._params, "isolated_downside_pressure_min_premarket_dollar_vol"))
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "isolated_downside_pressure_max_body_ratio", None)
|
|
|
is None
|
|
|
or body_ratio <= float(getattr(self._params, "isolated_downside_pressure_max_body_ratio"))
|
|
|
)
|
|
|
and (
|
|
|
getattr(self._params, "isolated_downside_pressure_max_close_location", None)
|
|
|
is None
|
|
|
or close_location <= float(getattr(self._params, "isolated_downside_pressure_max_close_location"))
|
|
|
)
|
|
|
):
|
|
|
raw_scale = getattr(
|
|
|
self._params,
|
|
|
"isolated_downside_pressure_size_scale",
|
|
|
1.0,
|
|
|
)
|
|
|
scale *= max(0.0, min(1.0, float(raw_scale or 0.0)))
|
|
|
|
|
|
return max(0.0, min(1.0, scale))
|
|
|
|
|
|
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
|
|
|
metadata = self._candidate_metadata_from_row(c)
|
|
|
self._restore_day_context_from_metadata(metadata)
|
|
|
metadata_orb_bar = metadata.get("orb_bar")
|
|
|
orb_bar = metadata_orb_bar if isinstance(metadata_orb_bar, dict) else {}
|
|
|
if not orb_bar:
|
|
|
orb_bar = {
|
|
|
"timestamp": _market_open_ts(date_str).isoformat(),
|
|
|
"open": c["orb_low"],
|
|
|
"high": c["orb_high"],
|
|
|
"low": c["orb_low"],
|
|
|
"close": c["orb_high"],
|
|
|
"volume": 0,
|
|
|
}
|
|
|
else:
|
|
|
orb_bar = {
|
|
|
"timestamp": orb_bar.get("timestamp")
|
|
|
or _market_open_ts(date_str).isoformat(),
|
|
|
"open": orb_bar.get("open", c["orb_low"]),
|
|
|
"high": orb_bar.get("high", c["orb_high"]),
|
|
|
"low": orb_bar.get("low", c["orb_low"]),
|
|
|
"close": orb_bar.get("close", c["orb_high"]),
|
|
|
"volume": orb_bar.get("volume", 0),
|
|
|
}
|
|
|
|
|
|
# Reconstruct candidate dict for breakout and timed-entry checks.
|
|
|
candidate = {
|
|
|
"ticker": c["ticker"],
|
|
|
"direction": c["direction"],
|
|
|
"orb_bar": orb_bar,
|
|
|
"atr": c["atr"],
|
|
|
"rvol": c["rvol"],
|
|
|
"gap_pct": c["gap_pct"],
|
|
|
"score": c["composite_score"],
|
|
|
"size_scale": c.get("size_scale", 1.0),
|
|
|
}
|
|
|
for key, value in metadata.items():
|
|
|
if key not in {"orb_bar", "live_day_context"}:
|
|
|
candidate[key] = value
|
|
|
result.append(candidate)
|
|
|
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 a standalone YAML config.
|
|
|
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,
|
|
|
)
|