"""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)