diff --git a/tests/unit/execution/test_alpaca_broker.py b/tests/unit/execution/test_alpaca_broker.py new file mode 100644 index 00000000..8f450c91 --- /dev/null +++ b/tests/unit/execution/test_alpaca_broker.py @@ -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 diff --git a/tradingagents/execution/__init__.py b/tradingagents/execution/__init__.py index 692aa75b..e880625a 100644 --- a/tradingagents/execution/__init__.py +++ b/tradingagents/execution/__init__.py @@ -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", ] diff --git a/tradingagents/execution/alpaca_broker.py b/tradingagents/execution/alpaca_broker.py new file mode 100644 index 00000000..17f44dd1 --- /dev/null +++ b/tradingagents/execution/alpaca_broker.py @@ -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"]