diff --git a/apps/orb_trader/engine.py b/apps/orb_trader/engine.py index 8c638cd..00ae4d0 100644 --- a/apps/orb_trader/engine.py +++ b/apps/orb_trader/engine.py @@ -846,6 +846,22 @@ class ORBTradingEngine: 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") @@ -880,21 +896,6 @@ class ORBTradingEngine: self._session.session_id, date_str, pos.ticker ) - # Cancel any unfilled breakout candidates (in-memory and DB) - for cand in self._pending_cands: - self._state.update_candidate_status( - self._session.session_id, date_str, cand["ticker"], "timeout" - ) - self._pending_cands = [] - - # Also sweep DB for any pending records not in in-memory list (e.g. after restart) - 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" - ) - return {"closed": closed} # ── Phase 6: Post-close ─────────────────────────────────────────────────── diff --git a/tests/unit/orb_trader/test_detection_guards.py b/tests/unit/orb_trader/test_detection_guards.py index b3b4404..4be31e1 100644 --- a/tests/unit/orb_trader/test_detection_guards.py +++ b/tests/unit/orb_trader/test_detection_guards.py @@ -246,3 +246,47 @@ class TestMaxSimultaneousEntries: 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" + )