Files
trading-agent/orchestration/committee.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

168 lines
6.9 KiB
Python

"""The Investment Committee — a Chief Risk Officer pass over the whole night's book.
After all three agents finalize, a clean-context LLM call reviews every pick against the
market regime, cross-book concentration, and each agent's track record, then issues a
verdict per pick: APPROVE, REDUCE (halve size), or VETO (drop, with reason). The morning
executor enforces the verdicts. A committee memo (markdown) lands in the reports dir so the
dashboard renders it like the other notes.
This is the layer no retail bot has: the agents don't trade unsupervised — their book has
to clear a risk officer first.
"""
from __future__ import annotations
import json
from pathlib import Path
from langchain_core.messages import HumanMessage, SystemMessage
from loguru import logger
from agents.schemas import AgentOutput
from config.settings import get_settings
from llm.client import build_llm
CRO_SYSTEM = (
"You are the Chief Risk Officer of a small fund. {n} analysts submitted tonight's picks. "
"Your job is capital preservation and portfolio coherence — NOT stock picking. Judge each "
"pick on: thesis quality vs the market regime, concentration/overlap across the books, "
"crowding into one theme, position sizing sanity, and each analyst's recent track record. "
"Each analyst has a declared risk style — judge every book against ITS OWN mandate: an "
"intentionally aggressive book is not vetoed for being aggressive, only for breaking its "
"own discipline (undefined risk, sloppy sizing, no catalyst). "
"Be decisive and specific. Output ONLY the requested JSON object."
)
CRO_PROMPT = (
"{regime}\n\n"
"ANALYST MANDATES (risk styles):\n{styles}\n\n"
"ANALYST TRACK RECORDS:\n{records}\n\n"
"TONIGHT'S SUBMITTED PICKS:\n{picks}\n\n"
'Return a JSON object: {{"memo": "<a 150-250 word committee memo in plain prose: overall '
'read on the book, concentration/crowding concerns, what you approved/cut and why>", '
'"verdicts": [{{"agent": "<persona_id>", "ticker": "<SYM>", "verdict": "approve|reduce|veto", '
'"reason": "<one sentence>"}}]}}\n'
"Every submitted pick MUST appear exactly once in verdicts. reduce = halve the size. "
"veto = do not trade it. Veto sparingly — only for real risk problems, not style disagreements."
)
def _fmt_picks(outputs: list[AgentOutput]) -> str:
lines = []
for o in outputs:
for p in o.picks:
inst = p.instrument.value if hasattr(p.instrument, "value") else p.instrument
lines.append(
f"- agent={o.persona_id} {p.ticker} [{inst}/{p.direction.value}] "
f"conv={p.conviction} weight={p.requested_weight_pct} "
f"stop={p.stop_loss_pct} take={p.take_profit_pct} :: {p.thesis[:160]}"
)
return "\n".join(lines) or "(none)"
def _extract_json(text: str) -> dict | None:
t = text.strip().strip("`")
start, end = t.find("{"), t.rfind("}")
if start == -1 or end <= start:
return None
try:
return json.loads(t[start : end + 1])
except Exception:
return None
def run_committee(
outputs: list[AgentOutput], trade_date: str, regime: str, records: dict[str, str]
) -> dict:
"""Run the CRO pass. Returns {(agent,ticker): {"verdict","reason"}}; writes memo + verdicts.
Fail-open: if the model or parsing fails, everything is approved (the guardrails still
apply downstream) — the committee can only make the book safer, never block it entirely.
"""
s = get_settings()
picks_txt = _fmt_picks(outputs)
if picks_txt == "(none)":
logger.info("committee: no picks to review")
return {}
records_txt = "\n".join(f"[{k}]\n{v or '(no history yet)'}" for k, v in records.items())
# style-aware review: the CRO judges each book against its own declared mandate
try:
from config.agent_registry import load_personas
styles = {p.id: p.risk_style for p in load_personas(include_disabled=True)}
except Exception:
styles = {}
styles_txt = "\n".join(
f"- {o.persona_id}: {styles.get(o.persona_id, 'balanced')}" for o in outputs
)
verdicts: dict = {}
memo = ""
try:
resp = build_llm(max_tokens=4096).invoke(
[
SystemMessage(content=CRO_SYSTEM.format(n=len(outputs))),
HumanMessage(
content=CRO_PROMPT.format(
regime=regime or "(regime unavailable)",
styles=styles_txt or "(none declared)",
records=records_txt,
picks=picks_txt,
)
),
]
)
content = resp.content if isinstance(resp.content, str) else str(resp.content)
data = _extract_json(content) or {}
memo = str(data.get("memo", ""))
for v in data.get("verdicts", []):
if not isinstance(v, dict):
continue
key = (str(v.get("agent", "")).strip(), str(v.get("ticker", "")).strip().upper())
verdict = str(v.get("verdict", "approve")).lower()
if verdict not in ("approve", "reduce", "veto"):
verdict = "approve"
verdicts[key] = {"verdict": verdict, "reason": str(v.get("reason", ""))[:300]}
except Exception as e:
logger.warning("committee failed (fail-open, all approved): {}", e)
# persist: verdicts next to the agent JSON (executor reads it), memo into reports
out_dir = s.output_dir / trade_date
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "_committee.json").write_text(
json.dumps(
{f"{a}|{t}": v for (a, t), v in verdicts.items()} | {"_memo": memo}, indent=2
)
)
rep_dir = s.reports_dir / trade_date
rep_dir.mkdir(parents=True, exist_ok=True)
vet_lines = "\n".join(
f"- **{t}** ({a}): **{v['verdict'].upper()}** — {v['reason']}"
for (a, t), v in verdicts.items()
)
(rep_dir / "committee.md").write_text(
f"# Investment Committee Memo — {trade_date}\n\n{memo or '(memo unavailable)'}\n\n"
f"## Verdicts\n{vet_lines or '(all picks approved by default)'}\n"
)
n_veto = sum(1 for v in verdicts.values() if v["verdict"] == "veto")
n_red = sum(1 for v in verdicts.values() if v["verdict"] == "reduce")
logger.info("committee: {} verdicts ({} veto, {} reduce)", len(verdicts), n_veto, n_red)
return verdicts
def load_verdicts(trade_date: str) -> dict:
"""Executor-side: {(agent,ticker): {"verdict","reason"}} or {} if no committee ran."""
path: Path = get_settings().output_dir / trade_date / "_committee.json"
if not path.exists():
return {}
try:
raw = json.loads(path.read_text())
return {
tuple(k.split("|", 1)): v
for k, v in raw.items()
if k != "_memo" and isinstance(v, dict)
}
except Exception:
return {}