333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""Validate fail-closed behavior for broker outages and ambiguous order states.
|
|
|
|
Tests that the system rejects orders rather than risking duplicates or
|
|
ambiguous execution when the broker API is unavailable, returns errors,
|
|
times out, or returns unexpected/ambiguous responses.
|
|
|
|
Requirements: 8.4, 8.5, N5
|
|
Design: Section 10 - Reliability and Safety
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from services.adapters.broker_adapter import (
|
|
AlpacaBrokerAdapter,
|
|
OrderRequest,
|
|
OrderResponse,
|
|
OrderSide,
|
|
OrderStatus,
|
|
OrderType,
|
|
TradingMode,
|
|
)
|
|
from services.risk.engine import (
|
|
AccountRiskState,
|
|
DailyLossLimits,
|
|
PortfolioRiskConfig,
|
|
PositionLimits,
|
|
ProposedOrder,
|
|
RiskCheckResult,
|
|
TradingMode as RiskTradingMode,
|
|
evaluate_order,
|
|
)
|
|
|
|
NOW = datetime(2026, 4, 11, 14, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_adapter(base_url: str = "https://paper-api.alpaca.markets") -> AlpacaBrokerAdapter:
|
|
return AlpacaBrokerAdapter(
|
|
api_key="test-key",
|
|
api_secret="test-secret",
|
|
mode=TradingMode.PAPER,
|
|
base_url=base_url,
|
|
)
|
|
|
|
|
|
def _make_buy_order(ticker: str = "AAPL", qty: float = 10) -> OrderRequest:
|
|
return OrderRequest(
|
|
ticker=ticker,
|
|
side=OrderSide.BUY,
|
|
quantity=qty,
|
|
order_type=OrderType.MARKET,
|
|
idempotency_key=f"test-{ticker}-{qty}",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Broker network outage — submit_order fails closed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSubmitOrderFailsClosed:
|
|
"""submit_order must return REJECTED on any network/transport error."""
|
|
|
|
async def test_connection_error_returns_rejected(self):
|
|
adapter = _make_adapter()
|
|
order = _make_buy_order()
|
|
|
|
with patch("httpx.AsyncClient.post", side_effect=httpx.ConnectError("connection refused")):
|
|
resp = await adapter.submit_order(order)
|
|
|
|
assert resp.status == OrderStatus.REJECTED
|
|
assert resp.ok is False
|
|
assert "fail-closed" in resp.error
|
|
|
|
async def test_timeout_returns_rejected(self):
|
|
adapter = _make_adapter()
|
|
order = _make_buy_order()
|
|
|
|
with patch("httpx.AsyncClient.post", side_effect=httpx.ReadTimeout("read timed out")):
|
|
resp = await adapter.submit_order(order)
|
|
|
|
assert resp.status == OrderStatus.REJECTED
|
|
assert resp.ok is False
|
|
assert "fail-closed" in resp.error
|
|
|
|
async def test_dns_error_returns_rejected(self):
|
|
adapter = _make_adapter()
|
|
order = _make_buy_order()
|
|
|
|
with patch("httpx.AsyncClient.post", side_effect=httpx.ConnectError("DNS resolution failed")):
|
|
resp = await adapter.submit_order(order)
|
|
|
|
assert resp.status == OrderStatus.REJECTED
|
|
assert "fail-closed" in resp.error
|
|
|
|
async def test_http_500_returns_rejected(self):
|
|
"""Broker internal server error should result in rejection."""
|
|
adapter = _make_adapter()
|
|
order = _make_buy_order()
|
|
|
|
mock_resp = httpx.Response(500, text="Internal Server Error", request=httpx.Request("POST", "http://test"))
|
|
with patch("httpx.AsyncClient.post", side_effect=httpx.HTTPStatusError("500", response=mock_resp, request=mock_resp.request)):
|
|
resp = await adapter.submit_order(order)
|
|
|
|
assert resp.status == OrderStatus.REJECTED
|
|
assert resp.ok is False
|
|
assert resp.broker_order_id == ""
|
|
|
|
async def test_http_503_returns_rejected(self):
|
|
"""Broker service unavailable should result in rejection."""
|
|
adapter = _make_adapter()
|
|
order = _make_buy_order()
|
|
|
|
mock_resp = httpx.Response(503, text="Service Unavailable", request=httpx.Request("POST", "http://test"))
|
|
with patch("httpx.AsyncClient.post", side_effect=httpx.HTTPStatusError("503", response=mock_resp, request=mock_resp.request)):
|
|
resp = await adapter.submit_order(order)
|
|
|
|
assert resp.status == OrderStatus.REJECTED
|
|
assert resp.ok is False
|
|
|
|
async def test_rejected_order_has_empty_broker_id(self):
|
|
"""Fail-closed responses must not carry a broker order ID that could be confused with a real order."""
|
|
adapter = _make_adapter()
|
|
order = _make_buy_order()
|
|
|
|
with patch("httpx.AsyncClient.post", side_effect=Exception("unexpected")):
|
|
resp = await adapter.submit_order(order)
|
|
|
|
assert resp.broker_order_id == ""
|
|
assert resp.filled_quantity == 0
|
|
assert resp.filled_avg_price is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Ambiguous order states — get_order_status fails closed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestGetOrderStatusFailsClosed:
|
|
"""get_order_status must return REJECTED on errors, not an ambiguous state."""
|
|
|
|
async def test_network_error_returns_rejected(self):
|
|
adapter = _make_adapter()
|
|
|
|
with patch("httpx.AsyncClient.get", side_effect=httpx.ConnectError("refused")):
|
|
resp = await adapter.get_order_status("order-123")
|
|
|
|
assert resp.status == OrderStatus.REJECTED
|
|
assert resp.error is not None
|
|
|
|
async def test_timeout_returns_rejected(self):
|
|
adapter = _make_adapter()
|
|
|
|
with patch("httpx.AsyncClient.get", side_effect=httpx.ReadTimeout("timeout")):
|
|
resp = await adapter.get_order_status("order-123")
|
|
|
|
assert resp.status == OrderStatus.REJECTED
|
|
assert resp.error is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Cancel order fails closed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestCancelOrderFailsClosed:
|
|
"""cancel_order must return REJECTED on errors rather than leaving order in unknown state."""
|
|
|
|
async def test_network_error_returns_rejected(self):
|
|
adapter = _make_adapter()
|
|
|
|
with patch("httpx.AsyncClient.delete", side_effect=httpx.ConnectError("refused")):
|
|
resp = await adapter.cancel_order("order-456")
|
|
|
|
assert resp.status == OrderStatus.REJECTED
|
|
assert resp.error is not None
|
|
|
|
async def test_timeout_returns_rejected(self):
|
|
adapter = _make_adapter()
|
|
|
|
with patch("httpx.AsyncClient.delete", side_effect=httpx.ReadTimeout("timeout")):
|
|
resp = await adapter.cancel_order("order-456")
|
|
|
|
assert resp.status == OrderStatus.REJECTED
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Position and account queries degrade safely
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestPositionAccountDegradation:
|
|
"""Position/account queries must return safe defaults on broker outage."""
|
|
|
|
async def test_get_positions_returns_empty_on_outage(self):
|
|
adapter = _make_adapter()
|
|
|
|
with patch("httpx.AsyncClient.get", side_effect=httpx.ConnectError("refused")):
|
|
positions = await adapter.get_positions()
|
|
|
|
assert positions == []
|
|
|
|
async def test_get_account_returns_zeroed_on_outage(self):
|
|
adapter = _make_adapter()
|
|
|
|
with patch("httpx.AsyncClient.get", side_effect=httpx.ConnectError("refused")):
|
|
acct = await adapter.get_account()
|
|
|
|
assert acct.buying_power == 0
|
|
assert acct.cash == 0
|
|
assert acct.portfolio_value == 0
|
|
assert acct.account_id == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Risk engine fails closed with degraded state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRiskEngineFailClosed:
|
|
"""Risk engine must reject orders when account state is missing or degraded."""
|
|
|
|
def test_zero_portfolio_value_blocks_buy(self):
|
|
"""If broker is down and portfolio_value is 0, position pct → 1.0 → fail."""
|
|
config = PortfolioRiskConfig()
|
|
state = AccountRiskState(portfolio_value=0.0, cash=0.0)
|
|
order = ProposedOrder(
|
|
ticker="AAPL", sector="Technology",
|
|
estimated_value=1000, quantity=10,
|
|
)
|
|
result = evaluate_order(order, config, state)
|
|
assert not result.passed
|
|
pct_check = next(c for c in result.checks if c.check_name == "max_position_pct")
|
|
assert pct_check.result == RiskCheckResult.FAIL
|
|
assert pct_check.actual == 1.0
|
|
|
|
def test_disabled_mode_blocks_all_orders(self):
|
|
config = PortfolioRiskConfig(trading_mode=RiskTradingMode.DISABLED)
|
|
state = AccountRiskState(portfolio_value=100_000.0, cash=50_000.0)
|
|
order = ProposedOrder(
|
|
ticker="AAPL", sector="Technology",
|
|
estimated_value=1000, quantity=10,
|
|
)
|
|
result = evaluate_order(order, config, state)
|
|
assert not result.passed
|
|
assert any("disabled" in r.lower() for r in result.rejection_reasons)
|
|
|
|
def test_degraded_state_with_zero_buying_power(self):
|
|
"""When broker returns zeroed account, position value check should still block large orders."""
|
|
config = PortfolioRiskConfig(
|
|
position_limits=PositionLimits(max_position_value=5_000.0),
|
|
)
|
|
state = AccountRiskState(
|
|
portfolio_value=0.0, cash=0.0, buying_power=0.0,
|
|
)
|
|
order = ProposedOrder(
|
|
ticker="AAPL", sector="Technology",
|
|
estimated_value=10_000.0, quantity=50,
|
|
)
|
|
result = evaluate_order(order, config, state)
|
|
assert not result.passed
|
|
|
|
def test_multiple_risk_failures_all_captured_on_degraded_state(self):
|
|
"""Degraded state should trigger multiple failures, all recorded for audit."""
|
|
config = PortfolioRiskConfig(
|
|
position_limits=PositionLimits(max_position_value=500),
|
|
daily_loss=DailyLossLimits(max_daily_loss_value=0),
|
|
)
|
|
state = AccountRiskState(portfolio_value=0.0, daily_pnl=-1.0)
|
|
order = ProposedOrder(
|
|
ticker="AAPL", sector="Technology",
|
|
estimated_value=1000, quantity=10,
|
|
)
|
|
result = evaluate_order(order, config, state)
|
|
assert not result.passed
|
|
assert len(result.rejection_reasons) >= 2
|
|
# Full decision trace is preserved
|
|
assert len(result.checks) > 0
|
|
assert result.config_snapshot is not None
|
|
assert result.state_snapshot is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Fetch (ingestion path) fails closed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestFetchFailsClosed:
|
|
"""Broker fetch() for ingestion must return error result, not raise."""
|
|
|
|
async def test_fetch_connection_error_returns_error_result(self):
|
|
adapter = _make_adapter()
|
|
|
|
with patch("httpx.AsyncClient.get", side_effect=httpx.ConnectError("refused")):
|
|
result = await adapter.fetch("AAPL", {"endpoint": "positions"})
|
|
|
|
assert not result.ok
|
|
assert result.error is not None
|
|
assert result.items == []
|
|
|
|
async def test_fetch_timeout_returns_error_result(self):
|
|
adapter = _make_adapter()
|
|
|
|
with patch("httpx.AsyncClient.get", side_effect=httpx.ReadTimeout("timeout")):
|
|
result = await adapter.fetch("AAPL", {"endpoint": "orders"})
|
|
|
|
assert not result.ok
|
|
assert result.error is not None
|
|
|
|
async def test_fetch_http_429_returns_error_result(self):
|
|
adapter = _make_adapter()
|
|
|
|
mock_resp = httpx.Response(429, text="Rate limited", request=httpx.Request("GET", "http://test"))
|
|
with patch("httpx.AsyncClient.get", side_effect=httpx.HTTPStatusError("429", response=mock_resp, request=mock_resp.request)):
|
|
result = await adapter.fetch("AAPL", {"endpoint": "positions"})
|
|
|
|
assert not result.ok
|
|
assert result.http_status == 429
|