"""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, ) -> 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=OrderClass.SIMPLE, 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=None, 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