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.

426 lines
14 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
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,
)