trading/tests/test_broker.py

539 lines
18 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,
) -> 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