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:
Aryan SinghandClaude Opus 4.8 committed 2026-07-01 16:29:40 -05:00
1 parent aa1ae0c95e
commit 709870b1d5
8 files changed
+813

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
"""Pure, unit-tested risk guardrails. Turns the agents' picks into a safe order plan.
No network, no Alpaca objects — takes plain data (equity, prices, held symbols) so the
whole pipeline is deterministic and testable. Handles equities and single-leg long options
(sized by premium × 100). The LLM's requested sizing / stops are inputs that get clamped
here; nothing is trusted as-is.
Pipeline: collect (long-only unless enabled) -> dedup across agents (highest conviction) ->
per-agent trade cap -> price/liquidity filter + skip already-held -> size & clamp weight ->
per-day cap -> buying-power trim.
"""
from __future__ import annotations
from dataclasses import dataclass
from math import floor
from agents.schemas import AgentOutput, Direction, Instrument, Pick
from config.risk import RiskConfig
@dataclass
class PlannedOrder:
ticker: str
persona_id: str
qty: int
est_price: float # share price (equity) or premium per share (option)
weight_pct: float
conviction: float
stop_pct: float | None
take_pct: float | None
entry_type: str
direction: str = "long"
instrument: str = "equity"
contract_symbol: str | None = None
multiplier: int = 1 # 100 for options
option_right: str | None = None
def _exec_key(pick: Pick) -> str:
"""The symbol we price/dedup/hold on: contract for options, ticker for equities."""
return pick.contract_symbol if pick.instrument == Instrument.OPTION else pick.ticker
def _multiplier(pick: Pick) -> int:
return 100 if pick.instrument == Instrument.OPTION else 1
def plan_orders(
agent_outputs: list[AgentOutput],
*,
equity: float,
buying_power: float,
prices: dict[str, float],
held_symbols: set[str],
risk: RiskConfig,
persona_caps: dict[str, float],
) -> tuple[list[PlannedOrder], list[str]]:
notes: list[str] = []
# 1. collect candidates (long-only unless shorts allowed; options are long-only here)
candidates: list[tuple[str, Pick]] = []
for out in agent_outputs:
for pick in out.picks:
if pick.direction != Direction.LONG and not risk.allow_shorts:
notes.append(f"drop {pick.ticker} ({out.persona_id}): shorts disabled")
continue
if pick.instrument == Instrument.OPTION and pick.direction != Direction.LONG:
notes.append(f"drop {pick.ticker} ({out.persona_id}): only long options supported")
continue
candidates.append((out.persona_id, pick))
# 2. dedup across agents — highest conviction wins the exec key
best: dict[str, tuple[str, Pick]] = {}
for pid, pick in candidates:
k = _exec_key(pick)
cur = best.get(k)
if cur is None or pick.conviction > cur[1].conviction:
if cur is not None:
notes.append(f"dedup {pick.ticker}: {pid} outranks {cur[0]}")
best[k] = (pid, pick)
# 3. per-agent trade cap — keep each persona's top-conviction picks
by_persona: dict[str, list[Pick]] = {}
for pid, pick in best.values():
by_persona.setdefault(pid, []).append(pick)
kept: list[tuple[str, Pick]] = []
for pid, picks in by_persona.items():
picks.sort(key=lambda p: p.conviction, reverse=True)
if len(picks) > risk.max_trades_per_agent:
notes.append(f"{pid}: capped to {risk.max_trades_per_agent} trades")
kept.extend((pid, p) for p in picks[: risk.max_trades_per_agent])
# 4. price / liquidity filter + skip already-held (min_price only applies to equities)
priced: list[tuple[str, Pick, float]] = []
for pid, pick in kept:
key = _exec_key(pick)
px = prices.get(key)
if not px or px <= 0:
notes.append(f"drop {pick.ticker}: no price")
elif pick.instrument == Instrument.EQUITY and px < risk.min_price:
notes.append(f"drop {pick.ticker}: price {px:.2f} < min {risk.min_price}")
elif pick.instrument == Instrument.EQUITY and risk.max_price and px > risk.max_price:
notes.append(f"drop {pick.ticker}: price {px:.2f} > max {risk.max_price}")
elif key in held_symbols:
notes.append(f"skip {pick.ticker}: already held")
else:
priced.append((pid, pick, px))
# 5. clamp weight, size to whole shares/contracts
orders: list[PlannedOrder] = []
for pid, pick, px in priced:
cap = min(
persona_caps.get(pid, risk.max_position_pct),
risk.max_position_pct,
risk.max_symbol_exposure_pct,
)
weight = min(pick.requested_weight_pct, cap)
mult = _multiplier(pick)
qty = floor(weight * equity / (px * mult))
if qty < 1:
notes.append(f"drop {pick.ticker}: qty<1 at weight {weight:.3f}")
continue
orders.append(
PlannedOrder(
ticker=pick.ticker, persona_id=pid, qty=qty, est_price=px,
weight_pct=weight, conviction=pick.conviction,
stop_pct=pick.stop_loss_pct, take_pct=pick.take_profit_pct,
entry_type=pick.entry_type.value, direction=pick.direction.value,
instrument=pick.instrument.value, contract_symbol=pick.contract_symbol,
multiplier=mult, option_right=pick.option_right,
)
)
# 6. per-day cap — rank all by conviction, truncate
orders.sort(key=lambda o: o.conviction, reverse=True)
if len(orders) > risk.max_trades_per_day:
notes.append(f"day cap: {len(orders)} -> {risk.max_trades_per_day}")
orders = orders[: risk.max_trades_per_day]
# 7. buying-power trim — drop lowest-conviction until it fits (notional = qty*price*mult)
def _notional(o: PlannedOrder) -> float:
return o.qty * o.est_price * o.multiplier
total = sum(_notional(o) for o in orders)
while orders and total > buying_power:
dropped = orders.pop()
total -= _notional(dropped)
notes.append(f"drop {dropped.ticker}: buying power (need ${total:,.0f} > ${buying_power:,.0f})")
return orders, notes