feat(execution): add Alpaca broker for US stocks, ETFs, crypto - Issue #24 (37 tests)

This commit is contained in:
Andrew Kaszubski 2025-12-26 21:06:32 +11:00
parent 850346a47a
commit 593d59937c
3 changed files with 1774 additions and 0 deletions

View File

@ -0,0 +1,988 @@
"""Tests for Alpaca Broker module.
Issue #24: [EXEC-23] Alpaca broker - US stocks, ETFs, crypto
These tests use mocks to test the broker without requiring actual
Alpaca API credentials.
"""
import pytest
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import sys
from tradingagents.execution import (
# Enums
AssetClass,
OrderSide,
OrderType,
TimeInForce,
OrderStatus,
PositionSide,
# Data Classes
OrderRequest,
Order,
Position,
AccountInfo,
Quote,
AssetInfo,
# Exceptions
BrokerError,
AuthenticationError,
ConnectionError,
OrderError,
InsufficientFundsError,
InvalidOrderError,
PositionError,
RateLimitError,
)
# =============================================================================
# Mock Alpaca SDK
# =============================================================================
class MockAlpacaOrderSide:
"""Mock Alpaca OrderSide enum."""
BUY = "buy"
SELL = "sell"
class MockAlpacaOrderType:
"""Mock Alpaca OrderType enum."""
MARKET = "market"
LIMIT = "limit"
STOP = "stop"
STOP_LIMIT = "stop_limit"
TRAILING_STOP = "trailing_stop"
class MockAlpacaOrderStatus:
"""Mock Alpaca OrderStatus enum."""
NEW = "new"
ACCEPTED = "accepted"
PENDING_NEW = "pending_new"
PARTIALLY_FILLED = "partially_filled"
FILLED = "filled"
CANCELED = "canceled"
EXPIRED = "expired"
REPLACED = "replaced"
REJECTED = "rejected"
DONE_FOR_DAY = "done_for_day"
PENDING_CANCEL = "pending_cancel"
PENDING_REPLACE = "pending_replace"
ACCEPTED_FOR_BIDDING = "accepted_for_bidding"
STOPPED = "stopped"
SUSPENDED = "suspended"
CALCULATED = "calculated"
class MockAlpacaTimeInForce:
"""Mock Alpaca TimeInForce enum."""
DAY = "day"
GTC = "gtc"
IOC = "ioc"
FOK = "fok"
OPG = "opg"
CLS = "cls"
class MockQueryOrderStatus:
"""Mock Alpaca QueryOrderStatus."""
OPEN = "open"
CLOSED = "closed"
ALL = "all"
class MockAlpacaPositionSide:
"""Mock Alpaca PositionSide."""
LONG = "long"
SHORT = "short"
class MockAlpacaAccount:
"""Mock Alpaca account response."""
def __init__(
self,
account_number: str = "TEST123456",
status: str = "ACTIVE",
cash: str = "100000.00",
portfolio_value: str = "150000.00",
buying_power: str = "200000.00",
equity: str = "150000.00",
initial_margin: str = "5000.00",
regt_buying_power: str = "195000.00",
daytrade_count: int = 0,
pattern_day_trader: bool = False,
account_type: str = "margin",
):
self.account_number = account_number
self.status = status
self.cash = cash
self.portfolio_value = portfolio_value
self.buying_power = buying_power
self.equity = equity
self.initial_margin = initial_margin
self.regt_buying_power = regt_buying_power
self.daytrade_count = daytrade_count
self.pattern_day_trader = pattern_day_trader
self.account_type = account_type
class MockAlpacaClock:
"""Mock Alpaca clock response."""
def __init__(self, is_open: bool = True):
self.is_open = is_open
self.timestamp = datetime.now(timezone.utc)
self.next_open = datetime.now(timezone.utc)
self.next_close = datetime.now(timezone.utc)
class MockAlpacaOrder:
"""Mock Alpaca order response."""
def __init__(
self,
id: str = "order-123",
client_order_id: Optional[str] = None,
symbol: str = "AAPL",
side: str = "buy",
qty: str = "100",
order_type: str = "market",
status: str = "new",
limit_price: Optional[str] = None,
stop_price: Optional[str] = None,
time_in_force: str = "day",
filled_qty: str = "0",
filled_avg_price: Optional[str] = None,
):
self.id = id
self.client_order_id = client_order_id
self.symbol = symbol
self.side = MagicMock()
self.side.__eq__ = lambda s, other: side == other.value if hasattr(other, 'value') else side == other
# For comparison, mock the value attribute
if side == "buy":
self.side = type('MockSide', (), {'value': 'buy', '__eq__': lambda s, o: o == MockAlpacaOrderSide.BUY or getattr(o, 'value', o) == 'buy'})()
else:
self.side = type('MockSide', (), {'value': 'sell', '__eq__': lambda s, o: o == MockAlpacaOrderSide.SELL or getattr(o, 'value', o) == 'sell'})()
self.qty = qty
self.order_type = type('MockOrderType', (), {'value': order_type})()
self.status = type('MockStatus', (), {'value': status})()
self.limit_price = limit_price
self.stop_price = stop_price
self.time_in_force = type('MockTIF', (), {'value': time_in_force})()
self.filled_qty = filled_qty
self.filled_avg_price = filled_avg_price
self.created_at = datetime.now(timezone.utc)
self.updated_at = datetime.now(timezone.utc)
self.submitted_at = datetime.now(timezone.utc)
self.filled_at = None
self.expired_at = None
self.canceled_at = None
class MockAlpacaPosition:
"""Mock Alpaca position response."""
def __init__(
self,
symbol: str = "AAPL",
qty: str = "100",
avg_entry_price: str = "150.00",
current_price: str = "160.00",
market_value: str = "16000.00",
cost_basis: str = "15000.00",
unrealized_pl: str = "1000.00",
unrealized_plpc: str = "0.0667",
asset_class: str = "us_equity",
):
self.symbol = symbol
self.qty = qty
self.avg_entry_price = avg_entry_price
self.current_price = current_price
self.market_value = market_value
self.cost_basis = cost_basis
self.unrealized_pl = unrealized_pl
self.unrealized_plpc = unrealized_plpc
self.asset_class = asset_class
class MockAlpacaAsset:
"""Mock Alpaca asset response."""
def __init__(
self,
symbol: str = "AAPL",
name: str = "Apple Inc.",
asset_class: str = "us_equity",
exchange: str = "NASDAQ",
tradable: bool = True,
shortable: bool = True,
marginable: bool = True,
fractionable: bool = True,
easy_to_borrow: bool = True,
):
self.symbol = symbol
self.name = name
self.asset_class = asset_class
self.exchange = exchange
self.tradable = tradable
self.shortable = shortable
self.marginable = marginable
self.fractionable = fractionable
self.easy_to_borrow = easy_to_borrow
class MockAlpacaQuote:
"""Mock Alpaca quote response."""
def __init__(
self,
symbol: str = "AAPL",
bid_price: float = 159.95,
ask_price: float = 160.05,
bid_size: int = 100,
ask_size: int = 100,
):
self.symbol = symbol
self.bid_price = bid_price
self.ask_price = ask_price
self.bid_size = bid_size
self.ask_size = ask_size
self.timestamp = datetime.now(timezone.utc)
class MockTradingClient:
"""Mock Alpaca TradingClient."""
def __init__(
self,
api_key: str,
secret_key: str,
paper: bool = True,
):
self.api_key = api_key
self.secret_key = secret_key
self.paper = paper
self._orders: Dict[str, MockAlpacaOrder] = {}
self._order_counter = 0
def get_account(self) -> MockAlpacaAccount:
return MockAlpacaAccount()
def get_clock(self) -> MockAlpacaClock:
return MockAlpacaClock()
def submit_order(self, request: Any) -> MockAlpacaOrder:
self._order_counter += 1
order_id = f"order-{self._order_counter}"
order = MockAlpacaOrder(
id=order_id,
symbol=request.symbol,
qty=str(request.qty),
side="buy" if str(request.side).lower() == "buy" else "sell",
order_type=getattr(request, 'order_type', 'market') or 'market',
limit_price=str(getattr(request, 'limit_price', None)) if getattr(request, 'limit_price', None) else None,
stop_price=str(getattr(request, 'stop_price', None)) if getattr(request, 'stop_price', None) else None,
client_order_id=getattr(request, 'client_order_id', None),
)
self._orders[order_id] = order
return order
def cancel_order_by_id(self, order_id: str) -> None:
if order_id in self._orders:
self._orders[order_id].status = type('MockStatus', (), {'value': 'canceled'})()
def get_order_by_id(self, order_id: str) -> MockAlpacaOrder:
if order_id in self._orders:
return self._orders[order_id]
raise Exception(f"Order {order_id} not found")
def replace_order_by_id(self, order_id: str, order_data: Any) -> MockAlpacaOrder:
self._order_counter += 1
new_order_id = f"order-{self._order_counter}"
old_order = self._orders.get(order_id)
if not old_order:
raise Exception(f"Order {order_id} not found")
new_order = MockAlpacaOrder(
id=new_order_id,
symbol=old_order.symbol,
qty=str(order_data.qty) if order_data.qty else old_order.qty,
side=old_order.side.value,
limit_price=str(order_data.limit_price) if order_data.limit_price else old_order.limit_price,
stop_price=str(order_data.stop_price) if order_data.stop_price else old_order.stop_price,
)
self._orders[new_order_id] = new_order
return new_order
def get_orders(self, request: Any) -> List[MockAlpacaOrder]:
return list(self._orders.values())
def get_all_positions(self) -> List[MockAlpacaPosition]:
return [MockAlpacaPosition()]
def get_open_position(self, symbol: str) -> MockAlpacaPosition:
return MockAlpacaPosition(symbol=symbol)
def get_asset(self, symbol: str) -> MockAlpacaAsset:
return MockAlpacaAsset(symbol=symbol)
class MockStockHistoricalDataClient:
"""Mock Alpaca StockHistoricalDataClient."""
def __init__(self, api_key: str, secret_key: str):
self.api_key = api_key
self.secret_key = secret_key
def get_stock_latest_quote(self, request: Any) -> Dict[str, MockAlpacaQuote]:
symbols = request.symbol_or_symbols
return {symbol: MockAlpacaQuote(symbol=symbol) for symbol in symbols}
class MockCryptoHistoricalDataClient:
"""Mock Alpaca CryptoHistoricalDataClient."""
def __init__(self, api_key: str, secret_key: str):
self.api_key = api_key
self.secret_key = secret_key
def get_crypto_latest_quote(self, request: Any) -> Dict[str, MockAlpacaQuote]:
symbols = request.symbol_or_symbols
return {symbol: MockAlpacaQuote(symbol=symbol) for symbol in symbols}
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def mock_alpaca_module():
"""Create mock alpaca module."""
# Create mock module structure
mock_trading = MagicMock()
mock_trading.client.TradingClient = MockTradingClient
mock_trading.requests.MarketOrderRequest = MagicMock
mock_trading.requests.LimitOrderRequest = MagicMock
mock_trading.requests.StopOrderRequest = MagicMock
mock_trading.requests.StopLimitOrderRequest = MagicMock
mock_trading.requests.TrailingStopOrderRequest = MagicMock
mock_trading.requests.ReplaceOrderRequest = MagicMock
mock_trading.requests.GetOrdersRequest = MagicMock
mock_trading.enums.OrderSide = MockAlpacaOrderSide
mock_trading.enums.OrderType = MockAlpacaOrderType
mock_trading.enums.OrderStatus = MockAlpacaOrderStatus
mock_trading.enums.TimeInForce = MockAlpacaTimeInForce
mock_trading.enums.QueryOrderStatus = MockQueryOrderStatus
mock_trading.enums.PositionSide = MockAlpacaPositionSide
mock_data = MagicMock()
mock_data.historical.StockHistoricalDataClient = MockStockHistoricalDataClient
mock_data.historical.CryptoHistoricalDataClient = MockCryptoHistoricalDataClient
mock_data.requests.StockLatestQuoteRequest = MagicMock
mock_data.requests.CryptoLatestQuoteRequest = MagicMock
mock_data.live.StockDataStream = MagicMock
mock_data.live.CryptoDataStream = MagicMock
return mock_trading, mock_data
# =============================================================================
# AlpacaBroker Tests - Initialization
# =============================================================================
class TestAlpacaBrokerInit:
"""Tests for AlpacaBroker initialization."""
def test_init_default(self):
"""Test default initialization."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker()
assert broker.name == "Alpaca"
assert broker.is_paper_trading is True
assert AssetClass.EQUITY in broker.supported_asset_classes
assert AssetClass.ETF in broker.supported_asset_classes
assert AssetClass.CRYPTO in broker.supported_asset_classes
def test_init_with_credentials(self):
"""Test initialization with credentials."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker(
api_key="test-api-key",
api_secret="test-api-secret",
paper_trading=True,
)
assert broker.api_key == "test****-key" # Masked
def test_init_live_trading(self):
"""Test initialization for live trading."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker(
api_key="test-api-key",
api_secret="test-api-secret",
paper_trading=False,
)
assert broker.is_paper_trading is False
assert "paper" not in broker.base_url
def test_init_paper_trading(self):
"""Test initialization for paper trading."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker(
api_key="test-api-key",
api_secret="test-api-secret",
paper_trading=True,
)
assert broker.is_paper_trading is True
assert "paper" in broker.base_url
# =============================================================================
# AlpacaBroker Tests - Connection (with mocks)
# =============================================================================
class TestAlpacaBrokerConnection:
"""Tests for connection management."""
@pytest.mark.asyncio
async def test_connect_without_credentials(self):
"""Test connect fails without credentials."""
from tradingagents.execution.alpaca_broker import AlpacaBroker, ALPACA_AVAILABLE
if not ALPACA_AVAILABLE:
pytest.skip("alpaca-py not installed")
broker = AlpacaBroker(api_key="", api_secret="")
with pytest.raises(AuthenticationError, match="credentials not provided"):
await broker.connect()
@pytest.mark.asyncio
async def test_connect_without_sdk(self):
"""Test connect fails gracefully without SDK."""
from tradingagents.execution import alpaca_broker
# Save original value
original_available = alpaca_broker.ALPACA_AVAILABLE
try:
# Mock SDK not available
alpaca_broker.ALPACA_AVAILABLE = False
broker = alpaca_broker.AlpacaBroker(
api_key="test-key",
api_secret="test-secret",
)
with pytest.raises(BrokerError, match="alpaca-py is not installed"):
await broker.connect()
finally:
# Restore original value
alpaca_broker.ALPACA_AVAILABLE = original_available
@pytest.mark.asyncio
async def test_disconnect(self):
"""Test disconnect."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker()
broker._connected = True
await broker.disconnect()
assert broker.is_connected is False
# =============================================================================
# AlpacaBroker Tests - Order Mapping
# =============================================================================
class TestAlpacaBrokerOrderMapping:
"""Tests for order type/side/status mapping."""
def test_map_order_side_buy(self):
"""Test mapping buy order side."""
from tradingagents.execution import alpaca_broker
if not alpaca_broker.ALPACA_AVAILABLE:
pytest.skip("alpaca-py not installed")
broker = alpaca_broker.AlpacaBroker()
result = broker._map_order_side(OrderSide.BUY)
# Check it maps correctly (exact check depends on SDK)
assert result is not None
def test_map_time_in_force(self):
"""Test mapping time in force."""
from tradingagents.execution import alpaca_broker
if not alpaca_broker.ALPACA_AVAILABLE:
pytest.skip("alpaca-py not installed")
broker = alpaca_broker.AlpacaBroker()
for tif in TimeInForce:
result = broker._map_time_in_force(tif)
assert result is not None
# =============================================================================
# AlpacaBroker Tests - Order Requests (structure tests)
# =============================================================================
class TestAlpacaBrokerOrderRequests:
"""Tests for order request building."""
def test_market_order_request(self):
"""Test market order request."""
request = OrderRequest.market("AAPL", OrderSide.BUY, 100)
assert request.symbol == "AAPL"
assert request.side == OrderSide.BUY
assert request.quantity == Decimal("100")
assert request.order_type == OrderType.MARKET
def test_limit_order_request(self):
"""Test limit order request."""
request = OrderRequest.limit(
symbol="AAPL",
side=OrderSide.BUY,
quantity=100,
limit_price=Decimal("150.00"),
)
assert request.symbol == "AAPL"
assert request.order_type == OrderType.LIMIT
assert request.limit_price == Decimal("150.00")
def test_stop_order_request(self):
"""Test stop order request."""
request = OrderRequest.stop(
symbol="AAPL",
side=OrderSide.SELL,
quantity=100,
stop_price=Decimal("145.00"),
)
assert request.order_type == OrderType.STOP
assert request.stop_price == Decimal("145.00")
def test_stop_limit_order_request(self):
"""Test stop-limit order request."""
request = OrderRequest.stop_limit(
symbol="AAPL",
side=OrderSide.SELL,
quantity=100,
stop_price=Decimal("145.00"),
limit_price=Decimal("144.50"),
)
assert request.order_type == OrderType.STOP_LIMIT
assert request.stop_price == Decimal("145.00")
assert request.limit_price == Decimal("144.50")
def test_trailing_stop_order_request(self):
"""Test trailing stop order request."""
request = OrderRequest.trailing_stop(
symbol="AAPL",
side=OrderSide.SELL,
quantity=100,
trail_percent=Decimal("2.0"),
)
assert request.order_type == OrderType.TRAILING_STOP
assert request.trail_percent == Decimal("2.0")
# =============================================================================
# AlpacaBroker Tests - With Mocked SDK
# =============================================================================
class TestAlpacaBrokerWithMockedSDK:
"""Tests using mocked Alpaca SDK."""
async def _create_connected_broker(self):
"""Create a broker with mocked SDK and connect it."""
from tradingagents.execution import alpaca_broker
# Set up broker
broker = alpaca_broker.AlpacaBroker(
api_key="test-key",
api_secret="test-secret",
paper_trading=True,
)
# Mock the SDK connection
broker._trading_client = MockTradingClient(
api_key="test-key",
secret_key="test-secret",
paper=True,
)
broker._stock_data_client = MockStockHistoricalDataClient(
api_key="test-key",
secret_key="test-secret",
)
broker._crypto_data_client = MockCryptoHistoricalDataClient(
api_key="test-key",
secret_key="test-secret",
)
broker._connected = True
return broker
@pytest.mark.asyncio
async def test_get_account(self):
"""Test getting account info."""
broker = await self._create_connected_broker()
account = await broker.get_account()
assert isinstance(account, AccountInfo)
assert account.account_id == "TEST123456"
assert account.status == "ACTIVE"
assert account.cash == Decimal("100000.00")
@pytest.mark.asyncio
async def test_is_market_open(self):
"""Test checking market status."""
broker = await self._create_connected_broker()
is_open = await broker.is_market_open()
assert is_open is True
@pytest.mark.asyncio
async def test_submit_market_order(self):
"""Test submitting market order."""
from tradingagents.execution import alpaca_broker
if not alpaca_broker.ALPACA_AVAILABLE:
pytest.skip("alpaca-py not installed")
broker = await self._create_connected_broker()
request = OrderRequest.market("AAPL", OrderSide.BUY, 100)
order = await broker.submit_order(request)
assert order is not None
assert order.symbol == "AAPL"
@pytest.mark.asyncio
async def test_get_positions(self):
"""Test getting positions."""
broker = await self._create_connected_broker()
positions = await broker.get_positions()
assert len(positions) >= 1
assert positions[0].symbol == "AAPL"
@pytest.mark.asyncio
async def test_get_position(self):
"""Test getting specific position."""
broker = await self._create_connected_broker()
position = await broker.get_position("AAPL")
assert position is not None
assert position.symbol == "AAPL"
@pytest.mark.asyncio
async def test_get_quote_stock(self):
"""Test getting stock quote."""
from tradingagents.execution import alpaca_broker
if not alpaca_broker.ALPACA_AVAILABLE:
pytest.skip("alpaca-py not installed")
broker = await self._create_connected_broker()
quote = await broker.get_quote("AAPL")
assert quote.symbol == "AAPL"
assert quote.bid_price is not None
assert quote.ask_price is not None
@pytest.mark.asyncio
async def test_get_quote_crypto(self):
"""Test getting crypto quote."""
from tradingagents.execution import alpaca_broker
if not alpaca_broker.ALPACA_AVAILABLE:
pytest.skip("alpaca-py not installed")
broker = await self._create_connected_broker()
quote = await broker.get_quote("BTCUSD")
assert quote.symbol == "BTCUSD"
@pytest.mark.asyncio
async def test_get_asset(self):
"""Test getting asset info."""
broker = await self._create_connected_broker()
asset = await broker.get_asset("AAPL")
assert asset.symbol == "AAPL"
assert asset.name == "Apple Inc."
assert asset.tradable is True
# =============================================================================
# AlpacaBroker Tests - Error Handling
# =============================================================================
class TestAlpacaBrokerErrorHandling:
"""Tests for error handling."""
@pytest.mark.asyncio
async def test_requires_connection(self):
"""Test operations fail without connection."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker(api_key="test", api_secret="test")
with pytest.raises(ConnectionError, match="Not connected"):
await broker.get_account()
@pytest.mark.asyncio
async def test_invalid_limit_order_without_price(self):
"""Test limit order without price fails."""
from tradingagents.execution import alpaca_broker
if not alpaca_broker.ALPACA_AVAILABLE:
pytest.skip("alpaca-py not installed")
broker = alpaca_broker.AlpacaBroker(
api_key="test-key",
api_secret="test-secret",
)
broker._trading_client = MockTradingClient(
api_key="test", secret_key="test"
)
broker._connected = True
# Create limit order request without limit price
request = OrderRequest(
symbol="AAPL",
side=OrderSide.BUY,
quantity=Decimal("100"),
order_type=OrderType.LIMIT,
time_in_force=TimeInForce.DAY,
# Missing limit_price
)
with pytest.raises(InvalidOrderError, match="Limit price required"):
await broker.submit_order(request)
@pytest.mark.asyncio
async def test_invalid_stop_order_without_price(self):
"""Test stop order without price fails."""
from tradingagents.execution import alpaca_broker
if not alpaca_broker.ALPACA_AVAILABLE:
pytest.skip("alpaca-py not installed")
broker = alpaca_broker.AlpacaBroker(
api_key="test-key",
api_secret="test-secret",
)
broker._trading_client = MockTradingClient(
api_key="test", secret_key="test"
)
broker._connected = True
# Create stop order without stop price
request = OrderRequest(
symbol="AAPL",
side=OrderSide.SELL,
quantity=Decimal("100"),
order_type=OrderType.STOP,
time_in_force=TimeInForce.DAY,
# Missing stop_price
)
with pytest.raises(InvalidOrderError, match="Stop price required"):
await broker.submit_order(request)
# =============================================================================
# AlpacaBroker Tests - Asset Class Support
# =============================================================================
class TestAlpacaBrokerAssetClasses:
"""Tests for asset class support."""
def test_supports_equity(self):
"""Test broker supports equity."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker()
assert broker.supports_asset_class(AssetClass.EQUITY) is True
def test_supports_etf(self):
"""Test broker supports ETF."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker()
assert broker.supports_asset_class(AssetClass.ETF) is True
def test_supports_crypto(self):
"""Test broker supports crypto."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker()
assert broker.supports_asset_class(AssetClass.CRYPTO) is True
def test_does_not_support_futures(self):
"""Test broker does not support futures."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker()
assert broker.supports_asset_class(AssetClass.FUTURE) is False
def test_does_not_support_options(self):
"""Test broker does not support options."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker()
assert broker.supports_asset_class(AssetClass.OPTION) is False
# =============================================================================
# AlpacaBroker Tests - API Key Masking
# =============================================================================
class TestAlpacaBrokerSecurity:
"""Tests for security features."""
def test_api_key_masked(self):
"""Test API key is masked in property."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker(
api_key="PKEXAMPLEAPIKEY12345",
api_secret="supersecretkey",
)
# Key should be masked
assert "****" in broker.api_key
assert "EXAMPLE" not in broker.api_key
def test_short_api_key_fully_masked(self):
"""Test short API key is fully masked."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker(
api_key="short",
api_secret="test",
)
# Short keys should be fully masked
assert broker.api_key == "****"
# =============================================================================
# AlpacaBroker Tests - URL Configuration
# =============================================================================
class TestAlpacaBrokerURLs:
"""Tests for URL configuration."""
def test_paper_url(self):
"""Test paper trading URL."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker(paper_trading=True)
assert "paper" in broker.base_url
def test_live_url(self):
"""Test live trading URL."""
from tradingagents.execution.alpaca_broker import AlpacaBroker
broker = AlpacaBroker(paper_trading=False)
assert "paper" not in broker.base_url
assert "api.alpaca.markets" in broker.base_url
# =============================================================================
# Integration with BrokerRouter
# =============================================================================
class TestAlpacaBrokerRouterIntegration:
"""Tests for AlpacaBroker integration with BrokerRouter."""
def test_register_with_router(self):
"""Test registering AlpacaBroker with router."""
from tradingagents.execution import AlpacaBroker, BrokerRouter
router = BrokerRouter()
broker = AlpacaBroker()
router.register(broker)
assert "Alpaca" in router.registered_brokers
assert AssetClass.EQUITY in router.supported_asset_classes
assert AssetClass.CRYPTO in router.supported_asset_classes
def test_route_to_alpaca(self):
"""Test routing routes to Alpaca for supported assets."""
from tradingagents.execution import AlpacaBroker, BrokerRouter
router = BrokerRouter()
broker = AlpacaBroker()
router.register(broker)
routed_broker, decision = router.route("AAPL")
assert routed_broker.name == "Alpaca"
assert decision.asset_class == AssetClass.EQUITY
def test_route_crypto_to_alpaca(self):
"""Test crypto routing goes to Alpaca."""
from tradingagents.execution import AlpacaBroker, BrokerRouter
router = BrokerRouter()
broker = AlpacaBroker()
router.register(broker)
routed_broker, decision = router.route("BTCUSD")
assert routed_broker.name == "Alpaca"
assert decision.asset_class == AssetClass.CRYPTO

View File

@ -101,6 +101,11 @@ from .broker_router import (
DuplicateBrokerError,
)
from .alpaca_broker import (
AlpacaBroker,
ALPACA_AVAILABLE,
)
__all__ = [
# Enums
"AssetClass",
@ -136,4 +141,7 @@ __all__ = [
"NoBrokerError",
"BrokerNotFoundError",
"DuplicateBrokerError",
# Alpaca Broker
"AlpacaBroker",
"ALPACA_AVAILABLE",
]

View File

@ -0,0 +1,778 @@
"""Alpaca Broker implementation.
Issue #24: [EXEC-23] Alpaca broker - US stocks, ETFs, crypto
This module provides a concrete implementation of BrokerBase for the Alpaca
trading platform. Alpaca supports:
- US Stocks and ETFs (commission-free)
- Cryptocurrency trading
- Paper trading mode for testing
- Extended hours trading
Requirements:
pip install alpaca-py
Environment Variables:
ALPACA_API_KEY: Your Alpaca API key
ALPACA_API_SECRET: Your Alpaca API secret
ALPACA_PAPER: Set to 'true' for paper trading (default: true)
Example:
>>> from tradingagents.execution import AlpacaBroker, OrderRequest, OrderSide
>>>
>>> broker = AlpacaBroker(
... api_key="your-api-key",
... api_secret="your-api-secret",
... paper_trading=True,
... )
>>>
>>> await broker.connect()
>>> order = await broker.submit_order(
... OrderRequest.market("AAPL", OrderSide.BUY, 10)
... )
>>> print(f"Order placed: {order.broker_order_id}")
"""
from __future__ import annotations
import asyncio
import os
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Dict, List, Optional
from .broker_base import (
AccountInfo,
AssetClass,
AssetInfo,
AuthenticationError,
BrokerBase,
BrokerError,
ConnectionError,
InsufficientFundsError,
InvalidOrderError,
Order,
OrderError,
OrderRequest,
OrderSide,
OrderStatus,
OrderType,
Position,
PositionError,
PositionSide,
Quote,
RateLimitError,
TimeInForce,
)
# Try to import alpaca-py, provide stubs for testing without it
try:
from alpaca.trading.client import TradingClient
from alpaca.trading.requests import (
GetOrdersRequest,
LimitOrderRequest,
MarketOrderRequest,
ReplaceOrderRequest,
StopLimitOrderRequest,
StopOrderRequest,
TrailingStopOrderRequest,
)
from alpaca.trading.enums import (
OrderSide as AlpacaOrderSide,
OrderType as AlpacaOrderType,
OrderStatus as AlpacaOrderStatus,
TimeInForce as AlpacaTimeInForce,
QueryOrderStatus,
PositionSide as AlpacaPositionSide,
)
from alpaca.data.live import StockDataStream, CryptoDataStream
from alpaca.data.historical import StockHistoricalDataClient, CryptoHistoricalDataClient
from alpaca.data.requests import StockLatestQuoteRequest, CryptoLatestQuoteRequest
ALPACA_AVAILABLE = True
except ImportError:
ALPACA_AVAILABLE = False
TradingClient = None
StockDataStream = None
CryptoDataStream = None
StockHistoricalDataClient = None
CryptoHistoricalDataClient = None
StockLatestQuoteRequest = None
CryptoLatestQuoteRequest = None
# Enums stubs
AlpacaOrderSide = None
AlpacaOrderType = None
AlpacaOrderStatus = None
AlpacaTimeInForce = None
QueryOrderStatus = None
class AlpacaBroker(BrokerBase):
"""Alpaca broker implementation.
Supports US stocks, ETFs, and cryptocurrency trading through the
Alpaca API. Provides both paper and live trading modes.
Attributes:
api_key: Alpaca API key
api_secret: Alpaca API secret
base_url: Alpaca API base URL
data_url: Alpaca data API URL
Example:
>>> broker = AlpacaBroker(
... api_key="PKXXXXXXXXXX",
... api_secret="xxxxxxxxxxxxxxxxxx",
... paper_trading=True,
... )
>>> await broker.connect()
>>> account = await broker.get_account()
>>> print(f"Cash: ${account.cash}")
"""
# Alpaca API endpoints
LIVE_URL = "https://api.alpaca.markets"
PAPER_URL = "https://paper-api.alpaca.markets"
DATA_URL = "https://data.alpaca.markets"
def __init__(
self,
api_key: Optional[str] = None,
api_secret: Optional[str] = None,
paper_trading: bool = True,
**kwargs: Any,
) -> None:
"""Initialize Alpaca broker.
Args:
api_key: Alpaca API key. If not provided, reads from
ALPACA_API_KEY environment variable.
api_secret: Alpaca API secret. If not provided, reads from
ALPACA_API_SECRET environment variable.
paper_trading: If True, use paper trading account.
Defaults to True for safety.
**kwargs: Additional arguments passed to BrokerBase.
"""
super().__init__(
name="Alpaca",
supported_asset_classes=[
AssetClass.EQUITY,
AssetClass.ETF,
AssetClass.CRYPTO,
],
paper_trading=paper_trading,
**kwargs,
)
self._api_key = api_key or os.environ.get("ALPACA_API_KEY", "")
self._api_secret = api_secret or os.environ.get("ALPACA_API_SECRET", "")
self._base_url = self.PAPER_URL if paper_trading else self.LIVE_URL
self._data_url = self.DATA_URL
self._trading_client: Optional[TradingClient] = None
self._stock_data_client: Optional[Any] = None
self._crypto_data_client: Optional[Any] = None
self._stock_stream: Optional[Any] = None
self._crypto_stream: Optional[Any] = None
@property
def api_key(self) -> str:
"""Get API key (masked)."""
if len(self._api_key) > 8:
return self._api_key[:4] + "****" + self._api_key[-4:]
return "****"
@property
def base_url(self) -> str:
"""Get API base URL."""
return self._base_url
def _require_connection(self) -> None:
"""Require broker to be connected.
Raises:
ConnectionError: If not connected.
"""
if not self.is_connected:
raise ConnectionError("Not connected to Alpaca. Call connect() first.")
def _check_alpaca_available(self) -> None:
"""Check if alpaca-py is installed."""
if not ALPACA_AVAILABLE:
raise BrokerError(
"alpaca-py is not installed. "
"Install it with: pip install alpaca-py"
)
async def connect(self) -> bool:
"""Connect to Alpaca API.
Returns:
True if connection successful.
Raises:
AuthenticationError: If API credentials are invalid.
ConnectionError: If connection fails.
"""
self._check_alpaca_available()
if not self._api_key or not self._api_secret:
raise AuthenticationError(
"Alpaca API credentials not provided. "
"Set ALPACA_API_KEY and ALPACA_API_SECRET environment variables "
"or pass api_key and api_secret to constructor."
)
try:
# Initialize trading client
self._trading_client = TradingClient(
api_key=self._api_key,
secret_key=self._api_secret,
paper=self._paper_trading,
)
# Verify connection by getting account
account = self._trading_client.get_account()
if account.status != "ACTIVE":
raise AuthenticationError(
f"Alpaca account is not active: {account.status}"
)
# Initialize data clients
self._stock_data_client = StockHistoricalDataClient(
api_key=self._api_key,
secret_key=self._api_secret,
)
self._crypto_data_client = CryptoHistoricalDataClient(
api_key=self._api_key,
secret_key=self._api_secret,
)
self._connected = True
return True
except Exception as e:
error_msg = str(e).lower()
if "unauthorized" in error_msg or "forbidden" in error_msg:
raise AuthenticationError(f"Alpaca authentication failed: {e}")
elif "connect" in error_msg or "timeout" in error_msg:
raise ConnectionError(f"Failed to connect to Alpaca: {e}")
else:
raise BrokerError(f"Alpaca connection error: {e}")
async def disconnect(self) -> None:
"""Disconnect from Alpaca API."""
# Close streaming connections if active
if self._stock_stream:
await self._stock_stream.close()
self._stock_stream = None
if self._crypto_stream:
await self._crypto_stream.close()
self._crypto_stream = None
self._trading_client = None
self._stock_data_client = None
self._crypto_data_client = None
self._connected = False
async def is_market_open(self) -> bool:
"""Check if the market is currently open.
Returns:
True if market is open for trading.
"""
self._require_connection()
try:
clock = self._trading_client.get_clock()
return clock.is_open
except Exception as e:
raise BrokerError(f"Failed to get market status: {e}")
async def get_account(self) -> AccountInfo:
"""Get account information.
Returns:
AccountInfo with current account state.
Raises:
BrokerError: If account retrieval fails.
"""
self._require_connection()
try:
account = self._trading_client.get_account()
return AccountInfo(
account_id=account.account_number,
account_type=account.account_type or "margin",
status=account.status,
cash=Decimal(str(account.cash)),
portfolio_value=Decimal(str(account.portfolio_value or 0)),
buying_power=Decimal(str(account.buying_power)),
equity=Decimal(str(account.equity)),
margin_used=Decimal(str(account.initial_margin or 0)),
margin_available=Decimal(str(account.regt_buying_power or 0)),
day_trades_remaining=account.daytrade_count or 0,
is_pattern_day_trader=account.pattern_day_trader or False,
)
except Exception as e:
raise BrokerError(f"Failed to get account: {e}")
def _map_order_side(self, side: OrderSide) -> "AlpacaOrderSide":
"""Map internal order side to Alpaca order side."""
return AlpacaOrderSide.BUY if side == OrderSide.BUY else AlpacaOrderSide.SELL
def _map_time_in_force(self, tif: TimeInForce) -> "AlpacaTimeInForce":
"""Map internal time in force to Alpaca time in force."""
mapping = {
TimeInForce.DAY: AlpacaTimeInForce.DAY,
TimeInForce.GTC: AlpacaTimeInForce.GTC,
TimeInForce.IOC: AlpacaTimeInForce.IOC,
TimeInForce.FOK: AlpacaTimeInForce.FOK,
TimeInForce.OPG: AlpacaTimeInForce.OPG,
TimeInForce.CLS: AlpacaTimeInForce.CLS,
}
return mapping.get(tif, AlpacaTimeInForce.DAY)
def _map_alpaca_order_status(self, status: "AlpacaOrderStatus") -> OrderStatus:
"""Map Alpaca order status to internal order status."""
mapping = {
AlpacaOrderStatus.NEW: OrderStatus.NEW,
AlpacaOrderStatus.ACCEPTED: OrderStatus.NEW,
AlpacaOrderStatus.PENDING_NEW: OrderStatus.PENDING,
AlpacaOrderStatus.ACCEPTED_FOR_BIDDING: OrderStatus.PENDING,
AlpacaOrderStatus.PARTIALLY_FILLED: OrderStatus.PARTIALLY_FILLED,
AlpacaOrderStatus.FILLED: OrderStatus.FILLED,
AlpacaOrderStatus.DONE_FOR_DAY: OrderStatus.FILLED,
AlpacaOrderStatus.CANCELED: OrderStatus.CANCELLED,
AlpacaOrderStatus.EXPIRED: OrderStatus.EXPIRED,
AlpacaOrderStatus.REPLACED: OrderStatus.REPLACED,
AlpacaOrderStatus.PENDING_CANCEL: OrderStatus.PENDING,
AlpacaOrderStatus.PENDING_REPLACE: OrderStatus.PENDING,
AlpacaOrderStatus.STOPPED: OrderStatus.CANCELLED,
AlpacaOrderStatus.REJECTED: OrderStatus.REJECTED,
AlpacaOrderStatus.SUSPENDED: OrderStatus.CANCELLED,
AlpacaOrderStatus.CALCULATED: OrderStatus.NEW,
}
return mapping.get(status, OrderStatus.NEW)
def _map_alpaca_order_type(self, order_type: "AlpacaOrderType") -> OrderType:
"""Map Alpaca order type to internal order type."""
mapping = {
AlpacaOrderType.MARKET: OrderType.MARKET,
AlpacaOrderType.LIMIT: OrderType.LIMIT,
AlpacaOrderType.STOP: OrderType.STOP,
AlpacaOrderType.STOP_LIMIT: OrderType.STOP_LIMIT,
AlpacaOrderType.TRAILING_STOP: OrderType.TRAILING_STOP,
}
return mapping.get(order_type, OrderType.MARKET)
def _convert_alpaca_order(self, alpaca_order: Any) -> Order:
"""Convert Alpaca order to internal Order type."""
return Order(
broker_order_id=str(alpaca_order.id),
client_order_id=alpaca_order.client_order_id,
symbol=alpaca_order.symbol,
side=OrderSide.BUY if alpaca_order.side == AlpacaOrderSide.BUY else OrderSide.SELL,
quantity=Decimal(str(alpaca_order.qty)),
order_type=self._map_alpaca_order_type(alpaca_order.order_type),
status=self._map_alpaca_order_status(alpaca_order.status),
limit_price=Decimal(str(alpaca_order.limit_price)) if alpaca_order.limit_price else None,
stop_price=Decimal(str(alpaca_order.stop_price)) if alpaca_order.stop_price else None,
time_in_force=TimeInForce(alpaca_order.time_in_force.value.lower()),
filled_quantity=Decimal(str(alpaca_order.filled_qty or 0)),
avg_fill_price=Decimal(str(alpaca_order.filled_avg_price)) if alpaca_order.filled_avg_price else None,
created_at=alpaca_order.created_at,
updated_at=alpaca_order.updated_at,
submitted_at=alpaca_order.submitted_at,
filled_at=alpaca_order.filled_at,
expired_at=alpaca_order.expired_at,
cancelled_at=alpaca_order.canceled_at,
)
async def submit_order(self, request: OrderRequest) -> Order:
"""Submit an order to Alpaca.
Args:
request: Order request details.
Returns:
Order with broker order ID.
Raises:
InvalidOrderError: If order parameters are invalid.
InsufficientFundsError: If insufficient buying power.
OrderError: If order submission fails.
"""
self._require_connection()
try:
# Build order request based on order type
if request.order_type == OrderType.MARKET:
alpaca_request = MarketOrderRequest(
symbol=request.symbol,
qty=float(request.quantity),
side=self._map_order_side(request.side),
time_in_force=self._map_time_in_force(request.time_in_force),
client_order_id=request.client_order_id,
extended_hours=request.extended_hours,
)
elif request.order_type == OrderType.LIMIT:
if request.limit_price is None:
raise InvalidOrderError("Limit price required for limit orders")
alpaca_request = LimitOrderRequest(
symbol=request.symbol,
qty=float(request.quantity),
side=self._map_order_side(request.side),
time_in_force=self._map_time_in_force(request.time_in_force),
limit_price=float(request.limit_price),
client_order_id=request.client_order_id,
extended_hours=request.extended_hours,
)
elif request.order_type == OrderType.STOP:
if request.stop_price is None:
raise InvalidOrderError("Stop price required for stop orders")
alpaca_request = StopOrderRequest(
symbol=request.symbol,
qty=float(request.quantity),
side=self._map_order_side(request.side),
time_in_force=self._map_time_in_force(request.time_in_force),
stop_price=float(request.stop_price),
client_order_id=request.client_order_id,
)
elif request.order_type == OrderType.STOP_LIMIT:
if request.stop_price is None:
raise InvalidOrderError("Stop price required for stop-limit orders")
if request.limit_price is None:
raise InvalidOrderError("Limit price required for stop-limit orders")
alpaca_request = StopLimitOrderRequest(
symbol=request.symbol,
qty=float(request.quantity),
side=self._map_order_side(request.side),
time_in_force=self._map_time_in_force(request.time_in_force),
stop_price=float(request.stop_price),
limit_price=float(request.limit_price),
client_order_id=request.client_order_id,
)
elif request.order_type == OrderType.TRAILING_STOP:
if request.trail_percent is None and request.trail_price is None:
raise InvalidOrderError(
"Trail percent or trail price required for trailing stop orders"
)
alpaca_request = TrailingStopOrderRequest(
symbol=request.symbol,
qty=float(request.quantity),
side=self._map_order_side(request.side),
time_in_force=self._map_time_in_force(request.time_in_force),
trail_percent=float(request.trail_percent) if request.trail_percent else None,
trail_price=float(request.trail_price) if request.trail_price else None,
client_order_id=request.client_order_id,
)
else:
raise InvalidOrderError(f"Unsupported order type: {request.order_type}")
# Submit order
alpaca_order = self._trading_client.submit_order(alpaca_request)
return self._convert_alpaca_order(alpaca_order)
except InvalidOrderError:
raise
except Exception as e:
error_msg = str(e).lower()
if "insufficient" in error_msg or "buying power" in error_msg:
raise InsufficientFundsError(f"Insufficient funds for order: {e}")
elif "invalid" in error_msg or "validation" in error_msg:
raise InvalidOrderError(f"Invalid order: {e}")
elif "rate" in error_msg and "limit" in error_msg:
raise RateLimitError(f"Alpaca rate limit exceeded: {e}")
else:
raise OrderError(f"Failed to submit order: {e}")
async def cancel_order(self, order_id: str) -> Order:
"""Cancel an order.
Args:
order_id: Broker order ID to cancel.
Returns:
Updated order with cancelled status.
Raises:
OrderError: If cancellation fails.
"""
self._require_connection()
try:
self._trading_client.cancel_order_by_id(order_id)
# Get updated order
alpaca_order = self._trading_client.get_order_by_id(order_id)
return self._convert_alpaca_order(alpaca_order)
except Exception as e:
raise OrderError(f"Failed to cancel order {order_id}: {e}")
async def replace_order(
self,
order_id: str,
quantity: Optional[Decimal] = None,
limit_price: Optional[Decimal] = None,
stop_price: Optional[Decimal] = None,
time_in_force: Optional[TimeInForce] = None,
) -> Order:
"""Replace/modify an existing order.
Args:
order_id: Broker order ID to replace.
quantity: New quantity (optional).
limit_price: New limit price (optional).
stop_price: New stop price (optional).
time_in_force: New time in force (optional).
Returns:
New order that replaced the original.
Raises:
OrderError: If replacement fails.
"""
self._require_connection()
try:
replace_request = ReplaceOrderRequest(
qty=float(quantity) if quantity else None,
limit_price=float(limit_price) if limit_price else None,
stop_price=float(stop_price) if stop_price else None,
time_in_force=self._map_time_in_force(time_in_force) if time_in_force else None,
)
alpaca_order = self._trading_client.replace_order_by_id(
order_id=order_id,
order_data=replace_request,
)
return self._convert_alpaca_order(alpaca_order)
except Exception as e:
raise OrderError(f"Failed to replace order {order_id}: {e}")
async def get_order(self, order_id: str) -> Order:
"""Get order by ID.
Args:
order_id: Broker order ID.
Returns:
Order details.
Raises:
OrderError: If order not found or retrieval fails.
"""
self._require_connection()
try:
alpaca_order = self._trading_client.get_order_by_id(order_id)
return self._convert_alpaca_order(alpaca_order)
except Exception as e:
raise OrderError(f"Failed to get order {order_id}: {e}")
async def get_orders(
self,
status: Optional[OrderStatus] = None,
limit: int = 100,
symbols: Optional[List[str]] = None,
) -> List[Order]:
"""Get orders with optional filters.
Args:
status: Filter by order status.
limit: Maximum number of orders to return.
symbols: Filter by symbols.
Returns:
List of orders.
"""
self._require_connection()
try:
# Map status to query status
if status == OrderStatus.NEW:
query_status = QueryOrderStatus.OPEN
elif status in (OrderStatus.FILLED, OrderStatus.PARTIALLY_FILLED):
query_status = QueryOrderStatus.CLOSED
else:
query_status = QueryOrderStatus.ALL
request = GetOrdersRequest(
status=query_status,
limit=limit,
symbols=symbols,
)
alpaca_orders = self._trading_client.get_orders(request)
orders = [self._convert_alpaca_order(o) for o in alpaca_orders]
# Filter by exact status if needed
if status:
orders = [o for o in orders if o.status == status]
return orders
except Exception as e:
raise BrokerError(f"Failed to get orders: {e}")
async def get_positions(self) -> List[Position]:
"""Get all positions.
Returns:
List of current positions.
"""
self._require_connection()
try:
alpaca_positions = self._trading_client.get_all_positions()
positions = []
for pos in alpaca_positions:
position = Position(
symbol=pos.symbol,
quantity=Decimal(str(pos.qty)),
side=PositionSide.LONG if Decimal(str(pos.qty)) > 0 else PositionSide.SHORT,
avg_entry_price=Decimal(str(pos.avg_entry_price)),
current_price=Decimal(str(pos.current_price)),
market_value=Decimal(str(pos.market_value)),
cost_basis=Decimal(str(pos.cost_basis)),
unrealized_pnl=Decimal(str(pos.unrealized_pl)),
unrealized_pnl_percent=Decimal(str(pos.unrealized_plpc)) * 100,
asset_class=AssetClass.CRYPTO if pos.asset_class == "crypto" else AssetClass.EQUITY,
)
positions.append(position)
return positions
except Exception as e:
raise PositionError(f"Failed to get positions: {e}")
async def get_position(self, symbol: str) -> Optional[Position]:
"""Get position for a specific symbol.
Args:
symbol: Symbol to get position for.
Returns:
Position if exists, None otherwise.
"""
self._require_connection()
try:
pos = self._trading_client.get_open_position(symbol)
return Position(
symbol=pos.symbol,
quantity=Decimal(str(pos.qty)),
side=PositionSide.LONG if Decimal(str(pos.qty)) > 0 else PositionSide.SHORT,
avg_entry_price=Decimal(str(pos.avg_entry_price)),
current_price=Decimal(str(pos.current_price)),
market_value=Decimal(str(pos.market_value)),
cost_basis=Decimal(str(pos.cost_basis)),
unrealized_pnl=Decimal(str(pos.unrealized_pl)),
unrealized_pnl_percent=Decimal(str(pos.unrealized_plpc)) * 100,
asset_class=AssetClass.CRYPTO if pos.asset_class == "crypto" else AssetClass.EQUITY,
)
except Exception as e:
if "not found" in str(e).lower():
return None
raise PositionError(f"Failed to get position for {symbol}: {e}")
async def get_quote(self, symbol: str) -> Quote:
"""Get current quote for a symbol.
Args:
symbol: Symbol to get quote for.
Returns:
Current quote data.
"""
self._require_connection()
try:
# Determine if crypto or stock
is_crypto = "/" in symbol or symbol.endswith("USD") or symbol.endswith("USDT")
if is_crypto:
# Use crypto data client
request = CryptoLatestQuoteRequest(symbol_or_symbols=[symbol])
quotes = self._crypto_data_client.get_crypto_latest_quote(request)
quote_data = quotes[symbol]
else:
# Use stock data client
request = StockLatestQuoteRequest(symbol_or_symbols=[symbol])
quotes = self._stock_data_client.get_stock_latest_quote(request)
quote_data = quotes[symbol]
return Quote(
symbol=symbol,
bid_price=Decimal(str(quote_data.bid_price)) if quote_data.bid_price else None,
ask_price=Decimal(str(quote_data.ask_price)) if quote_data.ask_price else None,
bid_size=quote_data.bid_size,
ask_size=quote_data.ask_size,
timestamp=quote_data.timestamp,
)
except Exception as e:
raise BrokerError(f"Failed to get quote for {symbol}: {e}")
async def get_asset(self, symbol: str) -> AssetInfo:
"""Get asset information.
Args:
symbol: Symbol to get info for.
Returns:
Asset information.
"""
self._require_connection()
try:
asset = self._trading_client.get_asset(symbol)
# Determine asset class
if asset.asset_class == "crypto":
asset_class = AssetClass.CRYPTO
elif asset.easy_to_borrow and asset.marginable:
asset_class = AssetClass.ETF if asset.exchange == "ARCA" else AssetClass.EQUITY
else:
asset_class = AssetClass.EQUITY
return AssetInfo(
symbol=asset.symbol,
name=asset.name or asset.symbol,
asset_class=asset_class,
exchange=asset.exchange,
tradable=asset.tradable,
shortable=asset.shortable,
marginable=asset.marginable,
fractionable=asset.fractionable,
)
except Exception as e:
raise BrokerError(f"Failed to get asset info for {symbol}: {e}")
# Export
__all__ = ["AlpacaBroker", "ALPACA_AVAILABLE"]