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

+72 -34
View File
@@ -1,11 +1,15 @@
"""get_portfolio — persona-scoped. Each agent sees ONLY its own paper account's holdings
(written by execution/reconcile.py, keyed by persona id), so picks build on that book.
Built per-persona via make_get_portfolio() in tools/build_toolset.
"""get_portfolio — persona-scoped. Each agent sees ONLY its own paper account.
LIVE-first: pulls the account + positions straight from Alpaca at call time, so overnight
research reflects intraday bracket exits (a stop/take that filled at 2pm is gone from the
book by night). Falls back to the last reconciled DB snapshot (execution/reconcile.py)
if Alpaca is unreachable — clearly labeled as cached so the agent knows the freshness.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from langchain_core.tools import tool
@@ -13,44 +17,78 @@ from config.settings import get_settings
from state import db as statedb
def _live_portfolio(account: str) -> str | None:
"""Live account + positions from Alpaca. None if unreachable/unconfigured."""
try:
from execution.broker import for_persona
b = for_persona(account)
acct = b.get_account()
positions = b.get_positions()
except Exception:
return None
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines = [
f"LIVE from Alpaca as of {now}: equity=${float(acct.equity):,.2f}, "
f"cash=${float(acct.cash):,.2f}, buying_power=${float(acct.buying_power):,.2f}"
]
if not positions:
lines.append("Holdings: none (all cash).")
else:
lines.append("Holdings:")
for p in positions:
lines.append(
f" {p.symbol}: {p.qty} @ {p.avg_entry_price} "
f"-> mv={p.market_value}, unrealized_pl={p.unrealized_pl}"
)
return "\n".join(lines)
def _snapshot_portfolio(account: str) -> str:
"""Last reconciled DB snapshot — the offline fallback."""
path = get_settings().db_path
if not path.exists():
return "No portfolio data yet (fresh account, no trades recorded)."
try:
con = statedb.connect(path)
snap = statedb.latest_snapshot(con, account or None)
con.close()
except Exception as e:
return f"No portfolio data yet ({e})."
if snap is None:
return "No holdings yet (fresh account, all cash)."
lines = [
f"CACHED snapshot from {snap['ts']} (live fetch unavailable — intraday fills after "
f"that time are NOT reflected): equity=${snap['equity']:,.2f}, "
f"cash=${snap['cash']:,.2f}, buying_power=${snap['buying_power']:,.2f}"
]
try:
positions = json.loads(snap["positions_json"] or "[]")
except Exception:
positions = []
if not positions:
lines.append("Holdings: none (all cash).")
else:
lines.append("Holdings:")
for p in positions:
lines.append(
f" {p.get('symbol')}: {p.get('qty')} @ {p.get('avg_entry')} "
f"-> mv={p.get('market_value')}, unrealized_pl={p.get('unrealized_pl')}"
)
return "\n".join(lines)
def make_get_portfolio(account: str):
"""Return a get_portfolio tool bound to one account (persona id)."""
@tool
def get_portfolio() -> str:
"""Return YOUR current paper-account holdings, unrealized PnL, and cash.
"""Return YOUR current paper-account holdings, unrealized PnL, and cash
(live from Alpaca; falls back to the last recorded snapshot if offline).
Use this BEFORE proposing trades so you account for what you already own.
"""
path = get_settings().db_path
if not path.exists():
return "No portfolio data yet (fresh account, no trades recorded)."
try:
con = statedb.connect(path)
snap = statedb.latest_snapshot(con, account or None)
con.close()
except Exception as e:
return f"No portfolio data yet ({e})."
if snap is None:
return "No holdings yet (fresh account, all cash)."
lines = [
f"As of {snap['ts']}: equity=${snap['equity']:,.2f}, "
f"cash=${snap['cash']:,.2f}, buying_power=${snap['buying_power']:,.2f}"
]
try:
positions = json.loads(snap["positions_json"] or "[]")
except Exception:
positions = []
if not positions:
lines.append("Holdings: none (all cash).")
else:
lines.append("Holdings:")
for p in positions:
lines.append(
f" {p.get('symbol')}: {p.get('qty')} @ {p.get('avg_entry')} "
f"-> mv={p.get('market_value')}, unrealized_pl={p.get('unrealized_pl')}"
)
return "\n".join(lines)
return _live_portfolio(account) or _snapshot_portfolio(account)
return get_portfolio
+3 -2
View File
@@ -19,8 +19,9 @@ _TIMEOUT_S = 5
def run_python(code: str) -> str:
"""Execute a short Python snippet for EXACT math and return its stdout.
Use for ANY arithmetic: position sizing, percentages, P/E, returns, averages, ratios.
You MUST print() the result. `math` and `statistics` are importable.
Use for ANY arithmetic: position sizing, percentages, P/E, returns, averages, ratios,
option breakevens. STANDARD LIBRARY ONLY (`math`, `statistics`) — NO pandas, numpy, or
yfinance here; for data analysis use run_analysis instead. You MUST print() the result.
Example: `print(round(1000 / 23.55, 4))`. No access to secrets; 5s limit.
"""
from tools.sandbox import scrubbed_env
+57 -7
View File
@@ -26,6 +26,44 @@ _STDOUT_LIMIT = 4000
_SECRET_PREFIXES = ("ALPACA", "FINNHUB", "OMLX", "EMAIL", "SMTP", "OPENAI", "ANTHROPIC")
_ARTIFACT_EXTS = {".png", ".jpg", ".jpeg", ".svg", ".csv", ".txt", ".html", ".md", ".json"}
_AVAILABLE = "pandas, numpy, yfinance, matplotlib (Agg), scipy + the standard library"
# Common failure -> actionable hint, appended to the ERROR the agent reads back. Turns the
# usual retry-loop ("same error again") into a one-shot self-correction.
_ERROR_HINTS: list[tuple[str, str]] = [
("ModuleNotFoundError", f"HINT: only {_AVAILABLE} are installed — rewrite using those."),
(
"KeyError",
"HINT: yf.download() returns MultiIndex columns by default. Flatten first: "
"df.columns = df.columns.get_level_values(0) — or download with "
"yf.download(ticker, period=..., multi_level_index=False).",
),
(
"FileNotFoundError",
"HINT: each run_analysis call starts in a FRESH empty directory — files from earlier "
"calls are gone. Make the script self-contained (re-download data in this script).",
),
(
"NameError",
"HINT: nothing persists between run_analysis calls — define every variable and import "
"in THIS script.",
),
(
"JSONDecodeError",
"HINT: a data request likely returned empty/error content — print the raw response "
"first, and add a fallback ticker/period.",
),
]
def _hint_for(stderr: str) -> str:
for needle, hint in _ERROR_HINTS:
if needle in stderr:
return "\n" + hint
if "No data found" in stderr or "no price data" in stderr.lower():
return "\nHINT: yfinance returned no data — check the ticker symbol and use a simple period like '6mo'."
return ""
def scrubbed_env(home: str) -> dict[str, str]:
"""A minimal environment with NO secrets — built from scratch, not inherited."""
@@ -45,14 +83,24 @@ def scrubbed_env(home: str) -> dict[str, str]:
def run_analysis(code: str) -> str:
"""Run a Python analysis script and return its stdout plus any files it wrote.
Available: pandas, numpy, yfinance (data), matplotlib (charts, Agg), scipy. Use this like a
real analyst: fetch data with yfinance, compute indicators/stats/backtests, and save charts
with `plt.savefig("chart.png")`. You MUST print() anything you want to read back. Runs in an
isolated temp dir with a ~90s limit and NO access to account secrets.
ENVIRONMENT — read once, avoid the classic errors:
- Installed: pandas, numpy, yfinance (market data), matplotlib (charts, headless Agg),
scipy, plus the standard library. Nothing else — no requests/sklearn/talib.
- Each call runs in a FRESH EMPTY temp directory. Files and variables do NOT persist
between calls: every script must be fully self-contained (imports + data download +
computation in one script).
- You MUST print() every value you want to read back; save charts with
plt.savefig("name.png") (saved files are listed in the reply).
- ~90s wall-clock limit; no access to account secrets.
- yfinance pitfalls: yf.download() returns MultiIndex columns — flatten with
`df.columns = df.columns.get_level_values(0)` (or pass multi_level_index=False);
it also auto-adjusts prices by default (auto_adjust=True).
Use it like a real analyst: fetch data, compute indicators/stats/backtests, save charts.
Example:
import yfinance as yf, matplotlib.pyplot as plt
df = yf.download("AAPL", period="6mo")
df = yf.download("AAPL", period="6mo", multi_level_index=False)
df["SMA50"] = df["Close"].rolling(50).mean()
print(df[["Close","SMA50"]].tail())
df[["Close","SMA50"]].plot(); plt.savefig("aapl.png")
@@ -89,8 +137,10 @@ def run_analysis(code: str) -> str:
]
if proc.returncode != 0:
return f"ERROR (exit {proc.returncode}): {err[-1500:] or 'no stderr'}"
result = out[:_STDOUT_LIMIT] or "(no stdout — did you print()?)"
return f"ERROR (exit {proc.returncode}): {err[-1500:] or 'no stderr'}{_hint_for(err)}"
result = out[:_STDOUT_LIMIT] or (
"(no stdout — you must print() every value you want to read back)"
)
if len(out) > _STDOUT_LIMIT:
result += "\n...[truncated]"
if err: