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.
|
||||
"""
|
||||
...
|
||||
Loading…
Add table
Add a link
Reference in a new issue