feat(execution): per-account Alpaca GTC brackets, guardrails, shorts, options, reconcile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016DvdRZkyCy9NJpHYDdPqmS
This commit is contained in:
1 parent
aa1ae0c95e
commit
709870b1d5
8 files changed
+813
No files matched your search
@@ -0,0 +1,153 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user