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