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:
1 parent
b3821abd69
commit
e1ac514f30
36 files changed
+1476
-294
No files matched your search
+47
-10
@@ -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})")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user