Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016DvdRZkyCy9NJpHYDdPqmS
154 lines
5.7 KiB
Python
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)
|