Files
trading-agent/execution/guardrails.py
T

152 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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