You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

361 lines
12 KiB
Python

"""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
@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]
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_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) -> 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,
)
order = self._trading.submit_order(req)
return self._to_order(order)
def submit_moc_buy(self, symbol: str, qty: int) -> 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,
)
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, **kwargs) -> Order:
"""Close a position. Pass qty for partial close."""
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 in [start, end]."""
if not symbols:
return {}
from alpaca.data.requests import StockBarsRequest
from alpaca.data.timeframe import TimeFrame
req = StockBarsRequest(
symbol_or_symbols=symbols,
timeframe=TimeFrame.Day,
start=dt.datetime.combine(start, dt.time.min),
end=dt.datetime.combine(end, dt.time.max),
feed="iex",
)
response = self._data.get_stock_bars(req)
result: dict[str, list[Bar]] = {}
for sym in symbols:
try:
bars_data = response[sym]
except (KeyError, TypeError):
bars_data = []
result[sym] = [
Bar(
date=b.timestamp.date().isoformat() if hasattr(b.timestamp, "date") else str(b.timestamp)[:10],
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_latest_bars(self, symbols: list[str]) -> dict[str, Bar]:
"""Fetch the latest bar for each symbol."""
if not symbols:
return {}
from alpaca.data.requests import StockLatestBarRequest
req = StockLatestBarRequest(symbol_or_symbols=symbols, feed="iex")
response = self._data.get_stock_latest_bar(req)
result: dict[str, Bar] = {}
for sym in symbols:
b = response.get(sym)
if b is not None:
result[sym] = Bar(
date=b.timestamp.date().isoformat() if hasattr(b.timestamp, "date") else str(b.timestamp)[:10],
open=float(b.open),
high=float(b.high),
low=float(b.low),
close=float(b.close),
volume=float(b.volume),
)
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)),
)
@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,
)