"""Tests: Session A positions/orders are not touched by Session B's reconciler/canceller.""" from __future__ import annotations import datetime as dt from unittest.mock import MagicMock, patch import pytest from unittest.mock import patch from apps.paper_trader.alpaca_broker import AccountInfo, Order, Position from apps.paper_trader.engine import PaperTradingEngine, ReconciliationReport from apps.paper_trader.state import SessionRow, SessionStateRow, StateManager, StrategyStateRow @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_orders.return_value = [] return broker @pytest.fixture() def state_manager(tmp_path): return StateManager(tmp_path / "test.db") def _make_session(state_manager: StateManager, name: str) -> SessionRow: sid = state_manager.create_session(name, "configs/test.json", 10000.0) return state_manager.get_session(sid) def _make_engine(session: SessionRow, broker, state_manager: StateManager) -> PaperTradingEngine: 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.cash_parking_enabled = False 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 def _make_position(symbol: str) -> Position: return Position( symbol=symbol, qty=10, avg_entry_price=100.0, market_value=1000.0, unrealized_pl=0.0, current_price=100.0, ) def _make_strategy_state(session_id: str, symbol: str) -> StrategyStateRow: return StrategyStateRow( session_id=session_id, symbol=symbol, event_id="evt1", engine_id="eng", entry_date="2026-04-01", stop_price=90.0, target_price=120.0, current_stop=90.0, peak_price=100.0, days_held=1, trade_direction="long", candidate_json="{}", plan_json="{}", status="open", ) class TestMultiSessionReconcilerIsolation: def test_orphaned_alpaca_positions_from_other_session_not_closed( self, state_manager: StateManager, mock_broker ): """Session B's reconciler sees Session A's AAPL as 'orphaned' but must NOT close it.""" session_a = _make_session(state_manager, "session_a") session_b = _make_session(state_manager, "session_b") # Only Session A has AAPL in local state ss_a = _make_strategy_state(session_a.session_id, "AAPL") state_manager.save_strategy_state(session_a.session_id, ss_a) # Alpaca has AAPL (Session A's position) alpaca_positions = [_make_position("AAPL")] # Session B's strategy_states — no AAPL session_b_states: dict[str, StrategyStateRow] = {} engine_b = _make_engine(session_b, mock_broker, state_manager) report = engine_b._reconcile_positions( alpaca_positions=alpaca_positions, strategy_states=session_b_states, today=dt.date(2026, 4, 24), ) # Session B must NOT close AAPL on Alpaca (it's Session A's position) mock_broker.close_position.assert_not_called() # AAPL is reported as orphaned from Session B's perspective assert "AAPL" in report.orphaned_alpaca def test_ghost_close_only_affects_own_session( self, state_manager: StateManager, mock_broker ): """Ghost close must only remove Session B's local state, not Session A's.""" session_a = _make_session(state_manager, "session_a") session_b = _make_session(state_manager, "session_b") # Session A has MSFT open ss_a = _make_strategy_state(session_a.session_id, "MSFT") state_manager.save_strategy_state(session_a.session_id, ss_a) # Session B also has MSFT open locally ss_b = _make_strategy_state(session_b.session_id, "MSFT") state_manager.save_strategy_state(session_b.session_id, ss_b) # Alpaca has NO MSFT (filled and closed somehow) alpaca_positions: list[Position] = [] session_b_states = {"MSFT": ss_b} engine_b = _make_engine(session_b, mock_broker, state_manager) report = engine_b._reconcile_positions( alpaca_positions=alpaca_positions, strategy_states=session_b_states, today=dt.date(2026, 4, 24), ) # Session B's MSFT ghost-closed assert "MSFT" in report.ghost_local # Session A's MSFT state still intact assert state_manager.get_strategy_state_by_symbol(session_a.session_id, "MSFT") is not None def test_stale_order_cancel_filters_by_session_prefix( self, state_manager: StateManager, mock_broker ): """Only orders with our session prefix are cancelled; others left alone.""" session = _make_session(state_manager, "test_sess") prefix = f"pt-{session.session_id[:8]}-" own_order = MagicMock() own_order.id = "own-order-id" own_order.client_order_id = f"{prefix}abc123" own_order.symbol = "AAPL" other_order = MagicMock() other_order.id = "other-order-id" other_order.client_order_id = "pt-99999999-xyz" # different session other_order.symbol = "TSLA" mock_broker.list_orders.return_value = [own_order, other_order] engine = _make_engine(session, mock_broker, state_manager) cancelled = engine._cancel_stale_orders() # Only own order cancelled assert "AAPL:own-order-id" in cancelled assert not any("TSLA" in c for c in cancelled) mock_broker.cancel_order.assert_called_once_with("own-order-id")