"""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