Files
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

126 lines
4.4 KiB
Python

"""Dynamic agent registry — the bridge between the dashboard and the pipeline.
Built-in personas (``config/personas.py``) are code defaults. ``state/agents.json`` holds
dashboard edits: partial overrides for built-ins (budgets, toggles, prompts) and full
definitions for custom agents. ``load_personas()`` merges the two and is what the nightly
run, the executor, and the committee iterate — NOT the ``PERSONAS`` constant.
Fail-open: a missing/corrupt agents.json or an invalid agent entry never blocks a run;
the built-ins (and every valid entry) still load.
"""
from __future__ import annotations
import json
from pathlib import Path
from loguru import logger
from config.personas import PERSONAS, PERSONAS_BY_ID, Persona
from config.settings import get_settings
BUILTIN_IDS = set(PERSONAS_BY_ID)
# Baseline for custom agents so a partial definition still validates. display_name and
# system_prompt have no sensible default — the dashboard form requires them.
_CUSTOM_BASE: dict = {
"max_position_pct": 0.08,
"target_pick_count": 4,
"default_stop_pct": 0.07,
"default_take_pct": 0.15,
"preferred_horizon": "swing",
"tool_emphasis": [],
"enabled": True,
"time_budget_min": None,
"min_research_min": None,
"options_quota": 1,
"risk_style": "balanced",
}
def registry_path() -> Path:
return get_settings().state_dir / "agents.json"
def load_overrides() -> dict[str, dict]:
"""Raw {agent_id: fields} from agents.json. {} if absent or unreadable."""
path = registry_path()
if not path.exists():
return {}
try:
data = json.loads(path.read_text())
agents = data.get("agents", {})
return {str(k): v for k, v in agents.items() if isinstance(v, dict)}
except Exception as e:
logger.warning("agents.json unreadable ({}); using built-in personas only", e)
return {}
def load_personas(include_disabled: bool = False) -> list[Persona]:
"""Effective agent list: built-ins (with overrides applied) + valid custom agents.
Order: built-ins in code order, then custom agents sorted by id. Disabled agents are
excluded unless ``include_disabled`` (the dashboard shows everything).
"""
overrides = load_overrides()
personas: list[Persona] = []
for builtin in PERSONAS:
fields = builtin.model_dump()
ov = overrides.get(builtin.id, {})
fields.update({k: v for k, v in ov.items() if k != "id"})
try:
personas.append(Persona(**fields))
except Exception as e:
logger.warning("bad overrides for {} ({}); using built-in defaults", builtin.id, e)
personas.append(builtin)
for agent_id in sorted(set(overrides) - BUILTIN_IDS):
fields = {**_CUSTOM_BASE, **overrides[agent_id], "id": agent_id}
try:
personas.append(Persona(**fields))
except Exception as e:
logger.warning("skipping invalid custom agent {}: {}", agent_id, e)
if include_disabled:
return personas
return [p for p in personas if p.enabled]
def upsert_agent(agent_id: str, fields: dict) -> None:
"""Merge ``fields`` into an agent's entry and persist. Creates custom agents.
For built-ins only the diff vs code defaults is stored, so unchanged fields keep
tracking future code updates.
"""
agent_id = agent_id.strip().lower().replace(" ", "_").replace("-", "_")
if not agent_id:
raise ValueError("agent id must be non-empty")
overrides = load_overrides()
entry = {**overrides.get(agent_id, {}), **{k: v for k, v in fields.items() if k != "id"}}
builtin = PERSONAS_BY_ID.get(agent_id)
if builtin is not None:
defaults = builtin.model_dump()
entry = {k: v for k, v in entry.items() if defaults.get(k) != v}
if not builtin:
# validate custom agents up front so the dashboard surfaces mistakes immediately
Persona(**{**_CUSTOM_BASE, **entry, "id": agent_id})
overrides[agent_id] = entry
_write(overrides)
def delete_agent(agent_id: str) -> None:
"""Remove a custom agent, or reset a built-in back to its code defaults."""
overrides = load_overrides()
if agent_id in overrides:
del overrides[agent_id]
_write(overrides)
def _write(overrides: dict[str, dict]) -> None:
path = registry_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps({"agents": overrides}, indent=2))
tmp.replace(path)