Files
trading-agent/execution/broker.py

154 lines
5.7 KiB
Python

"""Alpaca paper-trading wrapper — one Broker per persona account.
Each persona trades its own paper account (see settings.alpaca_creds). Bracket design
(resolves the market-entry TIF trap): the entry is a *marketable limit* (limit set a hair
above current price so it fills like a market order at the open) with time_in_force=GTC, so
the take-profit and stop-loss legs are GTC too and survive multi-day holds instead of being
cancelled at session close. Stop/take are derived from the FRESH price at execution time.
"""
from __future__ import annotations
from dataclasses import dataclass
from loguru import logger
from config.settings import get_settings
_ENTRY_BUFFER = 0.02 # 2% above ref so a limit buy fills promptly at the open
@dataclass
class BracketResult:
ticker: str
qty: int
alpaca_order_id: str
status: str
entry_limit: float
stop_price: float
take_price: float
@dataclass
class OptionResult:
contract_symbol: str
qty: int
alpaca_order_id: str
status: str
def bracket_levels(ref_price: float, stop_pct: float, take_pct: float, direction: str = "long"):
"""Pure level math for a marketable-limit bracket. Returns (side, entry, stop, take).
long: buy entry just ABOVE ref; stop below, take above (stop < entry < take)
short: sell entry just BELOW ref; stop above, take below (take < entry < stop)
Adaptive buffer keeps entry strictly inside the stop/take band.
"""
buffer = min(_ENTRY_BUFFER, take_pct * 0.4, stop_pct * 0.4)
if direction == "short":
entry = round(ref_price * (1 - buffer), 2)
take = round(ref_price * (1 - take_pct), 2)
stop = round(ref_price * (1 + stop_pct), 2)
if not (take < entry < stop):
raise ValueError(f"invalid short bracket: take={take} entry={entry} stop={stop}")
return "sell", entry, stop, take
entry = round(ref_price * (1 + buffer), 2)
take = round(ref_price * (1 + take_pct), 2)
stop = round(ref_price * (1 - stop_pct), 2)
if not (stop < entry < take):
raise ValueError(f"invalid long bracket: stop={stop} entry={entry} take={take}")
return "buy", entry, stop, take
class Broker:
def __init__(self, key: str, secret: str, paper: bool = True):
from alpaca.trading.client import TradingClient
self.client = TradingClient(key, secret, paper=paper)
def get_clock(self):
return self.client.get_clock()
def get_account(self):
return self.client.get_account()
def get_positions(self) -> list:
return self.client.get_all_positions()
def held_symbols(self) -> set[str]:
try:
return {p.symbol for p in self.get_positions()}
except Exception:
return set()
def is_tradable(self, symbol: str) -> bool:
try:
return bool(self.client.get_asset(symbol).tradable)
except Exception:
return False
def submit_bracket(
self, ticker: str, qty: int, ref_price: float, stop_pct: float, take_pct: float,
direction: str = "long",
) -> BracketResult:
"""Submit a marketable-limit GTC bracket around ref_price (long or short)."""
from alpaca.trading.enums import OrderClass, OrderSide, TimeInForce
from alpaca.trading.requests import (
LimitOrderRequest,
StopLossRequest,
TakeProfitRequest,
)
side_str, entry, stop, take = bracket_levels(ref_price, stop_pct, take_pct, direction)
side = OrderSide.SELL if side_str == "sell" else OrderSide.BUY
req = LimitOrderRequest(
symbol=ticker,
qty=qty,
side=side,
time_in_force=TimeInForce.GTC, # exits persist across sessions
order_class=OrderClass.BRACKET,
limit_price=entry,
take_profit=TakeProfitRequest(limit_price=take),
stop_loss=StopLossRequest(stop_price=stop),
)
order = self.client.submit_order(req)
logger.info("submitted {} {} x{} bracket entry={} stop={} take={} id={}",
direction, ticker, qty, entry, stop, take, order.id)
return BracketResult(
ticker=ticker, qty=qty, alpaca_order_id=str(order.id),
status=str(order.status), entry_limit=entry, stop_price=stop, take_price=take,
)
def submit_option(self, contract_symbol: str, qty: int, limit_price: float) -> OptionResult:
"""Buy-to-open a single-leg long option (call/put) with a marketable LIMIT (Alpaca
rejects options market orders). Max loss = premium paid, so no stop leg; Alpaca has no
bracket class for options, so this is entry-only (Phase 6a MVP)."""
from alpaca.trading.enums import OrderSide, TimeInForce
from alpaca.trading.requests import LimitOrderRequest
req = LimitOrderRequest(
symbol=contract_symbol, qty=qty, side=OrderSide.BUY,
time_in_force=TimeInForce.DAY, limit_price=round(limit_price, 2),
)
order = self.client.submit_order(req)
logger.info("submitted option BUY {} x{} limit={} id={}",
contract_symbol, qty, round(limit_price, 2), order.id)
return OptionResult(
contract_symbol=contract_symbol, qty=qty,
alpaca_order_id=str(order.id), status=str(order.status),
)
def for_persona(persona_id: str) -> Broker:
s = get_settings()
key, secret = s.alpaca_creds(persona_id)
return Broker(key, secret, paper=s.alpaca_paper)
def any_broker() -> Broker:
"""Broker for account-agnostic calls (e.g. the market clock). Uses any configured account."""
s = get_settings()
key, secret = s.alpaca_data_creds()
return Broker(key, secret, paper=s.alpaca_paper)