Files
trading-agent/execution/guardrails.py
Aryan Singh e1ac514f30 Add dynamic agent registry and options mandate
Introduces a dashboard-managed agent registry (`state/agents.json`) used by nightly research and market-open execution, including support for custom/disabled agents and per-agent time budgets. Expands personas to four agents (adds Maverick), shifts pick targets to include mandatory options, and updates prompts/finalization nudges to enforce option research without hard-failing runs.

Strengthens execution safety by adding option-specific guardrails (conviction floor, premium cap, reserved option slot through trade/day/buying-power caps), validates OCC option symbols in schemas, and normalizes invalid option direction cases. The dashboard was refactored into modular views, adds an Agents admin tab, and improves live portfolio visibility with Alpaca-first data and snapshot fallback.
2026-07-07 22:00:20 -05:00

189 lines
8.2 KiB
Python
Raw Permalink 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; options quality gate) -> dedup across agents
(highest conviction) -> per-agent trade cap (option slot reserved) -> price/liquidity filter
+ skip already-held -> size & clamp weight (options premium-capped) -> per-day cap ->
buying-power trim (reserved option dropped last).
The "reserved option": each night one options trade is mandated per agent, so the
highest-conviction option that passes the quality gate is protected from the caps and the
buying-power trim — otherwise conviction-ranked truncation would routinely cut it.
"""
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)
# + options quality gate: the mandate is a HIGH-PROBABILITY options trade, so option
# picks below the conviction floor never reach sizing.
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
if pick.instrument == Instrument.OPTION and pick.conviction < risk.min_option_conviction:
notes.append(
f"drop {pick.ticker} option ({out.persona_id}): conviction "
f"{pick.conviction:.2f} < floor {risk.min_option_conviction}"
)
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, but the persona's
# best option pick survives the cap (options-trade mandate)
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)
keep = picks[: risk.max_trades_per_agent]
if len(picks) > risk.max_trades_per_agent:
notes.append(f"{pid}: capped to {risk.max_trades_per_agent} trades")
options = [p for p in picks if p.instrument == Instrument.OPTION]
if options and not any(p.instrument == Instrument.OPTION for p in keep):
notes.append(f"{pid}: reserved option slot for {options[0].ticker}")
keep[-1] = options[0]
kept.extend((pid, p) for p in keep)
# 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 (options additionally premium-capped:
# weight = premium notional, i.e. the max loss — keep any single bet survivable)
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,
)
if pick.instrument == Instrument.OPTION:
cap = min(cap, risk.max_option_weight_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,
)
)
# the mandated trade: highest-conviction option that made it through sizing is protected
# from the day cap and the buying-power trim below
reserved = max(
(o for o in orders if o.instrument == "option"), key=lambda o: o.conviction, default=None
)
# 6. per-day cap — rank all by conviction, truncate (reserved option swapped back in)
orders.sort(key=lambda o: o.conviction, reverse=True)
if len(orders) > risk.max_trades_per_day:
keep, cut = orders[: risk.max_trades_per_day], orders[risk.max_trades_per_day :]
if reserved is not None and reserved in cut:
swapped = keep.pop()
cut.remove(reserved)
keep.append(reserved)
notes.append(f"day cap: reserved option {reserved.ticker} replaces {swapped.ticker}")
notes.append(f"day cap: {len(orders)} -> {risk.max_trades_per_day}")
orders = keep
# 7. buying-power trim — drop lowest-conviction until it fits (notional = qty*price*mult);
# the reserved option goes last (it's also the cheapest kind of trade to keep)
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:
droppable = [o for o in orders if o is not reserved] or orders
dropped = min(droppable, key=lambda o: o.conviction)
orders.remove(dropped)
total -= _notional(dropped)
notes.append(f"drop {dropped.ticker}: buying power (need ${total:,.0f} > ${buying_power:,.0f})")
return orders, notes