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.
This commit is contained in:
Aryan Singh committed 2026-07-07 22:00:20 -05:00
1 parent b3821abd69
commit e1ac514f30
36 files changed
+1476 -294

No files matched your search

+47 -10
View File
@@ -5,9 +5,14 @@ whole pipeline is deterministic and testable. Handles equities and single-leg lo
(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.
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
@@ -59,6 +64,8 @@ def plan_orders(
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:
@@ -68,6 +75,12 @@ def plan_orders(
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
@@ -80,16 +93,22 @@ def plan_orders(
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
# 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")
kept.extend((pid, p) for p in picks[: risk.max_trades_per_agent])
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]] = []
@@ -107,7 +126,8 @@ def plan_orders(
else:
priced.append((pid, pick, px))
# 5. clamp weight, size to whole shares/contracts
# 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(
@@ -115,6 +135,8 @@ def plan_orders(
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))
@@ -132,19 +154,34 @@ def plan_orders(
)
)
# 6. per-day cap — rank all by conviction, truncate
# 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 = orders[: risk.max_trades_per_day]
orders = keep
# 7. buying-power trim — drop lowest-conviction until it fits (notional = qty*price*mult)
# 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:
dropped = orders.pop()
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})")
+6 -4
View File
@@ -17,7 +17,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from loguru import logger # noqa: E402
from agents.schemas import AgentOutput # noqa: E402
from config.personas import PERSONAS # noqa: E402
from config.agent_registry import load_personas # noqa: E402
from config.risk import DEFAULT_RISK # noqa: E402
from config.settings import get_settings # noqa: E402
from execution import broker as broker_mod # noqa: E402
@@ -111,9 +111,11 @@ def main(force: bool = False, date: str | None = None) -> int:
db.record_thesis(con, trade_date, o.persona_id, pick.model_dump(mode="json"))
con.commit()
# 4. per-persona: own account, own guardrails, own orders
# 4. per-persona: own account, own guardrails, own orders (registry: an agent disabled
# on the dashboard after its research ran will NOT have its picks executed)
personas = load_personas()
total_placed = 0
for persona in PERSONAS:
for persona in personas:
out = by_persona.get(persona.id)
if not out or not out.picks:
continue
@@ -196,7 +198,7 @@ def main(force: bool = False, date: str | None = None) -> int:
(trade_date,),
).fetchall()
lines = [f"**{total_placed} orders placed** across accounts for {trade_date}.", ""]
for persona in PERSONAS:
for persona in personas:
ords = [r for r in rows if r["agent"] == persona.id]
snap = db.latest_snapshot(con2, persona.id)
eq = f"${snap['equity']:,.2f}" if snap else "n/a"