"""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", ) 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) # ── 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 _mock_position(ticker: str) -> MagicMock: pos = MagicMock() pos.ticker = ticker return pos 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" )