You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
726 lines
29 KiB
Python
726 lines
29 KiB
Python
"""Unit tests for ORBTradingEngine guard conditions.
|
|
|
|
Covers early-exit paths in run_orb_detection (rolling_loss, circuit_breaker)
|
|
and the max_simultaneous_entries guard in run_breakout_check.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from apps.orb_trader.engine import ORBTradingEngine
|
|
|
|
_DETECTION_PATCHES = (
|
|
"apps.orb_trader.engine.load_universe",
|
|
"apps.orb_trader.engine.enrich_daily_bars",
|
|
"apps.orb_trader.engine.compute_orb_candidates",
|
|
)
|
|
|
|
|
|
class TestLiveParamParity:
|
|
def test_constructor_does_not_disable_strategy_volume_filters(self):
|
|
params = SimpleNamespace(
|
|
settlement_days=2,
|
|
slippage_bps=5.0,
|
|
min_rvol=3.0,
|
|
min_premarket_dollar_vol=2_500_000.0,
|
|
)
|
|
session = SimpleNamespace(
|
|
session_id="test-session",
|
|
session_name="test",
|
|
initial_equity=10_000.0,
|
|
)
|
|
|
|
eng = ORBTradingEngine(
|
|
session=session,
|
|
broker=MagicMock(),
|
|
state=MagicMock(),
|
|
params=params,
|
|
)
|
|
|
|
assert eng._params is not params
|
|
assert eng._params.min_rvol == pytest.approx(3.0)
|
|
assert eng._params.min_premarket_dollar_vol == pytest.approx(2_500_000.0)
|
|
assert eng._params.settlement_days == 0
|
|
assert eng._params.slippage_bps == pytest.approx(0.0)
|
|
assert params.settlement_days == 2
|
|
assert params.slippage_bps == pytest.approx(5.0)
|
|
|
|
|
|
def _make_engine(
|
|
*,
|
|
initial_equity: float = 10_000.0,
|
|
rolling_loss_days: int | None = None,
|
|
rolling_loss_threshold: float | None = None,
|
|
max_simultaneous_entries: int | None = None,
|
|
) -> ORBTradingEngine:
|
|
params = SimpleNamespace(
|
|
daily_budget_reset=True,
|
|
rolling_loss_days=rolling_loss_days,
|
|
rolling_loss_threshold=rolling_loss_threshold,
|
|
max_simultaneous_entries=max_simultaneous_entries,
|
|
drawdown_governor_threshold=None,
|
|
drawdown_governor_min_scale=0.30,
|
|
streak_sizing_win_bonus=None,
|
|
streak_sizing_loss_penalty=None,
|
|
streak_sizing_max=2.5,
|
|
streak_sizing_min=0.5,
|
|
orb_minutes=5,
|
|
market_regime_spy_threshold=None,
|
|
min_candidate_breadth=None,
|
|
atr_stop_multiplier=0.75,
|
|
risk_per_trade_pct=0.05,
|
|
max_position_pct=0.70,
|
|
)
|
|
session = SimpleNamespace(
|
|
session_id="test-session",
|
|
session_name="test",
|
|
initial_equity=initial_equity,
|
|
)
|
|
state = MagicMock()
|
|
state.get_equity.return_value = initial_equity
|
|
state.get_peak_equity.return_value = initial_equity
|
|
state.list_snapshots.return_value = []
|
|
state.list_trades.return_value = []
|
|
|
|
engine = object.__new__(ORBTradingEngine)
|
|
engine._session = session
|
|
engine._params = params
|
|
engine._state = state
|
|
engine._broker = MagicMock()
|
|
engine._broker.get_bars.return_value = {}
|
|
engine._broker.get_intraday_bars.return_value = {}
|
|
engine._log_callback = None
|
|
engine._enrichment = {}
|
|
engine._daily_bars = {}
|
|
engine._candidates = []
|
|
engine._pending_cands = []
|
|
engine._pre_screened_tickers = None
|
|
engine._date_str = ""
|
|
return engine
|
|
|
|
|
|
def _run_detection_no_pipeline(eng: ORBTradingEngine, date_str: str) -> dict:
|
|
"""Run run_orb_detection with heavy library calls patched out."""
|
|
with patch("apps.orb_trader.engine.load_universe", return_value=[]), \
|
|
patch("apps.orb_trader.engine.enrich_daily_bars", return_value={}), \
|
|
patch("apps.orb_trader.engine.compute_orb_candidates", return_value=[]):
|
|
return eng.run_orb_detection(date_str)
|
|
|
|
|
|
class TestCandidateSizeScale:
|
|
def test_isolated_downside_profiles_apply_live_size_scale(self):
|
|
eng = _make_engine()
|
|
params = eng._params
|
|
params.broad_gapup_continuation_enabled = False
|
|
params.isolated_downside_loss_cap_min_abs_gap_pct = 0.02
|
|
params.isolated_downside_loss_cap_max_ret_5d = 10.0
|
|
params.isolated_downside_loss_cap_min_premarket_dollar_vol = 20_000_000
|
|
params.isolated_downside_loss_cap_max_premarket_dollar_vol = None
|
|
params.isolated_downside_loss_cap_min_body_ratio = 0.20
|
|
params.isolated_downside_loss_cap_min_close_location = 0.50
|
|
params.isolated_downside_loss_cap_min_orb_return = 0.0
|
|
params.isolated_downside_loss_cap_max_body_ratio = None
|
|
params.isolated_downside_loss_cap_max_close_location = None
|
|
params.isolated_downside_loss_cap_max_score_rank_pct = None
|
|
params.isolated_downside_loss_cap_allowed_trigger_types = ["orb"]
|
|
params.isolated_downside_size_scale = 0.35
|
|
params.isolated_downside_pressure_min_abs_gap_pct = 0.02
|
|
params.isolated_downside_pressure_max_ret_5d = 10.0
|
|
params.isolated_downside_pressure_min_premarket_dollar_vol = 300_000_000
|
|
params.isolated_downside_pressure_max_body_ratio = 0.05
|
|
params.isolated_downside_pressure_max_close_location = 0.25
|
|
params.isolated_downside_pressure_allowed_trigger_types = ["orb"]
|
|
params.isolated_downside_pressure_size_scale = 0.35
|
|
|
|
thrust_cand = {
|
|
"ticker": "DJT",
|
|
"direction": "bullish",
|
|
"gap_pct": -0.062,
|
|
"sector_confirmation_active": False,
|
|
"ret_5d": -0.09,
|
|
"premarket_dollar_vol": 270_000_000,
|
|
"body_ratio": 0.62,
|
|
"close_location": 0.78,
|
|
"orb_return": 0.039,
|
|
"trigger_type": "orb",
|
|
}
|
|
pressure_cand = {
|
|
"ticker": "FUTU",
|
|
"direction": "bullish",
|
|
"gap_pct": -0.186,
|
|
"sector_confirmation_active": False,
|
|
"ret_5d": 0.20,
|
|
"premarket_dollar_vol": 315_000_000,
|
|
"body_ratio": 0.0,
|
|
"close_location": 0.20,
|
|
"orb_return": -0.005,
|
|
"trigger_type": "orb",
|
|
}
|
|
|
|
assert eng._candidate_size_scale(thrust_cand) == pytest.approx(0.35)
|
|
assert eng._candidate_size_scale(pressure_cand) == pytest.approx(0.35)
|
|
|
|
def test_persisted_candidate_size_scale_is_reused(self):
|
|
eng = _make_engine()
|
|
|
|
assert eng._candidate_size_scale({"size_scale": 0.35}) == pytest.approx(0.35)
|
|
|
|
def test_overextended_downside_reclaim_applies_live_size_scale(self):
|
|
eng = _make_engine()
|
|
params = eng._params
|
|
params.broad_gapup_continuation_enabled = False
|
|
params.overextended_downside_reclaim_min_abs_gap_pct = 0.15
|
|
params.overextended_downside_reclaim_min_ret_5d = 0.15
|
|
params.overextended_downside_reclaim_min_premarket_dollar_vol = None
|
|
params.overextended_downside_reclaim_allowed_trigger_types = ["orb"]
|
|
params.overextended_downside_reclaim_size_scale = 0.25
|
|
|
|
cand = {
|
|
"ticker": "BULL",
|
|
"direction": "bullish",
|
|
"gap_pct": -0.205,
|
|
"ret_5d": 4.59,
|
|
"premarket_dollar_vol": 34_000_000,
|
|
"sector_confirmation_active": True,
|
|
"trigger_type": "orb",
|
|
}
|
|
|
|
assert eng._candidate_size_scale(cand) == pytest.approx(0.25)
|
|
|
|
def test_mid_attention_exhaustion_applies_live_size_scale(self):
|
|
eng = _make_engine()
|
|
params = eng._params
|
|
params.broad_gapup_continuation_enabled = False
|
|
params.mid_attention_exhaustion_min_rvol = 15.0
|
|
params.mid_attention_exhaustion_max_rvol = 25.0
|
|
params.mid_attention_exhaustion_min_premarket_dollar_vol = 20_000_000
|
|
params.mid_attention_exhaustion_max_premarket_dollar_vol = 100_000_000
|
|
params.mid_attention_exhaustion_allowed_trigger_types = ["orb"]
|
|
params.mid_attention_exhaustion_size_scale = 0.25
|
|
|
|
cand = {
|
|
"ticker": "OSCR",
|
|
"direction": "bullish",
|
|
"gap_pct": -0.134,
|
|
"rvol": 21.2,
|
|
"premarket_dollar_vol": 97_000_000,
|
|
"trigger_type": "orb",
|
|
}
|
|
|
|
assert eng._candidate_size_scale(cand) == pytest.approx(0.25)
|
|
|
|
def test_orphan_thin_attention_applies_live_size_scale(self):
|
|
eng = _make_engine()
|
|
params = eng._params
|
|
params.broad_gapup_continuation_enabled = False
|
|
params.orphan_thin_attention_max_premarket_dollar_vol = 2_500_000
|
|
params.orphan_thin_attention_allowed_trigger_types = ["orb"]
|
|
params.orphan_thin_attention_size_scale = 0.25
|
|
|
|
cand = {
|
|
"ticker": "THIN",
|
|
"direction": "bullish",
|
|
"gap_pct": 0.028,
|
|
"premarket_dollar_vol": 900_000,
|
|
"sector_confirmation_active": False,
|
|
"trigger_type": "orb",
|
|
}
|
|
|
|
assert eng._candidate_size_scale(cand) == pytest.approx(0.25)
|
|
|
|
def test_orphan_thin_attention_ignores_sector_confirmed_candidates(self):
|
|
eng = _make_engine()
|
|
params = eng._params
|
|
params.broad_gapup_continuation_enabled = False
|
|
params.orphan_thin_attention_max_premarket_dollar_vol = 2_500_000
|
|
params.orphan_thin_attention_allowed_trigger_types = ["orb"]
|
|
params.orphan_thin_attention_size_scale = 0.25
|
|
|
|
cand = {
|
|
"ticker": "GROUP",
|
|
"direction": "bullish",
|
|
"gap_pct": 0.028,
|
|
"premarket_dollar_vol": 900_000,
|
|
"sector_confirmation_active": True,
|
|
"trigger_type": "orb",
|
|
}
|
|
|
|
assert eng._candidate_size_scale(cand) == pytest.approx(1.0)
|
|
|
|
def test_orphan_thin_attention_uses_filter_premarket_for_iex_live_parity(self):
|
|
eng = _make_engine()
|
|
params = eng._params
|
|
params.broad_gapup_continuation_enabled = False
|
|
params.orphan_thin_attention_max_premarket_dollar_vol = 2_500_000
|
|
params.orphan_thin_attention_allowed_trigger_types = ["orb"]
|
|
params.orphan_thin_attention_size_scale = 0.25
|
|
|
|
cand = {
|
|
"ticker": "IEXLIQ",
|
|
"direction": "bullish",
|
|
"gap_pct": 0.028,
|
|
"premarket_dollar_vol": 900_000,
|
|
"filter_premarket_dollar_vol": 27_000_000,
|
|
"sector_confirmation_active": False,
|
|
"trigger_type": "orb",
|
|
}
|
|
|
|
assert eng._candidate_size_scale(cand) == pytest.approx(1.0)
|
|
|
|
def test_mid_attention_exhaustion_uses_filter_volume_for_iex_live_parity(self):
|
|
eng = _make_engine()
|
|
params = eng._params
|
|
params.broad_gapup_continuation_enabled = False
|
|
params.mid_attention_exhaustion_min_rvol = 15.0
|
|
params.mid_attention_exhaustion_max_rvol = 25.0
|
|
params.mid_attention_exhaustion_min_premarket_dollar_vol = 20_000_000
|
|
params.mid_attention_exhaustion_max_premarket_dollar_vol = 100_000_000
|
|
params.mid_attention_exhaustion_allowed_trigger_types = ["orb"]
|
|
params.mid_attention_exhaustion_size_scale = 0.25
|
|
|
|
cand = {
|
|
"ticker": "IEXMID",
|
|
"direction": "bullish",
|
|
"gap_pct": -0.134,
|
|
"rvol": 0.7,
|
|
"filter_rvol": 21.2,
|
|
"premarket_dollar_vol": 3_000_000,
|
|
"filter_premarket_dollar_vol": 90_000_000,
|
|
"trigger_type": "orb",
|
|
}
|
|
|
|
assert eng._candidate_size_scale(cand) == pytest.approx(0.25)
|
|
|
|
def test_unsupported_thin_attention_uses_filter_first_bar_for_iex_live_parity(self):
|
|
eng = _make_engine()
|
|
params = eng._params
|
|
params.broad_gapup_continuation_enabled = False
|
|
params.unsupported_attention_allowed_trigger_types = ["orb"]
|
|
params.unsupported_attention_size_scale = 0.25
|
|
params.unsupported_attention_thin_max_premarket_dollar_vol = 10_000_000
|
|
params.unsupported_attention_thin_min_gap_pct = 0.02
|
|
params.unsupported_attention_thin_min_rvol = 15.0
|
|
params.unsupported_attention_thin_max_first_bar_dollar_vol = 1_000_000
|
|
params.unsupported_attention_ignore_high_conviction = True
|
|
|
|
cand = {
|
|
"ticker": "IEXFIRST",
|
|
"direction": "bullish",
|
|
"gap_pct": 0.028,
|
|
"rvol": 20.0,
|
|
"premarket_dollar_vol": 900_000,
|
|
"first_bar_dollar_vol": 50_000,
|
|
"filter_first_bar_dollar_vol": 1_500_000,
|
|
"sector_confirmation_active": False,
|
|
"trigger_type": "orb",
|
|
}
|
|
|
|
assert eng._candidate_size_scale(cand) == pytest.approx(1.0)
|
|
|
|
def test_gap_up_fill_trap_applies_live_size_scale(self):
|
|
eng = _make_engine()
|
|
params = eng._params
|
|
params.broad_gapup_continuation_enabled = False
|
|
params.gap_up_fill_exit_min_gap_pct = 0.02
|
|
params.gap_up_fill_exit_max_premarket_dollar_vol = 10_000_000
|
|
params.gap_up_fill_exit_min_ret_5d = 0.0
|
|
params.gap_up_fill_exit_max_ret_5d = 0.25
|
|
params.gap_up_fill_exit_max_close_location = 0.85
|
|
params.gap_up_fill_trap_max_orb_return = 0.035
|
|
params.gap_up_fill_trap_allowed_trigger_types = ["orb"]
|
|
params.gap_up_fill_trap_size_scale = 0.0
|
|
|
|
cand = {
|
|
"ticker": "TRAP",
|
|
"direction": "bullish",
|
|
"gap_pct": 0.028,
|
|
"ret_5d": 0.04,
|
|
"premarket_dollar_vol": 4_600_000,
|
|
"body_ratio": 0.46,
|
|
"close_location": 0.60,
|
|
"orb_return": 0.014,
|
|
"trigger_type": "orb",
|
|
}
|
|
|
|
assert eng._candidate_size_scale(cand) == pytest.approx(0.0)
|
|
|
|
def test_gap_up_fill_trap_keeps_exceptional_first_bar_expansion(self):
|
|
eng = _make_engine()
|
|
params = eng._params
|
|
params.broad_gapup_continuation_enabled = False
|
|
params.gap_up_fill_exit_min_gap_pct = 0.02
|
|
params.gap_up_fill_exit_max_premarket_dollar_vol = 10_000_000
|
|
params.gap_up_fill_exit_min_ret_5d = 0.0
|
|
params.gap_up_fill_exit_max_ret_5d = 0.25
|
|
params.gap_up_fill_exit_max_close_location = 0.85
|
|
params.gap_up_fill_trap_max_orb_return = 0.035
|
|
params.gap_up_fill_trap_allowed_trigger_types = ["orb"]
|
|
params.gap_up_fill_trap_size_scale = 0.0
|
|
|
|
cand = {
|
|
"ticker": "LEADER",
|
|
"direction": "bullish",
|
|
"gap_pct": 0.028,
|
|
"ret_5d": 0.04,
|
|
"premarket_dollar_vol": 4_600_000,
|
|
"body_ratio": 0.65,
|
|
"close_location": 0.72,
|
|
"orb_return": 0.067,
|
|
"trigger_type": "orb",
|
|
}
|
|
|
|
assert eng._candidate_size_scale(cand) == pytest.approx(1.0)
|
|
|
|
|
|
# ── Rolling loss filter ───────────────────────────────────────────────────────
|
|
|
|
class TestRollingLossFilter:
|
|
def _snapshot(self, date: str, daily_pnl: float) -> dict:
|
|
return {"date": date, "daily_pnl": daily_pnl}
|
|
|
|
def test_not_enough_history_no_skip(self):
|
|
# Only 2 snapshots but roll_days=3 → filter does not trigger
|
|
eng = _make_engine(rolling_loss_days=3, rolling_loss_threshold=-0.05)
|
|
eng._state.list_snapshots.return_value = [
|
|
self._snapshot("2026-01-01", -300.0),
|
|
self._snapshot("2026-01-02", -300.0),
|
|
]
|
|
result = _run_detection_no_pipeline(eng, "2026-01-05")
|
|
assert result.get("skip_reason") != "rolling_loss"
|
|
|
|
def test_loss_below_threshold_triggers_skip(self):
|
|
# 3 days of -200 = -600 total; -600/10000 = -6% < -5% threshold → skip
|
|
eng = _make_engine(rolling_loss_days=3, rolling_loss_threshold=-0.05)
|
|
eng._state.list_snapshots.return_value = [
|
|
self._snapshot("2026-01-01", -200.0),
|
|
self._snapshot("2026-01-02", -200.0),
|
|
self._snapshot("2026-01-03", -200.0),
|
|
]
|
|
result = eng.run_orb_detection("2026-01-05")
|
|
assert result["skip_reason"] == "rolling_loss"
|
|
assert result["orb_candidates"] == 0
|
|
|
|
def test_loss_exactly_at_threshold_no_skip(self):
|
|
# -100 + -200 + -200 = -500; -500/10000 = -5.0% = threshold → NOT below → no skip
|
|
eng = _make_engine(rolling_loss_days=3, rolling_loss_threshold=-0.05)
|
|
eng._state.list_snapshots.return_value = [
|
|
self._snapshot("2026-01-01", -100.0),
|
|
self._snapshot("2026-01-02", -200.0),
|
|
self._snapshot("2026-01-03", -200.0),
|
|
]
|
|
result = _run_detection_no_pipeline(eng, "2026-01-05")
|
|
assert result.get("skip_reason") != "rolling_loss"
|
|
|
|
def test_future_snapshots_excluded_from_window(self):
|
|
# Snapshot for date_str itself must not be counted (filter: date < date_str)
|
|
# 2 past snapshots < roll_days=3 → no skip
|
|
eng = _make_engine(rolling_loss_days=3, rolling_loss_threshold=-0.05)
|
|
eng._state.list_snapshots.return_value = [
|
|
self._snapshot("2026-01-01", -400.0),
|
|
self._snapshot("2026-01-02", -400.0),
|
|
self._snapshot("2026-01-05", -400.0), # same-day: must not count
|
|
]
|
|
result = _run_detection_no_pipeline(eng, "2026-01-05")
|
|
assert result.get("skip_reason") != "rolling_loss"
|
|
|
|
def test_rolling_window_uses_last_n_days(self):
|
|
# 5 past snapshots, roll_days=3 → only last 3 used
|
|
# Last 3 are +100 each → sum=+300 → no trigger
|
|
eng = _make_engine(rolling_loss_days=3, rolling_loss_threshold=-0.05)
|
|
eng._state.list_snapshots.return_value = [
|
|
self._snapshot("2025-12-30", -400.0),
|
|
self._snapshot("2025-12-31", -400.0),
|
|
self._snapshot("2026-01-01", 100.0),
|
|
self._snapshot("2026-01-02", 100.0),
|
|
self._snapshot("2026-01-03", 100.0),
|
|
]
|
|
result = _run_detection_no_pipeline(eng, "2026-01-05")
|
|
assert result.get("skip_reason") != "rolling_loss"
|
|
|
|
|
|
# ── Circuit breaker ───────────────────────────────────────────────────────────
|
|
|
|
class TestCircuitBreaker:
|
|
def test_triggers_at_25pct_drawdown(self):
|
|
# Peak=10000, current=7400 → DD=26% ≥ 25% → halt
|
|
eng = _make_engine(initial_equity=10_000)
|
|
eng._state.get_equity.return_value = 7_400.0
|
|
eng._state.get_peak_equity.return_value = 10_000.0
|
|
|
|
result = eng.run_orb_detection("2026-01-05")
|
|
assert result["skip_reason"] == "circuit_breaker"
|
|
eng._state.set_session_status.assert_called_once_with("test-session", "paused")
|
|
|
|
def test_does_not_trigger_below_threshold(self):
|
|
# Peak=10000, current=7600 → DD=24% < 25% → continues
|
|
eng = _make_engine(initial_equity=10_000)
|
|
eng._state.get_equity.return_value = 7_600.0
|
|
eng._state.get_peak_equity.return_value = 10_000.0
|
|
|
|
result = _run_detection_no_pipeline(eng, "2026-01-05")
|
|
assert result.get("skip_reason") != "circuit_breaker"
|
|
eng._state.set_session_status.assert_not_called()
|
|
|
|
|
|
# ── max_simultaneous_entries guard ───────────────────────────────────────────
|
|
|
|
def _make_cand(ticker: str, price: float = 100.0) -> dict:
|
|
return {
|
|
"ticker": ticker,
|
|
"direction": "bullish",
|
|
"orb_bar": {"high": price, "low": price * 0.98, "open": price * 0.99, "close": price},
|
|
"atr": 1.0,
|
|
"score": 0.8,
|
|
"rvol": 2.0,
|
|
"gap_pct": 0.03,
|
|
}
|
|
|
|
|
|
def _make_bar(open_price: float = 100.0) -> dict:
|
|
return {
|
|
"timestamp": "2026-05-01T09:30:00-04:00",
|
|
"open": open_price,
|
|
"high": open_price * 1.01,
|
|
"low": open_price * 0.99,
|
|
"close": open_price,
|
|
"volume": 100_000,
|
|
}
|
|
|
|
|
|
def _make_bar_at(timestamp: str, open_price: float = 100.0) -> dict:
|
|
return {
|
|
"timestamp": timestamp,
|
|
"open": open_price,
|
|
"high": open_price * 1.01,
|
|
"low": open_price * 0.99,
|
|
"close": open_price,
|
|
"volume": 100_000,
|
|
}
|
|
|
|
|
|
def _mock_position(ticker: str) -> MagicMock:
|
|
pos = MagicMock()
|
|
pos.ticker = ticker
|
|
return pos
|
|
|
|
|
|
class TestLiveRegimeFallback:
|
|
def test_prescreen_fetches_regime_ticker_and_soft_fallback_scales_day(self):
|
|
eng = _make_engine()
|
|
eng._params.market_regime_spy_threshold = 0.0015
|
|
eng._params.market_regime_ticker = "QQQ"
|
|
eng._params.soft_day_fallback_on_regime_skip = True
|
|
eng._params.soft_day_regime_skip_size_scale = 0.1
|
|
eng._params.soft_day_combined_size_scale_floor = 0.35
|
|
eng._params.min_candidate_breadth = None
|
|
|
|
eng._pre_screened_tickers = ["ABC"]
|
|
eng._daily_bars = {"QQQ": [{}], "ABC": [{}]}
|
|
eng._enrichment = {
|
|
"QQQ": {"2026-05-01": {"prev_close": 100.0, "today_open": 100.0}},
|
|
"ABC": {"2026-05-01": {"prev_close": 10.0, "today_open": 10.0}},
|
|
}
|
|
eng._broker.get_intraday_bars.return_value = {
|
|
"QQQ": [_make_bar(100.0)],
|
|
"ABC": [_make_bar(11.0)],
|
|
}
|
|
|
|
with patch(
|
|
"apps.orb_trader.engine.compute_orb_candidates",
|
|
return_value=[_make_cand("QQQ"), _make_cand("ABC")],
|
|
):
|
|
result = eng.run_orb_detection("2026-05-01")
|
|
|
|
fetched = eng._broker.get_intraday_bars.call_args.args[0]
|
|
assert fetched[0] == "QQQ"
|
|
assert "ABC" in fetched
|
|
assert result["orb_candidates"] == 1
|
|
assert result.get("skip_reason") is None
|
|
assert eng._soft_day_reason == "market_regime"
|
|
assert eng._day_size_scale == pytest.approx(0.35)
|
|
eng._state.save_candidate.assert_called_once()
|
|
assert eng._state.save_candidate.call_args.args[0].ticker == "ABC"
|
|
|
|
def test_live_regime_uses_regular_session_open_not_premarket_tick(self):
|
|
eng = _make_engine()
|
|
eng._params.market_regime_spy_threshold = 0.0015
|
|
eng._params.market_regime_ticker = "QQQ"
|
|
eng._params.soft_day_fallback_on_regime_skip = False
|
|
eng._params.min_candidate_breadth = None
|
|
|
|
eng._pre_screened_tickers = ["ABC"]
|
|
eng._daily_bars = {"QQQ": [{}], "ABC": [{}]}
|
|
eng._enrichment = {
|
|
"QQQ": {"2026-05-01": {"prev_close": 100.0, "today_open": 100.0}},
|
|
"ABC": {"2026-05-01": {"prev_close": 10.0, "today_open": 10.0}},
|
|
}
|
|
eng._broker.get_intraday_bars.return_value = {
|
|
"QQQ": [
|
|
_make_bar_at("2026-05-01T08:15:00-04:00", 100.0),
|
|
_make_bar_at("2026-05-01T09:30:00-04:00", 101.0),
|
|
],
|
|
"ABC": [
|
|
_make_bar_at("2026-05-01T08:15:00-04:00", 10.0),
|
|
_make_bar_at("2026-05-01T09:30:00-04:00", 11.0),
|
|
],
|
|
}
|
|
|
|
with patch(
|
|
"apps.orb_trader.engine.compute_orb_candidates",
|
|
return_value=[_make_cand("ABC")],
|
|
) as compute_mock:
|
|
result = eng.run_orb_detection("2026-05-01")
|
|
|
|
assert result.get("skip_reason") is None
|
|
assert eng._enrichment["QQQ"]["2026-05-01"]["today_open"] == pytest.approx(101.0)
|
|
assert eng._day_size_scale == pytest.approx(1.0)
|
|
assert compute_mock.call_args.kwargs["bars_by_ticker"]["QQQ"][0]["timestamp"] == (
|
|
"2026-05-01T08:15:00-04:00"
|
|
)
|
|
eng._state.save_candidate.assert_called_once()
|
|
|
|
|
|
class TestLiveMarketThrustParity:
|
|
def test_market_thrust_sleeve_requires_override_active(self):
|
|
eng = _make_engine()
|
|
eng._params.min_candidate_breadth = 0.90
|
|
eng._params.soft_day_fallback_on_breadth_skip = True
|
|
eng._params.soft_day_breadth_skip_size_scale = 0.10
|
|
eng._params.market_thrust_liquid_continuation_enabled = True
|
|
eng._params.market_thrust_breadth_override_enabled = True
|
|
eng._params.market_thrust_breadth_override_min_primary_close_location = 0.95
|
|
eng._params.market_orb_quality_ticker = "SPY"
|
|
eng._params.market_orb_quality_secondary_ticker = "QQQ"
|
|
eng._params.broad_gapup_continuation_enabled = False
|
|
eng._params.market_thrust_opening_impulse_reclaim_enabled = False
|
|
|
|
eng._pre_screened_tickers = ["ABC"]
|
|
eng._daily_bars = {"SPY": [{}], "QQQ": [{}], "ABC": [{}]}
|
|
eng._enrichment = {
|
|
"SPY": {"2026-05-01": {"prev_close": 100.0, "today_open": 100.0}},
|
|
"QQQ": {"2026-05-01": {"prev_close": 100.0, "today_open": 100.0}},
|
|
"ABC": {"2026-05-01": {"prev_close": 10.0, "today_open": 10.0}},
|
|
}
|
|
eng._broker.get_intraday_bars.return_value = {
|
|
"SPY": [_make_bar(100.0)],
|
|
"QQQ": [_make_bar(100.0)],
|
|
"ABC": [_make_bar(11.0)],
|
|
}
|
|
|
|
with patch(
|
|
"apps.orb_trader.engine.compute_orb_candidates",
|
|
return_value=[],
|
|
) as compute_mock:
|
|
result = eng.run_orb_detection("2026-05-01")
|
|
|
|
assert result["orb_candidates"] == 0
|
|
assert eng._market_thrust_breadth_override_active is False
|
|
assert compute_mock.call_count == 1
|
|
params = compute_mock.call_args.kwargs["params"]
|
|
assert getattr(params, "market_thrust_liquid_continuation_enabled") is False
|
|
|
|
|
|
class TestMaxSimultaneousEntries:
|
|
def _make_breakout_engine(self, *, max_sim: int | None, open_positions: list) -> ORBTradingEngine:
|
|
eng = _make_engine(max_simultaneous_entries=max_sim)
|
|
daily_state = MagicMock()
|
|
daily_state.kill_switch = False
|
|
eng._state.get_daily_state.return_value = daily_state
|
|
eng._state.get_open_positions.return_value = open_positions
|
|
return eng
|
|
|
|
def test_at_max_candidate_stays_pending(self):
|
|
# 3 positions open, max_sim=3 → new candidate must NOT enter
|
|
eng = self._make_breakout_engine(
|
|
max_sim=3,
|
|
open_positions=[_mock_position("A"), _mock_position("B"), _mock_position("C")],
|
|
)
|
|
eng._pending_cands = [_make_cand("AAPL", price=150.0)]
|
|
|
|
snap = MagicMock()
|
|
snap.price = 155.0 # would trigger breakout if guard not active
|
|
with patch("apps.orb_trader.engine.get_snapshots", return_value={"AAPL": snap}):
|
|
result = eng.run_breakout_check("2026-01-05")
|
|
|
|
assert result["filled"] == 0
|
|
assert result["remaining"] == 1
|
|
|
|
def test_below_max_proceeds_past_guard(self):
|
|
# 2 positions open, max_sim=3 → not blocked by guard; snapshot check runs
|
|
eng = self._make_breakout_engine(
|
|
max_sim=3,
|
|
open_positions=[_mock_position("A"), _mock_position("B")],
|
|
)
|
|
eng._pending_cands = [_make_cand("AAPL", price=150.0)]
|
|
|
|
# Return no snapshot → candidate stays pending for snapshot reason (not max_sim)
|
|
with patch("apps.orb_trader.engine.get_snapshots", return_value={}) as mock_snaps:
|
|
result = eng.run_breakout_check("2026-01-05")
|
|
|
|
mock_snaps.assert_called_once() # proceeded past max_sim guard
|
|
assert result["remaining"] == 1 # pending, but due to missing snapshot
|
|
|
|
def test_no_limit_proceeds_past_guard(self):
|
|
# max_simultaneous_entries=None → no position cap enforced
|
|
eng = self._make_breakout_engine(
|
|
max_sim=None,
|
|
open_positions=[
|
|
_mock_position("A"), _mock_position("B"),
|
|
_mock_position("C"), _mock_position("D"),
|
|
],
|
|
)
|
|
eng._pending_cands = [_make_cand("AAPL", price=150.0)]
|
|
|
|
with patch("apps.orb_trader.engine.get_snapshots", return_value={}) as mock_snaps:
|
|
result = eng.run_breakout_check("2026-01-05")
|
|
|
|
mock_snaps.assert_called_once()
|
|
assert result["remaining"] == 1
|
|
|
|
|
|
# ── EOD DB sweep ──────────────────────────────────────────────────────────────
|
|
|
|
class TestEodDbSweep:
|
|
def test_sweep_runs_with_no_open_positions(self):
|
|
# Regression: before fix, DB sweep was inside the "has positions" branch;
|
|
# after a server restart with no open positions, stale pending records leaked.
|
|
eng = _make_engine()
|
|
eng._state.get_open_positions.return_value = []
|
|
eng._state.list_candidates.return_value = [
|
|
{"ticker": "AAPL", "status": "pending"},
|
|
{"ticker": "NVDA", "status": "pending"},
|
|
{"ticker": "TSLA", "status": "filled"}, # already filled, must not be touched
|
|
]
|
|
|
|
result = eng.run_eod_exit("2026-01-05")
|
|
|
|
assert result["closed"] == 0
|
|
# Both pending records should be timed out
|
|
calls = [
|
|
call.args for call in eng._state.update_candidate_status.call_args_list
|
|
]
|
|
assert ("test-session", "2026-01-05", "AAPL", "timeout") in calls
|
|
assert ("test-session", "2026-01-05", "NVDA", "timeout") in calls
|
|
# Filled record must not be touched
|
|
assert ("test-session", "2026-01-05", "TSLA", "timeout") not in calls
|
|
|
|
def test_in_memory_pending_swept_with_no_open_positions(self):
|
|
# In-memory pending candidates must also be swept unconditionally
|
|
eng = _make_engine()
|
|
eng._state.get_open_positions.return_value = []
|
|
eng._state.list_candidates.return_value = []
|
|
eng._pending_cands = [
|
|
{"ticker": "MSFT", "direction": "bullish", "orb_bar": {}, "atr": 1.0,
|
|
"score": 0.5, "rvol": 1.5, "gap_pct": 0.02},
|
|
]
|
|
|
|
eng.run_eod_exit("2026-01-05")
|
|
|
|
assert eng._pending_cands == [] # cleared
|
|
eng._state.update_candidate_status.assert_called_once_with(
|
|
"test-session", "2026-01-05", "MSFT", "timeout"
|
|
)
|