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.
369 lines
16 KiB
Python
369 lines
16 KiB
Python
"""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)
|
|
mock_broker.list_orders.return_value = [
|
|
Order(id="ord1", symbol="AAPL", qty=10, side="buy", status="open", filled_avg_price=None, filled_qty=0),
|
|
Order(id="ord2", symbol="TSLA", qty=5, side="buy", status="open", filled_avg_price=None, filled_qty=0),
|
|
]
|
|
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)
|
|
mock_broker.list_orders.return_value = [
|
|
Order(id="ord1", symbol="AAPL", qty=10, side="buy", status="open", filled_avg_price=None, filled_qty=0),
|
|
]
|
|
mock_broker.cancel_order.side_effect = Exception("API error")
|
|
cancelled = engine._cancel_stale_orders()
|
|
assert cancelled == [] # failed to cancel
|
|
|
|
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,
|
|
)
|
|
result = engine._verify_order_fill("ord1", "AAPL", timeout_sec=0.1)
|
|
assert result is not None
|
|
assert result.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,
|
|
)
|
|
result = engine._verify_order_fill("ord1", "AAPL", timeout_sec=0.1)
|
|
assert result is None
|
|
|
|
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,
|
|
)
|
|
result = engine._verify_order_fill("ord1", "AAPL", timeout_sec=0.1)
|
|
assert result is None
|
|
|
|
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),
|
|
]
|
|
result = engine._verify_order_fill("ord1", "AAPL", timeout_sec=2.0)
|
|
assert result is not None
|
|
assert result.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
|