"""Alpaca Paper Trading API wrapper. Switch from paper to live by setting paper=False (or ALPACA_PAPER=false). Requires: pip install alpaca-py """ from __future__ import annotations import datetime as dt import os from dataclasses import dataclass from typing import Any @dataclass class AccountInfo: equity: float cash: float buying_power: float long_market_value: float unrealized_pl: float portfolio_value: float @dataclass class Order: id: str symbol: str qty: int side: str # "buy" or "sell" status: str filled_avg_price: float | None filled_qty: int client_order_id: str | None = None @dataclass class Position: symbol: str qty: int avg_entry_price: float current_price: float unrealized_pl: float market_value: float @dataclass class Bar: date: str open: float high: float low: float close: float volume: float @dataclass class PortfolioHistory: timestamps: list[int] equity: list[float] profit_loss: list[float] profit_loss_pct: list[float] @dataclass class MarketClock: is_open: bool next_open: dt.datetime | None next_close: dt.datetime | None class AlpacaBroker: """Thin wrapper around alpaca-py TradingClient for paper trading.""" def __init__( self, api_key: str | None = None, secret_key: str | None = None, paper: bool = True, ) -> None: self._api_key = api_key or os.environ.get("ALPACA_API_KEY", "") self._secret_key = secret_key or os.environ.get("ALPACA_SECRET_KEY", "") self._paper = paper if not self._api_key or not self._secret_key: raise ValueError( "Alpaca credentials missing. Set ALPACA_API_KEY and ALPACA_SECRET_KEY env vars." ) try: from alpaca.trading.client import TradingClient from alpaca.data.historical import StockHistoricalDataClient except ImportError as exc: raise ImportError("alpaca-py not installed. Run: pip install alpaca-py") from exc self._trading = TradingClient( api_key=self._api_key, secret_key=self._secret_key, paper=self._paper, ) self._data = StockHistoricalDataClient( api_key=self._api_key, secret_key=self._secret_key, ) # ------------------------------------------------------------------ # # Account # ------------------------------------------------------------------ # def get_clock(self) -> MarketClock: """Return Alpaca's market clock (respects holidays, early closes, halts).""" clk = self._trading.get_clock() return MarketClock( is_open=bool(clk.is_open), next_open=clk.next_open, next_close=clk.next_close, ) def get_account(self) -> AccountInfo: acct = self._trading.get_account() equity = float(acct.equity or 0) last_equity = float(acct.last_equity or equity) return AccountInfo( equity=equity, cash=float(acct.cash or 0), buying_power=float(acct.buying_power or 0), long_market_value=float(acct.long_market_value or 0), unrealized_pl=equity - last_equity, portfolio_value=float(acct.portfolio_value or equity), ) # ------------------------------------------------------------------ # # Orders # ------------------------------------------------------------------ # def submit_market_buy(self, symbol: str, qty: int, client_order_id: str | None = None) -> Order: from alpaca.trading.requests import MarketOrderRequest from alpaca.trading.enums import OrderSide, TimeInForce req = MarketOrderRequest( symbol=symbol, qty=qty, side=OrderSide.BUY, time_in_force=TimeInForce.DAY, **({"client_order_id": client_order_id} if client_order_id else {}), ) order = self._trading.submit_order(req) return self._to_order(order) def submit_moc_buy(self, symbol: str, qty: int, client_order_id: str | None = None) -> Order: """Submit a Market-on-Close buy order (fills at today's closing price).""" from alpaca.trading.requests import MarketOrderRequest from alpaca.trading.enums import OrderSide, TimeInForce req = MarketOrderRequest( symbol=symbol, qty=qty, side=OrderSide.BUY, time_in_force=TimeInForce.CLS, **({"client_order_id": client_order_id} if client_order_id else {}), ) order = self._trading.submit_order(req) return self._to_order(order) def submit_market_sell(self, symbol: str, qty: int) -> Order: from alpaca.trading.requests import MarketOrderRequest from alpaca.trading.enums import OrderSide, TimeInForce req = MarketOrderRequest( symbol=symbol, qty=qty, side=OrderSide.SELL, time_in_force=TimeInForce.DAY, ) order = self._trading.submit_order(req) return self._to_order(order) def get_order(self, order_id: str) -> Order: order = self._trading.get_order_by_id(order_id) return self._to_order(order) def list_orders(self, status: str = "open") -> list[Order]: from alpaca.trading.requests import GetOrdersRequest from alpaca.trading.enums import QueryOrderStatus status_map = { "open": QueryOrderStatus.OPEN, "closed": QueryOrderStatus.CLOSED, "all": QueryOrderStatus.ALL, } req = GetOrdersRequest(status=status_map.get(status, QueryOrderStatus.OPEN)) orders = self._trading.get_orders(req) return [self._to_order(o) for o in orders] def cancel_order(self, order_id: str) -> None: self._trading.cancel_order_by_id(order_id) # ------------------------------------------------------------------ # # Positions # ------------------------------------------------------------------ # def list_positions(self) -> list[Position]: positions = self._trading.get_all_positions() return [self._to_position(p) for p in positions] def get_position(self, symbol: str) -> Position | None: try: pos = self._trading.get_open_position(symbol) return self._to_position(pos) except Exception: return None def close_position(self, symbol: str, qty: int | None = None) -> Order: """Close a position. Pass qty for partial close. Returns the submitted close Order; caller must poll `get_order(order.id)` for the actual `filled_avg_price` — the submit response does NOT include fill data. """ from alpaca.trading.requests import ClosePositionRequest if qty is not None: req = ClosePositionRequest(qty=str(qty)) order = self._trading.close_position(symbol, close_options=req) else: order = self._trading.close_position(symbol) return self._to_order(order) def close_all_positions(self) -> list[Order]: responses = self._trading.close_all_positions(cancel_orders=True) if not responses: return [] result = [] for resp in responses: try: # close_all_positions returns ClosePositionResponse; body is the actual Order order_obj = getattr(resp, "body", resp) result.append(self._to_order(order_obj)) except Exception: pass return result # ------------------------------------------------------------------ # # Price data # ------------------------------------------------------------------ # def get_bars( self, symbols: list[str], start: dt.date, end: dt.date, ) -> dict[str, list[Bar]]: """Fetch daily OHLCV bars for a list of symbols via Oracle API. Oracle normalises problematic symbols (e.g. BF-B → BF.B) and maps responses back to the original symbol names. """ if not symbols: return {} from libs.oracle_client.alpaca import get_multi_daily_bars raw = get_multi_daily_bars( tickers=symbols, start_date=start.isoformat(), end_date=end.isoformat(), ) result: dict[str, list[Bar]] = {} for sym in symbols: bars_data = raw.get(sym, []) result[sym] = [ Bar( date=b["date"], open=float(b["open"]), high=float(b["high"]), low=float(b["low"]), close=float(b["close"]), volume=float(b["volume"]), ) for b in bars_data ] return result def get_bars_as_dict( self, symbols: list[str], start: dt.date, end: dt.date, ) -> dict[str, dict[dt.date, dict[str, Any]]]: """Return bars indexed by symbol → date → OHLCV dict (matches backtest format).""" raw = self.get_bars(symbols, start, end) result: dict[str, dict[dt.date, dict[str, Any]]] = {} for sym, bars in raw.items(): date_map: dict[dt.date, dict[str, Any]] = {} for bar in bars: d = dt.date.fromisoformat(bar.date) date_map[d] = { "date": d, "open": bar.open, "high": bar.high, "low": bar.low, "close": bar.close, "volume": bar.volume, } result[sym] = date_map return result def get_intraday_bars( self, symbols: list[str], start: dt.datetime, end: dt.datetime, timeframe_minutes: int = 5, ) -> dict[str, list[dict]]: """Fetch intraday OHLCV bars for a list of symbols via Oracle API. - Today's date → /alpaca/intraday/today (IEX real-time, force_refresh) - Historical dates → /alpaca/intraday (SIP, DB-cached) Returns {symbol: [{timestamp: ISO8601, open, high, low, close, volume}, ...]}. """ if not symbols: return {} interval = f"{timeframe_minutes}min" today = dt.date.today() if start.date() >= today: from libs.oracle_client.alpaca import get_multi_intraday_bars_today raw = get_multi_intraday_bars_today(tickers=symbols, interval=interval) else: from libs.oracle_client.alpaca import get_multi_intraday_bars raw = get_multi_intraday_bars( tickers=symbols, start_date=start.date().isoformat(), end_date=end.date().isoformat(), interval=interval, ) result: dict[str, list[dict]] = {sym: [] for sym in symbols} for sym in symbols: result[sym] = raw.get(sym, []) return result def get_latest_bars(self, symbols: list[str]) -> dict[str, Bar]: """Fetch the latest bar for each symbol via Oracle snapshot API. Routes through Oracle so that problematic symbols (e.g. BF-B → BF.B) are normalised server-side before hitting Alpaca. """ if not symbols: return {} from libs.oracle_client.alpaca import get_snapshots snaps = get_snapshots(symbols) result: dict[str, Bar] = {} import datetime as _dt today = _dt.date.today().isoformat() for sym, snap in snaps.items(): price = snap.price or snap.mid if price is not None: result[sym] = Bar( date=today, open=price, high=price, low=price, close=price, volume=snap.volume or 0, ) return result # ------------------------------------------------------------------ # # Portfolio history # ------------------------------------------------------------------ # def get_portfolio_history(self, period: str = "1M") -> PortfolioHistory: from alpaca.trading.requests import GetPortfolioHistoryRequest req = GetPortfolioHistoryRequest(period=period, timeframe="1D") hist = self._trading.get_portfolio_history(req) return PortfolioHistory( timestamps=list(hist.timestamp or []), equity=[float(v) for v in (hist.equity or [])], profit_loss=[float(v) for v in (hist.profit_loss or [])], profit_loss_pct=[float(v) for v in (hist.profit_loss_pct or [])], ) # ------------------------------------------------------------------ # # Helpers # ------------------------------------------------------------------ # @staticmethod def _to_order(order: Any) -> Order: return Order( id=str(order.id), symbol=str(order.symbol), qty=int(float(order.qty or 0)), side=str(order.side.value if hasattr(order.side, "value") else order.side), status=str(order.status.value if hasattr(order.status, "value") else order.status), filled_avg_price=float(order.filled_avg_price) if order.filled_avg_price else None, filled_qty=int(float(order.filled_qty or 0)), client_order_id=str(order.client_order_id) if getattr(order, "client_order_id", None) else None, ) @staticmethod def _to_position(pos: Any) -> Position: return Position( symbol=str(pos.symbol), qty=int(float(pos.qty or 0)), avg_entry_price=float(pos.avg_entry_price or 0), current_price=float(pos.current_price or 0), unrealized_pl=float(pos.unrealized_pl or 0), market_value=float(pos.market_value or 0), ) @classmethod def from_env(cls) -> "AlpacaBroker": """Create from environment variables.""" paper = os.environ.get("ALPACA_PAPER", "true").lower() != "false" return cls( api_key=os.environ.get("ALPACA_API_KEY"), secret_key=os.environ.get("ALPACA_SECRET_KEY"), paper=paper, )