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.
388 lines
13 KiB
Python
388 lines
13 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 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.
|
|
|
|
Returns {symbol: [{timestamp: ISO8601, open, high, low, close, volume}, ...]}.
|
|
"""
|
|
if not symbols:
|
|
return {}
|
|
|
|
from libs.oracle_client.alpaca import get_multi_intraday_bars
|
|
|
|
interval = f"{timeframe_minutes}min"
|
|
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."""
|
|
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,
|
|
)
|