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.
168 lines
6.5 KiB
Python
168 lines
6.5 KiB
Python
"""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")
|