trading/tests/test_broker.py
Viktor Barzin 82dc622544 feat(kevin): reconcile Alpaca bracket auto-closes + order status
Bracket stop-loss/take-profit legs fill at Alpaca without passing through the executor, so those closes (and their P&L) were invisible locally.

- broker: add get_order(nested) + list_orders to BaseBroker/AlpacaBroker (+ SimulatedBroker); BrokerOrder carries child legs

- Trade gains broker_order_id (migration f6a7b8c9d0e1); executor stamps the entry order id

- new api_gateway trade-reconcile loop: books a closing SELL + realized P&L when a bracket leg fills (idempotent on the leg order id), syncs PENDING->terminal status, logs drift; runs alongside portfolio_sync

[ci skip]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:31:24 +00:00

695 lines
24 KiB
Python

"""Tests for the brokerage abstraction layer with a mocked Alpaca TradingClient."""
import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from alpaca.common.exceptions import APIError
from alpaca.trading.enums import OrderSide as AlpacaOrderSide
from alpaca.trading.enums import OrderStatus as AlpacaOrderStatus
from alpaca.trading.enums import OrderType as AlpacaOrderType
from alpaca.trading.enums import AssetClass, AssetExchange, OrderClass, PositionSide, TimeInForce
from alpaca.trading.models import Order as AlpacaOrder
from alpaca.trading.models import Position as AlpacaPosition
from alpaca.trading.models import TradeAccount
from alpaca.trading.requests import LimitOrderRequest, MarketOrderRequest, StopOrderRequest
from shared.broker.alpaca_broker import AlpacaBroker
from shared.schemas.trading import (
AccountInfo,
OrderRequest,
OrderResult,
OrderSide,
OrderStatus,
OrderType,
PositionInfo,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_alpaca_order(
*,
order_id: str | None = None,
symbol: str = "AAPL",
side: AlpacaOrderSide = AlpacaOrderSide.BUY,
qty: str = "10",
filled_avg_price: str | None = None,
status: AlpacaOrderStatus = AlpacaOrderStatus.NEW,
submitted_at: datetime | None = None,
order_class: OrderClass = OrderClass.SIMPLE,
legs: list[AlpacaOrder] | None = None,
) -> AlpacaOrder:
"""Build a minimal Alpaca ``Order`` model for testing."""
oid = uuid.UUID(order_id) if order_id else uuid.uuid4()
now = submitted_at or datetime.now(timezone.utc)
return AlpacaOrder(
id=oid,
client_order_id=str(uuid.uuid4()),
created_at=now,
updated_at=now,
submitted_at=now,
filled_at=None,
expired_at=None,
canceled_at=None,
failed_at=None,
replaced_at=None,
replaced_by=None,
replaces=None,
asset_id=uuid.uuid4(),
symbol=symbol,
asset_class=AssetClass.US_EQUITY,
notional=None,
qty=qty,
filled_qty="0",
filled_avg_price=filled_avg_price,
order_class=order_class,
order_type=AlpacaOrderType.MARKET,
type=AlpacaOrderType.MARKET,
side=side,
time_in_force=TimeInForce.DAY,
limit_price=None,
stop_price=None,
status=status,
extended_hours=False,
legs=legs,
trail_percent=None,
trail_price=None,
hwm=None,
)
def _make_alpaca_position(
*,
symbol: str = "AAPL",
qty: str = "10",
avg_entry_price: str = "150.00",
current_price: str = "155.00",
unrealized_pl: str = "50.00",
market_value: str = "1550.00",
) -> AlpacaPosition:
"""Build a minimal Alpaca ``Position`` model for testing."""
return AlpacaPosition(
asset_id=uuid.uuid4(),
symbol=symbol,
exchange=AssetExchange.NASDAQ,
asset_class=AssetClass.US_EQUITY,
asset_marginable=True,
avg_entry_price=avg_entry_price,
qty=qty,
side=PositionSide.LONG,
market_value=market_value,
cost_basis="1500.00",
unrealized_pl=unrealized_pl,
unrealized_plpc="0.0333",
unrealized_intraday_pl="50.00",
unrealized_intraday_plpc="0.0333",
current_price=current_price,
lastday_price="150.00",
change_today="0.0333",
)
def _make_alpaca_account(
*,
equity: str = "100000.00",
cash: str = "50000.00",
buying_power: str = "200000.00",
portfolio_value: str = "100000.00",
) -> TradeAccount:
"""Build a minimal Alpaca ``TradeAccount`` model for testing."""
return TradeAccount(
id=uuid.uuid4(),
account_number="test-123",
status="ACTIVE",
crypto_status=None,
currency="USD",
buying_power=buying_power,
regt_buying_power=buying_power,
daytrading_buying_power=buying_power,
non_marginable_buying_power=cash,
cash=cash,
accrued_fees="0",
pending_transfer_in=None,
portfolio_value=portfolio_value,
pattern_day_trader=False,
trading_blocked=False,
transfers_blocked=False,
account_blocked=False,
created_at=datetime.now(timezone.utc),
trade_suspended_by_user=False,
multiplier="2",
shorting_enabled=True,
equity=equity,
last_equity=equity,
long_market_value="50000.00",
short_market_value="0",
initial_margin="25000.00",
maintenance_margin="15000.00",
last_maintenance_margin="15000.00",
sma="100000.00",
daytrade_count=0,
)
@pytest.fixture
def mock_client() -> MagicMock:
"""Return a mocked ``TradingClient``."""
return MagicMock()
@pytest.fixture
def broker(mock_client: MagicMock) -> AlpacaBroker:
"""Return an ``AlpacaBroker`` whose internal client is mocked."""
with patch("shared.broker.alpaca_broker.TradingClient", return_value=mock_client):
b = AlpacaBroker(api_key="test-key", secret_key="test-secret", paper=True)
return b
# ---------------------------------------------------------------------------
# Order submission
# ---------------------------------------------------------------------------
class TestSubmitMarketOrder:
@pytest.mark.asyncio
async def test_submit_market_order(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A market buy order should be converted and submitted to Alpaca."""
alpaca_order = _make_alpaca_order(
symbol="AAPL",
side=AlpacaOrderSide.BUY,
qty="10",
status=AlpacaOrderStatus.NEW,
)
mock_client.submit_order.return_value = alpaca_order
order = OrderRequest(ticker="AAPL", side=OrderSide.BUY, qty=10.0, order_type=OrderType.MARKET)
result = await broker.submit_order(order)
assert isinstance(result, OrderResult)
assert result.ticker == "AAPL"
assert result.side == OrderSide.BUY
assert result.qty == 10.0
assert result.status == OrderStatus.PENDING
assert result.order_id == str(alpaca_order.id)
assert result.filled_price is None
# Verify the Alpaca client received a MarketOrderRequest
submitted = mock_client.submit_order.call_args[0][0]
assert isinstance(submitted, MarketOrderRequest)
assert submitted.symbol == "AAPL"
assert submitted.qty == 10.0
assert submitted.side == AlpacaOrderSide.BUY
@pytest.mark.asyncio
async def test_submit_market_sell(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A market sell order maps the side correctly."""
alpaca_order = _make_alpaca_order(
symbol="TSLA",
side=AlpacaOrderSide.SELL,
qty="5",
status=AlpacaOrderStatus.FILLED,
filled_avg_price="200.50",
)
mock_client.submit_order.return_value = alpaca_order
order = OrderRequest(ticker="TSLA", side=OrderSide.SELL, qty=5.0)
result = await broker.submit_order(order)
assert result.side == OrderSide.SELL
assert result.status == OrderStatus.FILLED
assert result.filled_price == 200.50
class TestSubmitLimitOrder:
@pytest.mark.asyncio
async def test_submit_limit_order(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A limit order should include the limit price in the Alpaca request."""
alpaca_order = _make_alpaca_order(
symbol="MSFT",
side=AlpacaOrderSide.BUY,
qty="20",
status=AlpacaOrderStatus.ACCEPTED,
)
mock_client.submit_order.return_value = alpaca_order
order = OrderRequest(
ticker="MSFT",
side=OrderSide.BUY,
qty=20.0,
order_type=OrderType.LIMIT,
limit_price=350.00,
)
result = await broker.submit_order(order)
assert isinstance(result, OrderResult)
assert result.ticker == "MSFT"
assert result.status == OrderStatus.PENDING
submitted = mock_client.submit_order.call_args[0][0]
assert isinstance(submitted, LimitOrderRequest)
assert submitted.limit_price == 350.00
@pytest.mark.asyncio
async def test_limit_order_missing_price_raises(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A limit order without limit_price should raise ValueError."""
order = OrderRequest(
ticker="MSFT",
side=OrderSide.BUY,
qty=20.0,
order_type=OrderType.LIMIT,
limit_price=None,
)
with pytest.raises(ValueError, match="limit_price is required"):
await broker.submit_order(order)
class TestSubmitStopOrder:
@pytest.mark.asyncio
async def test_submit_stop_order(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A stop order should include the stop price in the Alpaca request."""
alpaca_order = _make_alpaca_order(
symbol="GOOG",
side=AlpacaOrderSide.SELL,
qty="15",
status=AlpacaOrderStatus.NEW,
)
mock_client.submit_order.return_value = alpaca_order
order = OrderRequest(
ticker="GOOG",
side=OrderSide.SELL,
qty=15.0,
order_type=OrderType.STOP,
stop_price=140.00,
)
result = await broker.submit_order(order)
assert isinstance(result, OrderResult)
assert result.ticker == "GOOG"
submitted = mock_client.submit_order.call_args[0][0]
assert isinstance(submitted, StopOrderRequest)
assert submitted.stop_price == 140.00
@pytest.mark.asyncio
async def test_stop_order_missing_price_raises(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A stop order without stop_price should raise ValueError."""
order = OrderRequest(
ticker="GOOG",
side=OrderSide.SELL,
qty=15.0,
order_type=OrderType.STOP,
stop_price=None,
)
with pytest.raises(ValueError, match="stop_price is required"):
await broker.submit_order(order)
# ---------------------------------------------------------------------------
# Order rejection
# ---------------------------------------------------------------------------
class TestSubmitOrderRejected:
@pytest.mark.asyncio
async def test_api_error_returns_rejected_result(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""If Alpaca's API raises an error, submit_order returns REJECTED."""
mock_client.submit_order.side_effect = APIError("insufficient buying power")
order = OrderRequest(ticker="AAPL", side=OrderSide.BUY, qty=10000.0)
result = await broker.submit_order(order)
assert isinstance(result, OrderResult)
assert result.status == OrderStatus.REJECTED
assert result.ticker == "AAPL"
assert result.side == OrderSide.BUY
assert result.qty == 10000.0
assert result.filled_price is None
assert result.order_id == ""
# ---------------------------------------------------------------------------
# Cancel order
# ---------------------------------------------------------------------------
class TestCancelOrder:
@pytest.mark.asyncio
async def test_cancel_order_success(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""Successful cancellation returns True."""
mock_client.cancel_order_by_id.return_value = None # void method
result = await broker.cancel_order("some-order-id")
assert result is True
mock_client.cancel_order_by_id.assert_called_once_with("some-order-id")
@pytest.mark.asyncio
async def test_cancel_order_failure(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""If the API raises an error (e.g. order already filled), return False."""
mock_client.cancel_order_by_id.side_effect = APIError("order is not cancelable")
result = await broker.cancel_order("some-order-id")
assert result is False
# ---------------------------------------------------------------------------
# Positions
# ---------------------------------------------------------------------------
class TestGetPositions:
@pytest.mark.asyncio
async def test_get_positions_empty(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""When there are no open positions, return an empty list."""
mock_client.get_all_positions.return_value = []
positions = await broker.get_positions()
assert positions == []
@pytest.mark.asyncio
async def test_get_positions_with_data(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""Positions are correctly converted to PositionInfo."""
mock_client.get_all_positions.return_value = [
_make_alpaca_position(
symbol="AAPL",
qty="10",
avg_entry_price="150.00",
current_price="155.00",
unrealized_pl="50.00",
market_value="1550.00",
),
_make_alpaca_position(
symbol="TSLA",
qty="5",
avg_entry_price="200.00",
current_price="190.00",
unrealized_pl="-50.00",
market_value="950.00",
),
]
positions = await broker.get_positions()
assert len(positions) == 2
aapl = positions[0]
assert isinstance(aapl, PositionInfo)
assert aapl.ticker == "AAPL"
assert aapl.qty == 10.0
assert aapl.avg_entry == 150.0
assert aapl.current_price == 155.0
assert aapl.unrealized_pnl == 50.0
assert aapl.market_value == 1550.0
tsla = positions[1]
assert tsla.ticker == "TSLA"
assert tsla.qty == 5.0
assert tsla.unrealized_pnl == -50.0
# ---------------------------------------------------------------------------
# Account
# ---------------------------------------------------------------------------
class TestGetAccount:
@pytest.mark.asyncio
async def test_get_account(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""Account info is correctly converted from TradeAccount."""
mock_client.get_account.return_value = _make_alpaca_account(
equity="100000.00",
cash="50000.00",
buying_power="200000.00",
portfolio_value="100000.00",
)
account = await broker.get_account()
assert isinstance(account, AccountInfo)
assert account.equity == 100000.0
assert account.cash == 50000.0
assert account.buying_power == 200000.0
assert account.portfolio_value == 100000.0
# ---------------------------------------------------------------------------
# Order status
# ---------------------------------------------------------------------------
class TestGetOrderStatus:
@pytest.mark.asyncio
async def test_get_order_status_filled(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A filled order returns FILLED status and the fill price."""
order_id = str(uuid.uuid4())
alpaca_order = _make_alpaca_order(
order_id=order_id,
symbol="AAPL",
side=AlpacaOrderSide.BUY,
qty="10",
status=AlpacaOrderStatus.FILLED,
filled_avg_price="152.30",
)
mock_client.get_order_by_id.return_value = alpaca_order
result = await broker.get_order_status(order_id)
assert isinstance(result, OrderResult)
assert result.order_id == order_id
assert result.status == OrderStatus.FILLED
assert result.filled_price == 152.30
assert result.ticker == "AAPL"
assert result.side == OrderSide.BUY
mock_client.get_order_by_id.assert_called_once_with(order_id)
@pytest.mark.asyncio
async def test_get_order_status_pending(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A pending order returns PENDING status with no fill price."""
order_id = str(uuid.uuid4())
alpaca_order = _make_alpaca_order(
order_id=order_id,
symbol="MSFT",
status=AlpacaOrderStatus.PENDING_NEW,
)
mock_client.get_order_by_id.return_value = alpaca_order
result = await broker.get_order_status(order_id)
assert result.status == OrderStatus.PENDING
assert result.filled_price is None
@pytest.mark.asyncio
async def test_get_order_status_cancelled(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A cancelled order returns CANCELLED status."""
order_id = str(uuid.uuid4())
alpaca_order = _make_alpaca_order(
order_id=order_id,
symbol="TSLA",
status=AlpacaOrderStatus.CANCELED,
)
mock_client.get_order_by_id.return_value = alpaca_order
result = await broker.get_order_status(order_id)
assert result.status == OrderStatus.CANCELLED
@pytest.mark.asyncio
async def test_get_order_status_rejected(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A rejected order returns REJECTED status."""
order_id = str(uuid.uuid4())
alpaca_order = _make_alpaca_order(
order_id=order_id,
symbol="GOOG",
status=AlpacaOrderStatus.REJECTED,
)
mock_client.get_order_by_id.return_value = alpaca_order
result = await broker.get_order_status(order_id)
assert result.status == OrderStatus.REJECTED
# ---------------------------------------------------------------------------
# BaseBroker interface
# ---------------------------------------------------------------------------
class TestBaseBrokerInterface:
def test_alpaca_broker_is_subclass(self) -> None:
"""AlpacaBroker should be a proper subclass of BaseBroker."""
from shared.broker.base import BaseBroker
assert issubclass(AlpacaBroker, BaseBroker)
def test_package_exports(self) -> None:
"""The broker package should export BaseBroker and AlpacaBroker."""
from shared.broker import AlpacaBroker as AB
from shared.broker import BaseBroker as BB
assert AB is AlpacaBroker
from shared.broker.base import BaseBroker
assert BB is BaseBroker
# ---------------------------------------------------------------------------
# get_order — nested bracket parent + legs (Phase 4)
# ---------------------------------------------------------------------------
class TestGetOrderNested:
"""``get_order`` returns a ``BrokerOrder`` exposing the parent plus its
bracket child legs (stop-loss / take-profit) with status + fill price, so
reconciliation can tell which leg filled and at what price."""
@pytest.mark.asyncio
async def test_get_order_returns_broker_order(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
from shared.schemas.trading import BrokerOrder
parent_id = str(uuid.uuid4())
parent = _make_alpaca_order(
order_id=parent_id,
symbol="NVDA",
side=AlpacaOrderSide.BUY,
qty="10",
status=AlpacaOrderStatus.FILLED,
filled_avg_price="100.00",
order_class=OrderClass.BRACKET,
legs=[],
)
mock_client.get_order_by_id.return_value = parent
result = await broker.get_order(parent_id)
assert isinstance(result, BrokerOrder)
assert result.order_id == parent_id
assert result.ticker == "NVDA"
assert result.status == OrderStatus.FILLED
assert result.filled_price == 100.00
assert result.legs == []
@pytest.mark.asyncio
async def test_get_order_maps_take_profit_leg(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
"""A bracket parent with a FILLED take-profit leg exposes that leg with
its fill price; the stop-loss leg is reported still pending."""
parent_id = str(uuid.uuid4())
tp_id = str(uuid.uuid4())
sl_id = str(uuid.uuid4())
tp_leg = _make_alpaca_order(
order_id=tp_id,
symbol="NVDA",
side=AlpacaOrderSide.SELL,
qty="10",
status=AlpacaOrderStatus.FILLED,
filled_avg_price="120.00",
)
sl_leg = _make_alpaca_order(
order_id=sl_id,
symbol="NVDA",
side=AlpacaOrderSide.SELL,
qty="10",
status=AlpacaOrderStatus.HELD,
)
parent = _make_alpaca_order(
order_id=parent_id,
symbol="NVDA",
side=AlpacaOrderSide.BUY,
qty="10",
status=AlpacaOrderStatus.FILLED,
filled_avg_price="100.00",
order_class=OrderClass.BRACKET,
legs=[tp_leg, sl_leg],
)
mock_client.get_order_by_id.return_value = parent
result = await broker.get_order(parent_id)
assert len(result.legs) == 2
tp = next(leg for leg in result.legs if leg.order_id == tp_id)
sl = next(leg for leg in result.legs if leg.order_id == sl_id)
assert tp.status == OrderStatus.FILLED
assert tp.filled_price == 120.00
assert tp.side == OrderSide.SELL
assert sl.status == OrderStatus.PENDING
assert sl.filled_price is None
@pytest.mark.asyncio
async def test_get_order_passes_nested_flag(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
"""``nested=True`` must reach Alpaca as a GetOrderByIdRequest so the
legs come back populated."""
from alpaca.trading.requests import GetOrderByIdRequest
parent_id = str(uuid.uuid4())
mock_client.get_order_by_id.return_value = _make_alpaca_order(
order_id=parent_id, order_class=OrderClass.BRACKET, legs=[]
)
await broker.get_order(parent_id, nested=True)
call = mock_client.get_order_by_id.call_args
assert call.args[0] == parent_id
req = call.args[1] if len(call.args) > 1 else call.kwargs.get("filter")
assert isinstance(req, GetOrderByIdRequest)
assert req.nested is True
@pytest.mark.asyncio
async def test_get_order_returns_none_on_missing(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
"""A missing order (Alpaca 404 -> APIError) yields ``None`` rather than
raising, so reconciliation can log drift and move on."""
mock_client.get_order_by_id.side_effect = APIError("order not found")
result = await broker.get_order(str(uuid.uuid4()))
assert result is None
# ---------------------------------------------------------------------------
# list_orders (Phase 4)
# ---------------------------------------------------------------------------
class TestListOrders:
@pytest.mark.asyncio
async def test_list_orders_maps_results(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
o1 = _make_alpaca_order(symbol="AAPL", status=AlpacaOrderStatus.FILLED, filled_avg_price="1")
o2 = _make_alpaca_order(symbol="MSFT", status=AlpacaOrderStatus.NEW)
mock_client.get_orders.return_value = [o1, o2]
results = await broker.list_orders()
assert len(results) == 2
assert {r.ticker for r in results} == {"AAPL", "MSFT"}
assert all(isinstance(r, OrderResult) for r in results)
@pytest.mark.asyncio
async def test_list_orders_builds_request(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
from alpaca.trading.requests import GetOrdersRequest
mock_client.get_orders.return_value = []
await broker.list_orders(status="all", limit=25)
req = mock_client.get_orders.call_args.args[0]
assert isinstance(req, GetOrdersRequest)
assert req.limit == 25