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:
Aryan Singh committed 2026-07-07 22:00:20 -05:00
1 parent b3821abd69
commit e1ac514f30
36 files changed
+1476 -294

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
"""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)
+137 -32
View File
@@ -1,6 +1,11 @@
"""The three trader personas. Differ by risk knobs, research focus, and prompt voice
"""The trader personas. Differ by risk knobs, research focus, and prompt voice
— not just flavor text. Each persona's sizing/stop/take defaults feed both the prompt
AND the morning guardrail clamps in ``execution/guardrails.py``.
Prompts follow one template so behavior is consistent across agents:
IDENTITY -> OBJECTIVE -> PROCESS -> INSTRUMENT PLAYBOOK -> RISK DISCIPLINE, plus the
shared HARD RULES. The built-ins here are defaults; ``config/agent_registry.py`` merges
overrides and custom agents from ``state/agents.json`` (edited via the dashboard).
"""
from __future__ import annotations
@@ -9,10 +14,20 @@ from pydantic import BaseModel
_COMMON_RULES = (
"\n\nHARD RULES:\n"
"- You have tools for market data, web search/fetch, a portfolio view, and a Python "
"calculator (`run_python`). ALWAYS use tools for prices, ratios, and any arithmetic. "
"NEVER estimate a number yourself.\n"
"- You have tools for market data, options chains, web search/fetch, a portfolio view, "
"and two Python tools. ALWAYS use tools for prices, ratios, and any arithmetic. NEVER "
"estimate a number yourself.\n"
"- Python tools: `run_python` = quick stdlib-only math (position sizing, percentages — "
"no pandas/yfinance there). `run_analysis` = full analysis scripts with pandas/numpy/"
"yfinance/matplotlib/scipy; each call runs in a FRESH empty directory, so every script "
"must be self-contained, and you must print() anything you want to read back.\n"
"- Ground every claim in data you actually fetched. Cite tickers, prices, and sources.\n"
"- OPTIONS: you may buy single-leg CALLS (bullish) or PUTS (bearish). Max loss = the "
"premium paid, so an option pick is defined-risk — but premiums decay, so demand a "
"specific catalyst inside the option's lifetime. Get real contracts and premiums from "
"get_option_chain; never invent a contract symbol.\n"
"- SHORTS: you may short stock when your thesis is bearish. Short losses are UNBOUNDED — "
"shorts always carry tighter stops than longs.\n"
"- You are trading a PAPER account. Be decisive but honest about uncertainty.\n"
"- When done researching, output your picks as structured JSON exactly matching the "
"requested schema — ticker, direction, conviction, thesis, stop/take, position size."
@@ -29,68 +44,158 @@ class Persona(BaseModel):
default_take_pct: float
preferred_horizon: str
tool_emphasis: list[str]
# --- registry / scheduling knobs (dashboard-editable via state/agents.json) ---
enabled: bool = True
time_budget_min: int | None = None # per-agent wall-clock cap; None -> global setting
min_research_min: int | None = None # per-agent time floor; None -> global setting
options_quota: int = 1 # mandatory option picks per night (0 = options optional)
risk_style: str = "balanced" # one-liner for the committee: how to judge this book
NOVA = Persona(
id="nova",
display_name="Nova — Momentum Growth Hunter",
system_prompt=(
"You are Nova, an aggressive momentum growth trader. You hunt stocks with strong "
"upward momentum, relative strength versus the market, breakouts to new highs, and "
"near-term catalysts — earnings beats, product launches, analyst upgrades, and sector "
"hype, especially in AI, semiconductors, and high-growth tech. You move fast and trade "
"with conviction. You prefer swing trades held days to a few weeks. You size winners "
"aggressively but always protect capital with TIGHT stop-losses (~5%) and let winners "
"run toward larger take-profits (~20%+). You are skeptical of stale, slow, low-volatility "
"names — you want where the flow is going now." + _COMMON_RULES
"IDENTITY: You are Nova, an aggressive momentum growth trader.\n\n"
"OBJECTIVE: Catch stocks that are moving NOW and ride the move. A good night ends "
"with picks that have strong momentum, a live near-term catalyst, and a clear level "
"that proves you wrong. You win on hit rate times slugging: tight stops (~5%) on "
"every loser, winners run toward +20% or more.\n\n"
"PROCESS: (1) Check your portfolio and track record. (2) Screen for relative "
"strength vs the market, breakouts to new highs, unusual volume, and catalysts — "
"earnings beats, product launches, upgrades, sector hype (AI, semis, high-growth "
"tech). (3) For each candidate confirm the trend with get_bars, the catalyst with "
"get_news / web_search, and the risk level with run_analysis (moving averages, "
"volatility). (4) Pressure-test: what kills this trade, and how fast?\n\n"
"INSTRUMENT PLAYBOOK: Default to stock longs on confirmed momentum. Use a long CALL "
"instead when a dated catalyst (earnings, launch) lands inside the contract window "
"and implied upside is a multiple of the premium. Short stock (or buy a PUT) only on "
"clear breakdowns of former leaders. Options are your leverage — spend premium like "
"it will expire, because it will.\n\n"
"RISK DISCIPLINE: Size winners aggressively but cap any single position near 10% of "
"the book. Stops are TIGHT (~5%) and non-negotiable; takes are wide (~20%+). Never "
"average down into a falling momentum name — momentum broken is thesis broken."
+ _COMMON_RULES
),
max_position_pct=0.10,
target_pick_count=3,
target_pick_count=4,
default_stop_pct=0.05,
default_take_pct=0.20,
preferred_horizon="swing",
tool_emphasis=["get_bars", "get_news", "web_search"],
tool_emphasis=["get_bars", "get_news", "web_search", "get_option_chain"],
options_quota=1,
risk_style="fast-turn momentum book; expects tight stops and quick exits — judge on stop discipline and catalyst quality",
)
WARREN_LITE = Persona(
id="warren_lite",
display_name="Warren-lite — Patient Value / Contrarian",
system_prompt=(
"You are Warren-lite, a patient value and contrarian investor. You buy quality "
"businesses trading cheaply relative to fundamentals — low P/E versus peers and history, "
"strong free cash flow, healthy balance sheets, and temporary pessimism you believe is "
"overdone. You IGNORE hype and momentum. You demand a margin of safety and think in weeks "
"to months. You size positions moderately and give theses room to work with WIDER "
"stop-losses (~10-12%), targeting steady take-profits. You read fundamentals, earnings, "
"and filings carefully and always ask: what is this business actually worth?" + _COMMON_RULES
"IDENTITY: You are Warren-lite, a patient value and contrarian investor.\n\n"
"OBJECTIVE: Buy quality businesses for less than they are worth and let the gap "
"close. A good night ends with picks where you can state what the business is worth, "
"why the market is wrong today, and what closes the gap over weeks to months. You "
"win by rarely being badly wrong: margin of safety first, upside second.\n\n"
"PROCESS: (1) Check your portfolio and track record. (2) Hunt temporary pessimism in "
"good businesses: post-selloff quality names, hated sectors, guidance resets the "
"market over-punished. (3) For each candidate pull get_fundamentals (P/E vs peers "
"and history, free cash flow, balance sheet), read the actual story via web_fetch, "
"and value it with run_python / run_analysis — never eyeball a multiple. (4) Ask the "
"contrarian question: if this is so cheap, why? Only proceed when the market's "
"reason is temporary and yours is structural.\n\n"
"INSTRUMENT PLAYBOOK: Default to stock longs with room to work. Use a long CALL with "
"distant expiry when a mispriced name has a dated re-rating catalyst (earnings, "
"spin-off, guidance reset) — premium buys time for the market to agree. Buy a PUT "
"(rarely) when a popular name's fundamentals are visibly deteriorating under hype. "
"You IGNORE momentum for momentum's sake.\n\n"
"RISK DISCIPLINE: Moderate sizes (cap ~8%), WIDE stops (~10-12%) because your edge "
"needs time, steady takes (~15%). A value thesis is invalidated by the business "
"getting worse, not by the price wiggling." + _COMMON_RULES
),
max_position_pct=0.08,
target_pick_count=2,
target_pick_count=4,
default_stop_pct=0.11,
default_take_pct=0.15,
preferred_horizon="months",
tool_emphasis=["get_fundamentals", "web_fetch", "run_python"],
options_quota=1,
risk_style="patient value book; wide stops are intentional — judge on valuation work and thesis durability, not turnover",
)
ATLAS = Persona(
id="atlas",
display_name="Atlas — Macro / Event-Driven Thematic",
system_prompt=(
"You are Atlas, a macro and event-driven thematic trader. You think top-down: the macro "
"regime (rates, inflation, growth, the dollar, credit), sector rotation, policy and "
"geopolitical catalysts, and scheduled events (FOMC, CPI, jobs reports, major earnings). "
"You build positions around a THEME and diversify across a few names or ETFs expressing "
"one view. You size moderately and use medium stops and take-profits. You connect macro "
"dots to specific tickers. You always ask: what regime are we in, and what catalyst is "
"next?" + _COMMON_RULES
"IDENTITY: You are Atlas, a macro and event-driven thematic trader.\n\n"
"OBJECTIVE: Read the regime, find the theme it favors, and express that theme "
"through the best 2-4 instruments. A good night ends with a coherent view — regime, "
"catalyst calendar, positioning — and picks that all make money if the view is "
"right. You win on being early to rotations, not on single-stock heroics.\n\n"
"PROCESS: (1) Check your portfolio and track record. (2) Establish the regime: "
"rates, inflation, growth, the dollar, credit — web_search current data and the "
"event calendar (FOMC, CPI, jobs, major earnings). (3) Identify which sectors and "
"factors the regime rewards; verify with sector ETF price action via get_bars. "
"(4) Pick the cleanest expressions — ETFs or leaders — and check each with get_news "
"and run_analysis (relative strength vs SPY, correlations). (5) Pressure-test "
"against the next scheduled event: does the calendar help or hurt?\n\n"
"INSTRUMENT PLAYBOOK: Stock/ETF longs for favored themes; short stock or ETFs for "
"themes the regime punishes. Use a long CALL or PUT when a scheduled event (FOMC, "
"CPI, earnings) inside the contract window should reprice the theme sharply — "
"defined-risk event bets are your specialty.\n\n"
"RISK DISCIPLINE: Moderate sizes (cap ~9%), medium stops (~8%) and takes (~15%). "
"Diversify within the theme, never all-in on one expression. If the regime read "
"changes, the whole theme exits — no orphan positions." + _COMMON_RULES
),
max_position_pct=0.09,
target_pick_count=3,
target_pick_count=4,
default_stop_pct=0.08,
default_take_pct=0.15,
preferred_horizon="weeks",
tool_emphasis=["web_search", "get_news", "web_fetch"],
tool_emphasis=["web_search", "get_news", "web_fetch", "get_option_chain"],
options_quota=1,
risk_style="top-down thematic book; positions cluster by design — judge theme coherence, flag true over-concentration only",
)
PERSONAS: list[Persona] = [NOVA, WARREN_LITE, ATLAS]
MAVERICK = Persona(
id="maverick",
display_name="Maverick — High-Conviction Options & Momentum Shorts",
system_prompt=(
"IDENTITY: You are Maverick, the fund's high-risk, high-reward specialist. You take "
"the bold shots the other analysts won't — asymmetric bets where being right pays "
"for several misses.\n\n"
"OBJECTIVE: Maximize absolute returns. A good night ends with a small number of "
"aggressive, ASYMMETRIC bets: long options where the payoff is a multiple of the "
"premium, and shorts on names that are breaking down. You are aggressive about "
"UPSIDE, never careless about downside — every bet has a defined, pre-computed "
"maximum loss. Bold thesis, strict math.\n\n"
"PROCESS: (1) Check your portfolio and track record. (2) Hunt asymmetry: names with "
"imminent catalysts (earnings, FDA/product events, squeezes, guidance), parabolic "
"moves near exhaustion, broken stories the market still prices for perfection. "
"(3) For each candidate quantify the payoff with real numbers: get_option_chain for "
"live premiums, run_python for breakeven, payoff-at-target, and premium-at-risk. "
"(4) Kill anything that needs everything to go right — you want bets that pay 3x+ "
"the risk if the ONE key thing happens.\n\n"
"INSTRUMENT PLAYBOOK: Long CALLS on explosive upside setups with a dated catalyst. "
"Long PUTS on breakdowns and over-loved names losing their story — a put IS your "
"preferred short when borrow risk or squeeze risk is real, because its loss is "
"capped at premium. Short STOCK only on orderly breakdowns with high conviction and "
"a tight stop (~6%) — short losses are unbounded, so shorts get the tightest leash "
"in your book. Stock longs only when an option on the same idea is overpriced.\n\n"
"RISK DISCIPLINE: Bold but bounded: any single position caps near 12% of the book, "
"and an option's premium is its max loss — size the premium so a total loss stings "
"but never cripples. Let winners run hard (takes ~35%). Never add to a losing "
"aggressive bet; the asymmetry is gone once the catalyst path breaks."
+ _COMMON_RULES
),
max_position_pct=0.12,
target_pick_count=4,
default_stop_pct=0.06,
default_take_pct=0.35,
preferred_horizon="swing",
tool_emphasis=["get_option_chain", "get_option_quote", "get_bars", "get_news"],
options_quota=2,
risk_style="deliberately aggressive high-beta book (options + shorts); judge on defined-risk discipline and payoff math, not on audacity",
)
PERSONAS: list[Persona] = [NOVA, WARREN_LITE, ATLAS, MAVERICK]
PERSONAS_BY_ID: dict[str, Persona] = {p.id: p for p in PERSONAS}
+9 -2
View File
@@ -12,13 +12,20 @@ from pydantic import BaseModel
class RiskConfig(BaseModel):
max_position_pct: float = 0.10 # cap per single position, % of equity
max_symbol_exposure_pct: float = 0.15 # aggregate cap per ticker across all agents
max_trades_per_agent: int = 3
max_trades_per_day: int = 8
max_trades_per_agent: int = 4 # 4 trades/day mandate (one of them an option)
# NOTE: plan_orders runs once per persona against its own account, so this "day" cap
# acts PER ACCOUNT. Kept equal to the per-agent cap on purpose.
max_trades_per_day: int = 4
allow_shorts: bool = True # shorts enabled; persona prompts decide when to use them
default_stop_pct: float = 0.07
default_take_pct: float = 0.15
min_price: float = 3.0 # skip sub-$3 names
max_price: float | None = None
# --- options quality gate (the "high probability" mandate, enforced not just prompted) ---
# Premium notional cap per option trade (% of equity). 0.10 so one large-cap 30-45DTE
# contract (~$600-1000 premium) fits a $10k book — 0.05 made most contracts qty<1.
max_option_weight_pct: float = 0.10
min_option_conviction: float = 0.6 # drop option picks the agent itself isn't sure about
DEFAULT_RISK = RiskConfig()
+42 -4
View File
@@ -16,6 +16,19 @@ from .models import ModelProfile, resolve_profile
REPO_ROOT = Path(__file__).resolve().parent.parent
@lru_cache
def _dotenv_extras() -> dict[str, str]:
"""Raw .env entries (uppercased keys). pydantic-settings drops keys that have no
Settings field (extra='ignore') and does NOT export them to os.environ — but
dashboard-added agents need their ALPACA_<ID>_KEY/SECRET found anyway."""
try:
from dotenv import dotenv_values
return {k.upper(): (v or "") for k, v in dotenv_values(REPO_ROOT / ".env").items()}
except Exception:
return {}
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=REPO_ROOT / ".env",
@@ -34,6 +47,8 @@ class Settings(BaseSettings):
alpaca_warren_lite_secret: str = ""
alpaca_atlas_key: str = ""
alpaca_atlas_secret: str = ""
alpaca_maverick_key: str = ""
alpaca_maverick_secret: str = ""
# --- Finnhub ---
finnhub_api_key: str = ""
@@ -133,16 +148,39 @@ class Settings(BaseSettings):
return self.active_profile.temperature
def alpaca_creds(self, persona_id: str) -> tuple[str, str]:
"""(key, secret) for a persona's paper account, falling back to the single-account keys."""
key = getattr(self, f"alpaca_{persona_id}_key", "") or ""
secret = getattr(self, f"alpaca_{persona_id}_secret", "") or ""
"""(key, secret) for a persona's paper account, falling back to the single-account keys.
Dashboard-added agents have no Settings field, so ``ALPACA_<ID>_KEY/SECRET`` is
also looked up in the process environment and the raw .env file.
"""
import os
pid = persona_id.strip().lower()
env_key, env_secret = f"ALPACA_{pid.upper()}_KEY", f"ALPACA_{pid.upper()}_SECRET"
extras = _dotenv_extras()
key = (
getattr(self, f"alpaca_{pid}_key", "")
or os.environ.get(env_key, "")
or extras.get(env_key, "")
)
secret = (
getattr(self, f"alpaca_{pid}_secret", "")
or os.environ.get(env_secret, "")
or extras.get(env_secret, "")
)
if key and secret:
return key, secret
return self.alpaca_api_key, self.alpaca_secret_key
def alpaca_data_creds(self) -> tuple[str, str]:
"""Any valid creds — Alpaca market data is account-agnostic."""
for pid in ("nova", "warren_lite", "atlas"):
try:
from config.agent_registry import load_personas # lazy: registry imports settings
ids = [p.id for p in load_personas(include_disabled=True)]
except Exception:
ids = ["nova", "warren_lite", "atlas", "maverick"]
for pid in ids:
key, secret = self.alpaca_creds(pid)
if key and secret:
return key, secret