"""Tests for paper trading reconciliation, order verification, and kill switch.""" from __future__ import annotations import datetime as dt from unittest.mock import MagicMock, patch import pytest from apps.paper_trader.alpaca_broker import AccountInfo, Order, Position from apps.paper_trader.engine import ( PaperTradingEngine, ReconciliationReport, _KILL_SWITCH_DRAWDOWN_PCT, ) from apps.paper_trader.state import ( SessionRow, SessionStateRow, StateManager, StrategyStateRow, ) from libs.backtest.domain import Candidate, DailyPortfolioState # ── Fixtures ──────────────────────────────────────────────────────────────── @pytest.fixture() def mock_broker(): broker = MagicMock() broker.get_account.return_value = AccountInfo( equity=10000, cash=5000, buying_power=5000, long_market_value=5000, unrealized_pl=0, portfolio_value=10000, ) broker.list_positions.return_value = [] broker.list_orders.return_value = [] return broker @pytest.fixture() def state_manager(tmp_path): return StateManager(tmp_path / "test.db") @pytest.fixture() def session(state_manager) -> SessionRow: sid = state_manager.create_session("test_session", "configs/test.json", 10000.0) return state_manager.get_session(sid) def _make_strategy_state(session_id: str, symbol: str, **kwargs) -> StrategyStateRow: defaults = dict( session_id=session_id, symbol=symbol, event_id=f"evt_{symbol}", engine_id="default", entry_date="2026-01-10", stop_price=90.0, target_price=120.0, current_stop=90.0, peak_price=105.0, days_held=3, trade_direction="long", candidate_json='{"symbol":"' + symbol + '","event_type":"earnings_positive","timing_class":"same_day","trade_direction":"long","score":0.8,"event_id":"evt_' + symbol + '","engine_id":"default","reaction_day_return":0.05,"event_direction":"positive","event_close":100.0}', plan_json='{"symbol":"' + symbol + '","shares":10,"entry_price_limit":100.0,"stop_price":90.0,"target_price":120.0,"skip_reason":null,"candidate":{"symbol":"' + symbol + '","event_type":"earnings_positive","timing_class":"same_day","trade_direction":"long","score":0.8,"event_id":"evt_' + symbol + '","engine_id":"default","reaction_day_return":0.05,"event_direction":"positive","event_close":100.0}}', status="open", ) defaults.update(kwargs) return StrategyStateRow(**defaults) def _make_position(symbol: str, qty: int = 10, avg_price: float = 100.0) -> Position: return Position( symbol=symbol, qty=qty, avg_entry_price=avg_price, current_price=105.0, unrealized_pl=qty * 5.0, market_value=qty * 105.0, ) def _make_engine(session, broker, state_manager): """Build a PaperTradingEngine with mocked dependencies.""" with patch("apps.paper_trader.engine.load_manifest") as mock_manifest, \ patch("apps.paper_trader.engine.resolve_config") as mock_resolve: from libs.backtest.domain import BacktestConfig, RiskConfig, SignalConfig, UniverseConfig mock_manifest.return_value = {} cfg = MagicMock(spec=BacktestConfig) cfg.risk = MagicMock(spec=RiskConfig) cfg.risk.cooldown_after_loss_streak = 3 cfg.risk.cooldown_days = 2 cfg.risk.per_trade_risk_pct = 0.02 cfg.risk.macro_sma_period = 20 cfg.signal = MagicMock(spec=SignalConfig) cfg.signal.scoring_model = None cfg.signal.max_candidates_per_day = 5 cfg.universe = MagicMock(spec=UniverseConfig) cfg.get_strategy_engines.return_value = [] cfg.get_active_strategy_engines.return_value = [] mock_resolve.return_value = cfg detector = MagicMock() detector._oracle_url = "http://localhost:8000" engine = PaperTradingEngine( session=session, broker=broker, state=state_manager, event_detector=detector, ) return engine # ── ReconciliationReport ──────────────────────────────────────────────────── class TestReconciliationReport: def test_no_issues(self): report = ReconciliationReport() assert not report.has_issues def test_orphaned_has_issues(self): report = ReconciliationReport(orphaned_alpaca=["AAPL"]) assert report.has_issues def test_ghost_has_issues(self): report = ReconciliationReport(ghost_local=["TSLA"]) assert report.has_issues class TestCapitalBucketParity: def test_adjust_portfolio_state_reserves_only_active_bucket(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) engine._capital_bucket_specs = {"macro_etf": 0.05} today = dt.date(2026, 1, 15) portfolio_state = DailyPortfolioState( date=today, equity=10_000.0, sizing_equity=10_000.0, cash_available=10_000.0, gross_exposure=0.0, net_exposure=0.0, reserved_risk_budget=0.0, unrealized_pnl=0.0, realized_pnl=0.0, open_positions=[], daily_new_risk_used=0.0, peak_equity=10_000.0, current_drawdown_pct=0.0, ) core_candidate = Candidate( event_id="core::1", symbol="AAPL", score=0.8, sector="Technology", event_type="earnings_release", event_timestamp=dt.datetime(2026, 1, 14, 21, 0, tzinfo=dt.timezone.utc), filing_time_bucket="post_market", reaction_date=today, execution_date=today, entry_price_est=100.0, avg_dollar_volume=1_000_000.0, atr_14=2.0, score_bucket="high", engine_id="core", ) macro_candidate = Candidate( event_id="macro::1", symbol="XLK", source_symbol="QQQ", score=0.75, sector="Technology", event_type="macro_bullish_event", event_timestamp=dt.datetime(2026, 1, 14, 21, 0, tzinfo=dt.timezone.utc), filing_time_bucket="post_market", reaction_date=today, execution_date=today, entry_price_est=200.0, avg_dollar_volume=1_000_000.0, atr_14=4.0, score_bucket="high", engine_id="macro_bullish_qqq_etf_breadth", engine_capital_bucket_id="macro_etf", engine_capital_bucket_allocation_pct=0.05, ) active_bucket_ids = {"macro_etf"} core_state = engine._adjust_portfolio_state_for_candidate( session_id=session.session_id, candidate=core_candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, alpaca_positions=[], strategy_states={}, ) macro_state = engine._adjust_portfolio_state_for_candidate( session_id=session.session_id, candidate=macro_candidate, portfolio_state=portfolio_state, active_bucket_ids=active_bucket_ids, alpaca_positions=[], strategy_states={}, ) assert core_state.cash_available == pytest.approx(9_500.0) assert core_state.sizing_equity == pytest.approx(9_500.0) assert macro_state.cash_available == pytest.approx(500.0) assert macro_state.sizing_equity == pytest.approx(500.0) # ── _reconcile_positions ──────────────────────────────────────────────────── class TestReconcilePositions: def test_no_mismatch(self, session, mock_broker, state_manager): """Perfect alignment — no orphans, no ghosts.""" engine = _make_engine(session, mock_broker, state_manager) ss = _make_strategy_state(session.session_id, "AAPL") state_manager.save_strategy_state(session.session_id, ss) positions = [_make_position("AAPL")] states = {"AAPL": ss} report = engine._reconcile_positions(positions, states, dt.date(2026, 1, 15)) assert not report.has_issues assert report.orphaned_alpaca == [] assert report.ghost_local == [] def test_orphaned_position(self, session, mock_broker, state_manager): """Alpaca has AAPL, local state doesn't.""" engine = _make_engine(session, mock_broker, state_manager) positions = [_make_position("AAPL")] states = {} report = engine._reconcile_positions(positions, states, dt.date(2026, 1, 15)) assert report.orphaned_alpaca == ["AAPL"] assert report.ghost_local == [] def test_ghost_position_auto_closed(self, session, mock_broker, state_manager): """Local has TSLA, Alpaca doesn't — should auto-close and record trade.""" engine = _make_engine(session, mock_broker, state_manager) ss = _make_strategy_state(session.session_id, "TSLA") state_manager.save_strategy_state(session.session_id, ss) states = {"TSLA": ss} report = engine._reconcile_positions([], states, dt.date(2026, 1, 15)) assert report.ghost_local == ["TSLA"] assert report.reconciled_exits == ["TSLA"] # Verify local state was closed open_states = state_manager.get_open_strategy_states(session.session_id) assert len(open_states) == 0 # Verify trade was recorded trades = state_manager.list_trades(session.session_id) assert len(trades) == 1 assert trades[0]["exit_reason"] == "RECONCILED" assert trades[0]["symbol"] == "TSLA" def test_mixed_orphan_and_ghost(self, session, mock_broker, state_manager): """AAPL orphaned, TSLA ghost, MSFT aligned.""" engine = _make_engine(session, mock_broker, state_manager) ss_tsla = _make_strategy_state(session.session_id, "TSLA") ss_msft = _make_strategy_state(session.session_id, "MSFT") state_manager.save_strategy_state(session.session_id, ss_tsla) state_manager.save_strategy_state(session.session_id, ss_msft) positions = [_make_position("AAPL"), _make_position("MSFT")] states = {"TSLA": ss_tsla, "MSFT": ss_msft} report = engine._reconcile_positions(positions, states, dt.date(2026, 1, 15)) assert report.orphaned_alpaca == ["AAPL"] assert report.ghost_local == ["TSLA"] # ── _cancel_stale_orders ──────────────────────────────────────────────────── class TestCancelStaleOrders: def test_no_open_orders(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) mock_broker.list_orders.return_value = [] cancelled = engine._cancel_stale_orders() assert cancelled == [] def test_cancels_open_orders(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) prefix = f"pt-{session.session_id[:8]}-" mock_broker.list_orders.return_value = [ Order(id="ord1", symbol="AAPL", qty=10, side="buy", status="open", filled_avg_price=None, filled_qty=0, client_order_id=f"{prefix}aaa"), Order(id="ord2", symbol="TSLA", qty=5, side="buy", status="open", filled_avg_price=None, filled_qty=0, client_order_id=f"{prefix}bbb"), ] cancelled = engine._cancel_stale_orders() assert len(cancelled) == 2 assert mock_broker.cancel_order.call_count == 2 def test_handles_cancel_failure(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) prefix = f"pt-{session.session_id[:8]}-" mock_broker.list_orders.return_value = [ Order(id="ord1", symbol="AAPL", qty=10, side="buy", status="open", filled_avg_price=None, filled_qty=0, client_order_id=f"{prefix}aaa"), ] mock_broker.cancel_order.side_effect = Exception("API error") cancelled = engine._cancel_stale_orders() assert cancelled == [] # failed to cancel def test_skips_other_session_orders(self, session, mock_broker, state_manager): """Orders from other sessions (different prefix) must not be cancelled.""" engine = _make_engine(session, mock_broker, state_manager) prefix = f"pt-{session.session_id[:8]}-" mock_broker.list_orders.return_value = [ Order(id="ord1", symbol="AAPL", qty=10, side="buy", status="open", filled_avg_price=None, filled_qty=0, client_order_id=f"{prefix}aaa"), Order(id="ord2", symbol="TSLA", qty=5, side="buy", status="open", filled_avg_price=None, filled_qty=0, client_order_id="pt-othersess-bbb"), Order(id="ord3", symbol="GOOG", qty=2, side="buy", status="open", filled_avg_price=None, filled_qty=0, client_order_id=None), ] cancelled = engine._cancel_stale_orders() assert len(cancelled) == 1 assert "AAPL" in cancelled[0] assert mock_broker.cancel_order.call_count == 1 def test_handles_list_orders_failure(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) mock_broker.list_orders.side_effect = Exception("API down") cancelled = engine._cancel_stale_orders() assert cancelled == [] # ── _verify_order_fill ────────────────────────────────────────────────────── class TestVerifyOrderFill: def test_filled_order(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) mock_broker.get_order.return_value = Order( id="ord1", symbol="AAPL", qty=10, side="buy", status="filled", filled_avg_price=101.5, filled_qty=10, ) order, reason = engine._verify_order_fill("ord1", "AAPL", timeout_sec=0.1) assert order is not None assert order.filled_avg_price == 101.5 def test_rejected_order(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) mock_broker.get_order.return_value = Order( id="ord1", symbol="AAPL", qty=10, side="buy", status="rejected", filled_avg_price=None, filled_qty=0, ) order, reason = engine._verify_order_fill("ord1", "AAPL", timeout_sec=0.1) assert order is None assert "rejected" in reason def test_timeout(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) mock_broker.get_order.return_value = Order( id="ord1", symbol="AAPL", qty=10, side="buy", status="new", filled_avg_price=None, filled_qty=0, ) order, reason = engine._verify_order_fill("ord1", "AAPL", timeout_sec=0.1) assert order is None assert "timeout" in reason def test_api_error_retries(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) mock_broker.get_order.side_effect = [ Exception("network"), Order(id="ord1", symbol="AAPL", qty=10, side="buy", status="filled", filled_avg_price=102.0, filled_qty=10), ] order, reason = engine._verify_order_fill("ord1", "AAPL", timeout_sec=2.0) assert order is not None assert order.filled_avg_price == 102.0 # ── _check_kill_switch ────────────────────────────────────────────────────── class TestKillSwitch: def test_not_triggered_below_threshold(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) session_st = state_manager.get_session_state(session.session_id) result = engine._check_kill_switch(10.0, session_st) assert result is False assert not session_st.kill_switch_triggered def test_triggered_at_threshold(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) session_st = state_manager.get_session_state(session.session_id) result = engine._check_kill_switch(_KILL_SWITCH_DRAWDOWN_PCT, session_st) assert result is True assert session_st.kill_switch_triggered # Verify persisted reloaded = state_manager.get_session_state(session.session_id) assert reloaded.kill_switch_triggered def test_stays_triggered(self, session, mock_broker, state_manager): engine = _make_engine(session, mock_broker, state_manager) session_st = state_manager.get_session_state(session.session_id) session_st.kill_switch_triggered = True result = engine._check_kill_switch(5.0, session_st) # even low drawdown assert result is True # stays triggered