"""Broker API adapter interface for paper trading and order events. The BrokerDataAdapter is the abstract interface for all broker integrations. AlpacaBrokerAdapter is the first concrete implementation, targeting the Alpaca Markets REST API for paper and live trading. Requirements: 2.4, 2.5, 8.1, 8.3, 8.5 """ import hashlib import logging import time import uuid from abc import ABC, abstractmethod from datetime import datetime, timezone from enum import Enum from typing import Any import httpx from .base import AdapterResult, BaseAdapter logger = logging.getLogger("broker_adapter") # --- Broker-specific enums --- class OrderSide(str, Enum): BUY = "buy" SELL = "sell" class OrderType(str, Enum): MARKET = "market" LIMIT = "limit" STOP = "stop" STOP_LIMIT = "stop_limit" class OrderStatus(str, Enum): PENDING = "pending" SUBMITTED = "submitted" ACCEPTED = "accepted" PARTIALLY_FILLED = "partially_filled" FILLED = "filled" CANCELLED = "cancelled" REJECTED = "rejected" EXPIRED = "expired" class TradingMode(str, Enum): PAPER = "paper" LIVE = "live" class OrderEventType(str, Enum): SUBMITTED = "submitted" ACCEPTED = "accepted" REJECTED = "rejected" FILL = "fill" PARTIAL_FILL = "partial_fill" CANCELLED = "cancelled" EXPIRED = "expired" # --- Data structures --- class OrderRequest: """Represents an order to be submitted to a broker.""" def __init__( self, ticker: str, side: OrderSide, quantity: float, order_type: OrderType = OrderType.MARKET, limit_price: float | None = None, stop_price: float | None = None, time_in_force: str = "day", idempotency_key: str | None = None, ) -> None: self.ticker = ticker self.side = side self.quantity = quantity self.order_type = order_type self.limit_price = limit_price self.stop_price = stop_price self.time_in_force = time_in_force self.idempotency_key = idempotency_key or str(uuid.uuid4()) def to_dict(self) -> dict[str, Any]: """Serialize to a dict for audit/persistence.""" d: dict[str, Any] = { "ticker": self.ticker, "side": self.side.value, "quantity": self.quantity, "order_type": self.order_type.value, "time_in_force": self.time_in_force, "idempotency_key": self.idempotency_key, } if self.limit_price is not None: d["limit_price"] = self.limit_price if self.stop_price is not None: d["stop_price"] = self.stop_price return d class OrderResponse: """Represents a broker's response to an order submission.""" def __init__( self, broker_order_id: str, status: OrderStatus, ticker: str, side: OrderSide, quantity: float, filled_quantity: float = 0.0, filled_avg_price: float | None = None, submitted_at: datetime | None = None, raw_response: dict[str, Any] | None = None, error: str | None = None, ) -> None: self.broker_order_id = broker_order_id self.status = status self.ticker = ticker self.side = side self.quantity = quantity self.filled_quantity = filled_quantity self.filled_avg_price = filled_avg_price self.submitted_at = submitted_at or datetime.now(timezone.utc) self.raw_response = raw_response or {} self.error = error @property def ok(self) -> bool: return self.error is None and self.status not in ( OrderStatus.REJECTED, OrderStatus.CANCELLED, OrderStatus.EXPIRED, ) def to_dict(self) -> dict[str, Any]: return { "broker_order_id": self.broker_order_id, "status": self.status.value, "ticker": self.ticker, "side": self.side.value, "quantity": self.quantity, "filled_quantity": self.filled_quantity, "filled_avg_price": self.filled_avg_price, "submitted_at": self.submitted_at.isoformat(), "error": self.error, } class PositionInfo: """Represents a current position from the broker.""" def __init__( self, ticker: str, quantity: float, avg_entry_price: float, current_price: float, unrealized_pnl: float, market_value: float, side: str = "long", ) -> None: self.ticker = ticker self.quantity = quantity self.avg_entry_price = avg_entry_price self.current_price = current_price self.unrealized_pnl = unrealized_pnl self.market_value = market_value self.side = side def to_dict(self) -> dict[str, Any]: return { "ticker": self.ticker, "quantity": self.quantity, "avg_entry_price": self.avg_entry_price, "current_price": self.current_price, "unrealized_pnl": self.unrealized_pnl, "market_value": self.market_value, "side": self.side, } class AccountInfo: """Represents broker account summary.""" def __init__( self, account_id: str, buying_power: float, cash: float, portfolio_value: float, currency: str = "USD", mode: TradingMode = TradingMode.PAPER, ) -> None: self.account_id = account_id self.buying_power = buying_power self.cash = cash self.portfolio_value = portfolio_value self.currency = currency self.mode = mode def to_dict(self) -> dict[str, Any]: return { "account_id": self.account_id, "buying_power": self.buying_power, "cash": self.cash, "portfolio_value": self.portfolio_value, "currency": self.currency, "mode": self.mode.value, } # --- Abstract interface --- class BrokerDataAdapter(BaseAdapter, ABC): """Abstract interface for broker API integrations. Extends BaseAdapter with broker-specific operations: - submit_order: place an order with idempotency key - cancel_order: cancel an existing order - get_order_status: check order state - get_positions: list current positions - get_account: retrieve account summary All concrete adapters must enforce: - Idempotent order submission via idempotency_key (Req 8.5) - Paper/live mode separation (Req 8.1) - Fail-closed on broker unavailability (Req 8.5) """ def __init__(self, mode: TradingMode = TradingMode.PAPER) -> None: self._mode = mode @property def mode(self) -> TradingMode: return self._mode def source_type(self) -> str: return "broker" @abstractmethod async def submit_order(self, order: OrderRequest) -> OrderResponse: """Submit an order to the broker. Must use order.idempotency_key to prevent duplicate submissions. Must fail closed if the broker is unavailable or returns ambiguous state. """ ... @abstractmethod async def cancel_order(self, broker_order_id: str) -> OrderResponse: """Cancel an existing order by broker order ID.""" ... @abstractmethod async def get_order_status(self, broker_order_id: str) -> OrderResponse: """Get the current status of an order.""" ... @abstractmethod async def get_positions(self) -> list[PositionInfo]: """Get all current positions.""" ... @abstractmethod async def get_account(self) -> AccountInfo: """Get account summary (balance, buying power, etc.).""" ... async def cancel_all_orders(self) -> int: """Cancel all open orders. Returns the number of orders cancelled.""" return 0 async def close_all_positions(self) -> int: """Liquidate all open positions. Returns the number of positions closed.""" return 0 # --- Concrete Alpaca implementation --- class AlpacaBrokerAdapter(BrokerDataAdapter): """Concrete broker adapter for the Alpaca Markets REST API. Supports: - Paper trading via paper-api.alpaca.markets - Live trading via api.alpaca.markets - Order submission, cancellation, and status - Position and account queries Config options for fetch(): endpoint: One of "positions", "orders", "account" (default "positions") """ PAPER_BASE_URL: str = "https://paper-api.alpaca.markets" LIVE_BASE_URL: str = "https://api.alpaca.markets" def __init__( self, api_key: str, api_secret: str, mode: TradingMode = TradingMode.PAPER, base_url: str | None = None, ) -> None: super().__init__(mode=mode) self.api_key = api_key self.api_secret = api_secret if base_url: self.base_url = base_url.rstrip("/") # Strip trailing /v2 or /v1 — the adapter adds API version prefixes itself for suffix in ("/v2", "/v1"): if self.base_url.endswith(suffix): self.base_url = self.base_url[: -len(suffix)] break elif mode == TradingMode.LIVE: self.base_url = self.LIVE_BASE_URL else: self.base_url = self.PAPER_BASE_URL def _headers(self) -> dict[str, str]: return { "APCA-API-KEY-ID": self.api_key, "APCA-API-SECRET-KEY": self.api_secret, "Content-Type": "application/json", } async def fetch(self, ticker: str, config: dict[str, Any]) -> AdapterResult: """Fetch positions or recent orders for a ticker from Alpaca. This satisfies the BaseAdapter contract for the ingestion pipeline. The broker adapter uses fetch() to pull position/order snapshots that get persisted as raw artifacts. """ endpoint = config.get("endpoint", "positions") url = self._build_fetch_url(ticker, endpoint) async with httpx.AsyncClient(timeout=30) as client: t0 = time.monotonic() try: resp = await client.get(url, headers=self._headers()) elapsed_ms = (time.monotonic() - t0) * 1000 resp.raise_for_status() raw = resp.content data = resp.json() content_hash = hashlib.sha256(raw).hexdigest() items = [data] if isinstance(data, dict) else data if isinstance(data, list) else [] return AdapterResult( source_type="broker", ticker=ticker, items=items, raw_payload=raw, content_hash=content_hash, fetched_at=datetime.now(timezone.utc), http_status=resp.status_code, response_time_ms=round(elapsed_ms, 1), metadata={ "provider": "alpaca", "mode": self._mode.value, "endpoint": endpoint, }, ) except httpx.HTTPStatusError as e: elapsed_ms = (time.monotonic() - t0) * 1000 # 404 on positions endpoint means no position held — not an error if e.response is not None and e.response.status_code == 404 and endpoint == "positions": return AdapterResult( source_type="broker", ticker=ticker, items=[], raw_payload=b"[]", content_hash=hashlib.sha256(b"[]").hexdigest(), fetched_at=datetime.now(timezone.utc), http_status=404, response_time_ms=round(elapsed_ms, 1), metadata={"provider": "alpaca", "mode": self._mode.value, "endpoint": endpoint}, ) logger.error("Alpaca HTTP error for %s: %s", ticker, e) return self._error_result( ticker, str(e), elapsed_ms, http_status=e.response.status_code if e.response else None, raw=e.response.content if e.response else b"", ) except Exception as e: elapsed_ms = (time.monotonic() - t0) * 1000 logger.error("Alpaca fetch failed for %s: %s", ticker, e) return self._error_result(ticker, str(e), elapsed_ms) def _build_fetch_url(self, ticker: str, endpoint: str) -> str: """Build the URL for a fetch operation.""" if endpoint == "orders": return f"{self.base_url}/v2/orders?symbols={ticker}&status=all&limit=50" if endpoint == "account": return f"{self.base_url}/v2/account" # Default: positions for ticker return f"{self.base_url}/v2/positions/{ticker}" async def submit_order(self, order: OrderRequest) -> OrderResponse: """Submit an order to Alpaca with idempotency key. Fails closed: any network error or ambiguous response returns a rejected OrderResponse rather than risking duplicate orders. """ if self._mode == TradingMode.LIVE: logger.warning("LIVE order submission: %s %s %s", order.side.value, order.quantity, order.ticker) payload: dict[str, Any] = { "symbol": order.ticker, "qty": str(order.quantity), "side": order.side.value, "type": order.order_type.value, "time_in_force": order.time_in_force, } if order.limit_price is not None and order.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT): payload["limit_price"] = str(order.limit_price) if order.stop_price is not None and order.order_type in (OrderType.STOP, OrderType.STOP_LIMIT): payload["stop_price"] = str(order.stop_price) headers = {**self._headers(), "Idempotency-Key": order.idempotency_key} async with httpx.AsyncClient(timeout=30) as client: try: resp = await client.post( f"{self.base_url}/v2/orders", headers=headers, json=payload, ) resp.raise_for_status() data = resp.json() return self._parse_order_response(data) except httpx.HTTPStatusError as e: error_body = e.response.text if e.response else "unknown" logger.error("Order rejected by Alpaca: %s", error_body) return OrderResponse( broker_order_id="", status=OrderStatus.REJECTED, ticker=order.ticker, side=order.side, quantity=order.quantity, error=f"HTTP {e.response.status_code}: {error_body}" if e.response else str(e), raw_response={"error": error_body}, ) except Exception as e: # Fail closed: treat any unexpected error as rejection logger.error("Order submission failed (fail-closed): %s", e) return OrderResponse( broker_order_id="", status=OrderStatus.REJECTED, ticker=order.ticker, side=order.side, quantity=order.quantity, error=f"fail-closed: {e}", ) async def cancel_order(self, broker_order_id: str) -> OrderResponse: """Cancel an order on Alpaca.""" async with httpx.AsyncClient(timeout=30) as client: try: resp = await client.delete( f"{self.base_url}/v2/orders/{broker_order_id}", headers=self._headers(), ) if resp.status_code == 204: return OrderResponse( broker_order_id=broker_order_id, status=OrderStatus.CANCELLED, ticker="", side=OrderSide.BUY, quantity=0, ) resp.raise_for_status() data = resp.json() return self._parse_order_response(data) except Exception as e: logger.error("Cancel failed for %s: %s", broker_order_id, e) return OrderResponse( broker_order_id=broker_order_id, status=OrderStatus.REJECTED, ticker="", side=OrderSide.BUY, quantity=0, error=str(e), ) async def get_order_status(self, broker_order_id: str) -> OrderResponse: """Get order status from Alpaca.""" async with httpx.AsyncClient(timeout=30) as client: try: resp = await client.get( f"{self.base_url}/v2/orders/{broker_order_id}", headers=self._headers(), ) resp.raise_for_status() data = resp.json() return self._parse_order_response(data) except Exception as e: logger.error("Get order status failed for %s: %s", broker_order_id, e) return OrderResponse( broker_order_id=broker_order_id, status=OrderStatus.REJECTED, ticker="", side=OrderSide.BUY, quantity=0, error=str(e), ) async def get_positions(self) -> list[PositionInfo]: """Get all current positions from Alpaca.""" async with httpx.AsyncClient(timeout=30) as client: try: resp = await client.get( f"{self.base_url}/v2/positions", headers=self._headers(), ) resp.raise_for_status() data = resp.json() if not isinstance(data, list): return [] return [self._parse_position(p) for p in data if isinstance(p, dict)] except Exception as e: logger.error("Get positions failed: %s", e) return [] async def get_account(self) -> AccountInfo: """Get account summary from Alpaca.""" async with httpx.AsyncClient(timeout=30) as client: try: resp = await client.get( f"{self.base_url}/v2/account", headers=self._headers(), ) resp.raise_for_status() data = resp.json() return AccountInfo( account_id=str(data.get("id", "")), buying_power=float(data.get("buying_power", 0)), cash=float(data.get("cash", 0)), portfolio_value=float(data.get("portfolio_value", 0)), currency=str(data.get("currency", "USD")), mode=self._mode, ) except Exception as e: logger.error("Get account failed: %s", e) return AccountInfo( account_id="", buying_power=0, cash=0, portfolio_value=0, mode=self._mode, ) async def cancel_all_orders(self) -> int: """Cancel all open orders on Alpaca. Uses DELETE /v2/orders which cancels all open orders in bulk. Returns the number of orders that were cancelled. """ async with httpx.AsyncClient(timeout=30) as client: try: resp = await client.delete( f"{self.base_url}/v2/orders", headers=self._headers(), ) resp.raise_for_status() # Alpaca returns a list of cancelled order objects (HTTP 207) data = resp.json() cancelled = len(data) if isinstance(data, list) else 0 logger.info("Cancelled %d open orders on Alpaca", cancelled) return cancelled except Exception as e: logger.error("Cancel all orders failed: %s", e) return 0 async def close_all_positions(self) -> int: """Liquidate all open positions on Alpaca. Uses DELETE /v2/positions which closes all positions at market. Returns the number of positions that were closed. """ async with httpx.AsyncClient(timeout=30) as client: try: resp = await client.delete( f"{self.base_url}/v2/positions", headers=self._headers(), ) resp.raise_for_status() # Alpaca returns a list of closed position order objects (HTTP 207) data = resp.json() closed = len(data) if isinstance(data, list) else 0 logger.info("Closed %d positions on Alpaca", closed) return closed except Exception as e: logger.error("Close all positions failed: %s", e) return 0 def _parse_order_response(self, data: dict[str, Any]) -> OrderResponse: """Parse an Alpaca order response into an OrderResponse.""" status_map: dict[str, OrderStatus] = { "new": OrderStatus.SUBMITTED, "accepted": OrderStatus.ACCEPTED, "partially_filled": OrderStatus.PARTIALLY_FILLED, "filled": OrderStatus.FILLED, "done_for_day": OrderStatus.FILLED, "canceled": OrderStatus.CANCELLED, "expired": OrderStatus.EXPIRED, "replaced": OrderStatus.SUBMITTED, "pending_new": OrderStatus.PENDING, "pending_cancel": OrderStatus.PENDING, "pending_replace": OrderStatus.PENDING, "rejected": OrderStatus.REJECTED, } raw_status = str(data.get("status", "pending")) status = status_map.get(raw_status, OrderStatus.PENDING) side_str = str(data.get("side", "buy")) side = OrderSide.SELL if side_str == "sell" else OrderSide.BUY filled_qty = float(data.get("filled_qty", 0) or 0) filled_avg = data.get("filled_avg_price") filled_avg_price = float(filled_avg) if filled_avg else None return OrderResponse( broker_order_id=str(data.get("id", "")), status=status, ticker=str(data.get("symbol", "")), side=side, quantity=float(data.get("qty", 0) or 0), filled_quantity=filled_qty, filled_avg_price=filled_avg_price, raw_response=data, ) def _parse_position(self, data: dict[str, Any]) -> PositionInfo: """Parse an Alpaca position response into a PositionInfo.""" return PositionInfo( ticker=str(data.get("symbol", "")), quantity=float(data.get("qty", 0) or 0), avg_entry_price=float(data.get("avg_entry_price", 0) or 0), current_price=float(data.get("current_price", 0) or 0), unrealized_pnl=float(data.get("unrealized_pl", 0) or 0), market_value=float(data.get("market_value", 0) or 0), side=str(data.get("side", "long")), ) def _error_result( self, ticker: str, error: str, elapsed_ms: float, http_status: int | None = None, raw: bytes = b"", ) -> AdapterResult: """Build an error AdapterResult for broker fetches.""" return AdapterResult( source_type="broker", ticker=ticker, items=[], raw_payload=raw, content_hash="", fetched_at=datetime.now(timezone.utc), error=error, http_status=http_status, response_time_ms=round(elapsed_ms, 1), metadata={"provider": "alpaca", "mode": self._mode.value}, )