feat: market regime, self-learning journal, AI investment committee
- regime.py: computed SPY/VIX/sector-momentum snapshot injected into every kickoff - journal.py: each agent sees its own past picks marked-to-market (learns from wins/losses) - committee.py: Chief-Risk-Officer LLM pass over the whole book — approve/reduce/veto per pick with reasons; memo lands in reports; executor enforces verdicts at open Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016DvdRZkyCy9NJpHYDdPqmS
This commit is contained in:
1 parent
6b1c461a80
commit
4beee3dc82
6 files changed
+323
-2
No files matched your search
@@ -0,0 +1,151 @@
|
||||
"""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": "<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())
|
||||
|
||||
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 {}
|
||||
Reference in new issue
Block a user