feat: brokerage abstraction layer with Alpaca implementation
This commit is contained in:
parent
9f46071502
commit
5696da6472
4 changed files with 877 additions and 0 deletions
11
shared/broker/__init__.py
Normal file
11
shared/broker/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Brokerage abstraction layer.
|
||||
|
||||
Provides :class:`BaseBroker` (the interface) and :class:`AlpacaBroker`
|
||||
(the default Alpaca implementation). Additional brokerage adapters can be
|
||||
added by subclassing ``BaseBroker`` and implementing its abstract methods.
|
||||
"""
|
||||
|
||||
from shared.broker.alpaca_broker import AlpacaBroker
|
||||
from shared.broker.base import BaseBroker
|
||||
|
||||
__all__ = ["AlpacaBroker", "BaseBroker"]
|
||||
240
shared/broker/alpaca_broker.py
Normal file
240
shared/broker/alpaca_broker.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""Alpaca brokerage adapter implementing :class:`BaseBroker`.
|
||||
|
||||
Uses the ``alpaca-py`` SDK (``TradingClient``) for order management,
|
||||
position retrieval, and account information. Paper vs. live trading is
|
||||
controlled via the ``paper`` flag in the constructor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from alpaca.common.exceptions import APIError
|
||||
from alpaca.trading.client import TradingClient
|
||||
from alpaca.trading.enums import OrderSide as AlpacaOrderSide
|
||||
from alpaca.trading.enums import OrderStatus as AlpacaOrderStatus
|
||||
from alpaca.trading.enums import 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.base import BaseBroker
|
||||
from shared.schemas.trading import (
|
||||
AccountInfo,
|
||||
OrderRequest,
|
||||
OrderResult,
|
||||
OrderSide,
|
||||
OrderStatus,
|
||||
OrderType,
|
||||
PositionInfo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status mapping helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STATUS_MAP: dict[AlpacaOrderStatus, OrderStatus] = {
|
||||
AlpacaOrderStatus.NEW: OrderStatus.PENDING,
|
||||
AlpacaOrderStatus.ACCEPTED: OrderStatus.PENDING,
|
||||
AlpacaOrderStatus.PENDING_NEW: OrderStatus.PENDING,
|
||||
AlpacaOrderStatus.ACCEPTED_FOR_BIDDING: OrderStatus.PENDING,
|
||||
AlpacaOrderStatus.PENDING_CANCEL: OrderStatus.PENDING,
|
||||
AlpacaOrderStatus.PENDING_REPLACE: OrderStatus.PENDING,
|
||||
AlpacaOrderStatus.PENDING_REVIEW: OrderStatus.PENDING,
|
||||
AlpacaOrderStatus.HELD: OrderStatus.PENDING,
|
||||
AlpacaOrderStatus.PARTIALLY_FILLED: OrderStatus.PENDING,
|
||||
AlpacaOrderStatus.FILLED: OrderStatus.FILLED,
|
||||
AlpacaOrderStatus.DONE_FOR_DAY: OrderStatus.FILLED,
|
||||
AlpacaOrderStatus.CANCELED: OrderStatus.CANCELLED,
|
||||
AlpacaOrderStatus.EXPIRED: OrderStatus.CANCELLED,
|
||||
AlpacaOrderStatus.REPLACED: OrderStatus.CANCELLED,
|
||||
AlpacaOrderStatus.STOPPED: OrderStatus.CANCELLED,
|
||||
AlpacaOrderStatus.SUSPENDED: OrderStatus.CANCELLED,
|
||||
AlpacaOrderStatus.REJECTED: OrderStatus.REJECTED,
|
||||
}
|
||||
|
||||
|
||||
def _map_status(alpaca_status: AlpacaOrderStatus) -> OrderStatus:
|
||||
"""Convert an Alpaca order status to our canonical ``OrderStatus``."""
|
||||
return _STATUS_MAP.get(alpaca_status, OrderStatus.PENDING)
|
||||
|
||||
|
||||
def _map_side(alpaca_side: AlpacaOrderSide | None) -> OrderSide:
|
||||
"""Convert an Alpaca order side to our canonical ``OrderSide``."""
|
||||
if alpaca_side == AlpacaOrderSide.SELL:
|
||||
return OrderSide.SELL
|
||||
return OrderSide.BUY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alpaca broker implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AlpacaBroker(BaseBroker):
|
||||
"""Brokerage adapter backed by the Alpaca ``TradingClient``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
api_key:
|
||||
Alpaca API key ID.
|
||||
secret_key:
|
||||
Alpaca API secret key.
|
||||
paper:
|
||||
If ``True`` (the default), connect to the Alpaca paper-trading
|
||||
sandbox. Set to ``False`` for live trading.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, secret_key: str, *, paper: bool = True) -> None:
|
||||
self._client = TradingClient(
|
||||
api_key=api_key,
|
||||
secret_key=secret_key,
|
||||
paper=paper,
|
||||
)
|
||||
|
||||
# -- internal helpers ----------------------------------------------------
|
||||
|
||||
def _build_order_request(
|
||||
self, order: OrderRequest
|
||||
) -> MarketOrderRequest | LimitOrderRequest | StopOrderRequest:
|
||||
"""Convert our ``OrderRequest`` into the appropriate Alpaca request."""
|
||||
side = AlpacaOrderSide.BUY if order.side == OrderSide.BUY else AlpacaOrderSide.SELL
|
||||
|
||||
if order.order_type == OrderType.LIMIT:
|
||||
if order.limit_price is None:
|
||||
raise ValueError("limit_price is required for limit orders")
|
||||
return LimitOrderRequest(
|
||||
symbol=order.ticker,
|
||||
qty=order.qty,
|
||||
side=side,
|
||||
time_in_force=TimeInForce.DAY,
|
||||
limit_price=order.limit_price,
|
||||
)
|
||||
elif order.order_type == OrderType.STOP:
|
||||
if order.stop_price is None:
|
||||
raise ValueError("stop_price is required for stop orders")
|
||||
return StopOrderRequest(
|
||||
symbol=order.ticker,
|
||||
qty=order.qty,
|
||||
side=side,
|
||||
time_in_force=TimeInForce.DAY,
|
||||
stop_price=order.stop_price,
|
||||
)
|
||||
else:
|
||||
# Default: MARKET order
|
||||
return MarketOrderRequest(
|
||||
symbol=order.ticker,
|
||||
qty=order.qty,
|
||||
side=side,
|
||||
time_in_force=TimeInForce.DAY,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _order_to_result(alpaca_order: AlpacaOrder) -> OrderResult:
|
||||
"""Convert an Alpaca ``Order`` model to our ``OrderResult``."""
|
||||
filled_price: float | None = None
|
||||
if alpaca_order.filled_avg_price is not None:
|
||||
filled_price = float(alpaca_order.filled_avg_price)
|
||||
|
||||
qty = float(alpaca_order.qty) if alpaca_order.qty is not None else 0.0
|
||||
|
||||
timestamp = alpaca_order.submitted_at or alpaca_order.created_at or datetime.now(timezone.utc)
|
||||
|
||||
return OrderResult(
|
||||
order_id=str(alpaca_order.id),
|
||||
ticker=alpaca_order.symbol or "",
|
||||
side=_map_side(alpaca_order.side),
|
||||
qty=qty,
|
||||
filled_price=filled_price,
|
||||
status=_map_status(alpaca_order.status),
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _position_to_info(pos: AlpacaPosition) -> PositionInfo:
|
||||
"""Convert an Alpaca ``Position`` to our ``PositionInfo``."""
|
||||
return PositionInfo(
|
||||
ticker=pos.symbol,
|
||||
qty=float(pos.qty),
|
||||
avg_entry=float(pos.avg_entry_price),
|
||||
current_price=float(pos.current_price) if pos.current_price else 0.0,
|
||||
unrealized_pnl=float(pos.unrealized_pl) if pos.unrealized_pl else 0.0,
|
||||
market_value=float(pos.market_value) if pos.market_value else 0.0,
|
||||
)
|
||||
|
||||
# -- BaseBroker interface ------------------------------------------------
|
||||
|
||||
async def submit_order(self, order: OrderRequest) -> OrderResult:
|
||||
"""Submit an order to Alpaca.
|
||||
|
||||
Converts the ``OrderRequest`` into the appropriate Alpaca request
|
||||
object, submits it via the ``TradingClient``, and returns an
|
||||
``OrderResult`` reflecting the initial state of the order.
|
||||
|
||||
If the API rejects the order an ``OrderResult`` with status
|
||||
``REJECTED`` is returned rather than raising an exception.
|
||||
"""
|
||||
try:
|
||||
alpaca_request = self._build_order_request(order)
|
||||
alpaca_order: AlpacaOrder = await asyncio.to_thread(
|
||||
self._client.submit_order, alpaca_request
|
||||
)
|
||||
return self._order_to_result(alpaca_order)
|
||||
except APIError as exc:
|
||||
logger.warning("Order rejected by Alpaca: %s", exc)
|
||||
return OrderResult(
|
||||
order_id="",
|
||||
ticker=order.ticker,
|
||||
side=order.side,
|
||||
qty=order.qty,
|
||||
filled_price=None,
|
||||
status=OrderStatus.REJECTED,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
async def cancel_order(self, order_id: str) -> bool:
|
||||
"""Cancel an order on Alpaca by its ID.
|
||||
|
||||
Returns ``True`` if the cancellation request was accepted, or
|
||||
``False`` if the API raised an error (e.g. the order was already
|
||||
filled or does not exist).
|
||||
"""
|
||||
try:
|
||||
await asyncio.to_thread(self._client.cancel_order_by_id, order_id)
|
||||
return True
|
||||
except APIError as exc:
|
||||
logger.warning("Failed to cancel order %s: %s", order_id, exc)
|
||||
return False
|
||||
|
||||
async def get_positions(self) -> list[PositionInfo]:
|
||||
"""Return all open positions from Alpaca."""
|
||||
positions: list[AlpacaPosition] = await asyncio.to_thread(
|
||||
self._client.get_all_positions
|
||||
)
|
||||
return [self._position_to_info(p) for p in positions]
|
||||
|
||||
async def get_account(self) -> AccountInfo:
|
||||
"""Return account summary from Alpaca."""
|
||||
account: TradeAccount = await asyncio.to_thread(self._client.get_account)
|
||||
return AccountInfo(
|
||||
equity=float(account.equity) if account.equity else 0.0,
|
||||
cash=float(account.cash) if account.cash else 0.0,
|
||||
buying_power=float(account.buying_power) if account.buying_power else 0.0,
|
||||
portfolio_value=float(account.portfolio_value) if account.portfolio_value else 0.0,
|
||||
)
|
||||
|
||||
async def get_order_status(self, order_id: str) -> OrderResult:
|
||||
"""Fetch the current state of an order from Alpaca."""
|
||||
alpaca_order: AlpacaOrder = await asyncio.to_thread(
|
||||
self._client.get_order_by_id, order_id
|
||||
)
|
||||
return self._order_to_result(alpaca_order)
|
||||
87
shared/broker/base.py
Normal file
87
shared/broker/base.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Abstract base class for brokerage integrations.
|
||||
|
||||
All broker implementations must inherit from ``BaseBroker`` and provide
|
||||
concrete implementations for order management, position tracking, and
|
||||
account information retrieval. This abstraction layer allows the trading
|
||||
bot to swap brokerages (Alpaca, Interactive Brokers, Tradier, ...) without
|
||||
changing strategy or execution logic.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from shared.schemas.trading import AccountInfo, OrderRequest, OrderResult, PositionInfo
|
||||
|
||||
|
||||
class BaseBroker(ABC):
|
||||
"""Interface that every brokerage adapter must implement."""
|
||||
|
||||
@abstractmethod
|
||||
async def submit_order(self, order: OrderRequest) -> OrderResult:
|
||||
"""Submit a new order to the brokerage.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
order:
|
||||
The order details including ticker, side, quantity, and order type.
|
||||
|
||||
Returns
|
||||
-------
|
||||
OrderResult
|
||||
Result containing the order ID, status, and fill information.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def cancel_order(self, order_id: str) -> bool:
|
||||
"""Cancel an open order.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
order_id:
|
||||
The brokerage-assigned order identifier.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` if the cancellation was accepted, ``False`` otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_positions(self) -> list[PositionInfo]:
|
||||
"""Return all currently open positions.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[PositionInfo]
|
||||
One entry per open position with quantity, average entry, current
|
||||
price, and unrealized P&L.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_account(self) -> AccountInfo:
|
||||
"""Return account-level summary information.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AccountInfo
|
||||
Equity, cash, buying power, and total portfolio value.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_order_status(self, order_id: str) -> OrderResult:
|
||||
"""Fetch the current status of an existing order.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
order_id:
|
||||
The brokerage-assigned order identifier.
|
||||
|
||||
Returns
|
||||
-------
|
||||
OrderResult
|
||||
Current state of the order including fill price if applicable.
|
||||
"""
|
||||
...
|
||||
539
tests/test_broker.py
Normal file
539
tests/test_broker.py
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue