"""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. Three 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 three books, " "crowding into one theme, position sizing sanity, and each analyst's recent track record. " "Be decisive and specific. Output ONLY the requested JSON object." ) CRO_PROMPT = ( "{regime}\n\n" "ANALYST TRACK RECORDS:\n{records}\n\n" "TONIGHT'S SUBMITTED PICKS:\n{picks}\n\n" 'Return a JSON object: {{"memo": "", ' '"verdicts": [{{"agent": "", "ticker": "", "verdict": "approve|reduce|veto", ' '"reason": ""}}]}}\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()) verdicts: dict = {} memo = "" try: resp = build_llm(max_tokens=4096).invoke( [ SystemMessage(content=CRO_SYSTEM), HumanMessage( content=CRO_PROMPT.format( regime=regime or "(regime unavailable)", 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 {}