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

+20
View File
@@ -0,0 +1,20 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dashboard",
"runtimeExecutable": "uv",
"runtimeArgs": [
"run",
"streamlit",
"run",
"web/dashboard.py",
"--server.headless",
"true",
"--server.port",
"8599"
],
"port": 8599
}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(sysctl -n hw.memsize)",
"Bash(awk '{print \"RAM bytes:\", $1, \"=>\", $1/1024/1024/1024 \"GB\"}')",
"Bash(sysctl -n machdep.cpu.brand_string)",
"Bash(sysctl -n hw.model)",
"Read(//Users/aryan/.local/share/uv/python/cpython-3.11.15-macos-aarch64-none/bin/**)",
"Bash(/Users/aryan/.local/bin/python3.11 --version)",
"Bash(systemsetup -gettimezone)",
"Bash(uv --version)",
"Bash(uv python *)"
]
}
}
+1
View File
@@ -9,6 +9,7 @@ __pycache__/
.ruff_cache/ .ruff_cache/
# runtime state (keep dirs via .gitkeep, ignore contents) # runtime state (keep dirs via .gitkeep, ignore contents)
state/agents.json
state/trading.db state/trading.db
state/*.db state/*.db
state/output/ state/output/
+27 -15
View File
@@ -1,11 +1,12 @@
# Overnight Local-LLM Paper-Trading Agent # Overnight Local-LLM Paper-Trading Agent
Every night the Mac wakes itself, runs **3 trader personas** one at a time against **one local LLM** Every night the Mac wakes itself, runs **4 trader personas** one at a time against **one local LLM**
(gpt-oss-20b via [oMLX](https://omlx.ai), an Apple-MLX inference server). Each persona researches the (gpt-oss-20b via [oMLX](https://omlx.ai), an Apple-MLX inference server). Each persona researches the
web + live market data, forms a stock thesis, and writes JSON. In the morning at market open a web + live market data, forms up to **4 trade theses — at least one of them an options trade** —
separate job reads that JSON and places and writes JSON. In the morning at market open a separate job reads that JSON and places
**Alpaca paper trades** with programmatic risk guardrails. Everything is visible in a local **Alpaca paper trades** with programmatic risk guardrails. Everything is visible in a local
Streamlit dashboard. Streamlit dashboard, including an **Agents tab** that manages the roster (add/edit/disable agents,
per-agent research budgets) via `state/agents.json` — no code edits needed.
> **Paper trading only.** Fake money, real prices. "The build works" ≠ "it's profitable" — the > **Paper trading only.** Fake money, real prices. "The build works" ≠ "it's profitable" — the
> quality of a local model's theses is the open question you tune over time. > quality of a local model's theses is the open question you tune over time.
@@ -38,23 +39,33 @@ Switch models any time with one line in `.env`: `MODEL_PROFILE=gpt_oss` or `qwen
## Entry points ## Entry points
| Command | What it does | | Command | What it does |
|---|---| |---|---|
| `uv run python -m orchestration.run_nightly` | Nightly research run (3 personas → JSON). `--compressed` for a fast test | | `uv run python -m orchestration.run_nightly` | Nightly research run (all enabled agents → JSON). `--compressed` for a fast test |
| `uv run python -m execution.run_market_open` | Morning: JSON → guardrails → Alpaca paper orders. `--force` to test while closed | | `uv run python -m execution.run_market_open` | Morning: JSON → guardrails → Alpaca paper orders. `--force` to test while closed |
| `uv run streamlit run web/dashboard.py` | Dashboard: positions, PnL, theses, orders, logs | | `uv run streamlit run web/dashboard.py` | Dashboard: positions, PnL, theses, orders, logs |
| `uv run python -m reporting.report` | CLI summary | | `uv run python -m reporting.report` | CLI summary |
## How it works ## How it works
`run_nightly` starts oMLX, then runs Nova → Warren-lite → Atlas **sequentially** (one model, `run_nightly` starts oMLX, then runs Nova → Warren-lite → Atlas → Maverick **sequentially** (one
time-sliced with rollover budgets). Each agent researches with tools (`run_python` for all math, model, time-sliced with rollover budgets; per-agent budgets are dashboard-editable). The roster
web search/fetch, Alpaca/Finnhub/yfinance market data, `get_portfolio` for continuity) and its comes from `config/agent_registry.py` (built-ins merged with `state/agents.json`). Each agent
output is checkpointed incrementally to `state/output/<date>/`. A min-steps nudge keeps agents researches with tools (`run_python` for math, `run_analysis` for data/charts, web search/fetch,
researching broadly instead of stopping early. Alpaca/Finnhub/yfinance market data, options chains, `get_portfolio` for continuity) and its
output is checkpointed incrementally to `state/output/<date>/`. Nudges keep agents researching
broadly, steer them to the **options mandate** (each night's picks must include a researched
single-leg option), and converge them near the end of their budget.
**Each persona trades its own Alpaca paper account** (Nova→agent1, Warren-lite→agent2, Atlas→agent3), **Each persona trades its own Alpaca paper account** (Nova→agent1, Warren-lite→agent2,
so the three books — and their PnL — stay separate and comparable. `run_market_open` gates on Atlas→agent3, Maverick→agent4 — Maverick is the deliberately aggressive options/shorts book),
so the books — and their PnL — stay separate and comparable. `run_market_open` gates on
Alpaca's clock, then per account applies `execution/guardrails.py` (position/day caps, sizing, Alpaca's clock, then per account applies `execution/guardrails.py` (position/day caps, sizing,
buying-power) and places **GTC bracket** orders (marketable-limit entry so stop/take legs survive buying-power, an options conviction floor + premium cap, and a reserved slot so the mandated
multi-day holds). option trade isn't trimmed) and places **GTC bracket** orders (marketable-limit entry so
stop/take legs survive multi-day holds).
> **Adding an agent:** use the dashboard's 🤖 Agents tab (clone a template, tweak knobs), create
> a fresh Alpaca paper account for it, enable **options trading (level 2)** on that account, and
> add `ALPACA_<ID>_KEY` / `ALPACA_<ID>_SECRET` to `.env`. Maverick ships built-in but still needs
> its own account keys the same way.
### The differentiating layer ### The differentiating layer
- **Market regime snapshot** — SPY trend / VIX / sector momentum computed in code each night and - **Market regime snapshot** — SPY trend / VIX / sector momentum computed in code each night and
@@ -66,7 +77,8 @@ multi-day holds).
the Reports tab and the morning executor **enforces** the verdicts. the Reports tab and the morning executor **enforces** the verdicts.
## Automate it (America/Chicago) ## Automate it (America/Chicago)
- **00:00–07:00** research (~2.3 hr/agent), machine woken at **23:55**. - **00:00–07:00** research (fair-share time-slicing: ~105 min/agent with 4 agents; tune
per-agent budgets from the dashboard), machine woken at **23:55**.
- **08:30** execution (= 09:30 ET open; gated on Alpaca's clock, never local time). - **08:30** execution (= 09:30 ET open; gated on Alpaca's clock, never local time).
```bash ```bash
+123 -26
View File
@@ -18,7 +18,7 @@ from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from loguru import logger from loguru import logger
from agents import compaction from agents import compaction
from agents.schemas import AgentOutput, AgentStatus, Pick from agents.schemas import AgentOutput, AgentStatus, Instrument, Pick
from config.personas import Persona from config.personas import Persona
from config.settings import get_settings from config.settings import get_settings
from llm.client import build_llm from llm.client import build_llm
@@ -26,7 +26,7 @@ from tools import build_toolset
RESEARCH_KICKOFF = ( RESEARCH_KICKOFF = (
"Today is {date}. This is DEEP overnight research — be exhaustive, not a quick scan. " "Today is {date}. This is DEEP overnight research — be exhaustive, not a quick scan. "
"Choose {n} strong stocks that fit your style.\n\n" "Choose {n} strong picks that fit your style.\n\n"
"1. Call get_portfolio first (your current holdings).\n" "1. Call get_portfolio first (your current holdings).\n"
"2. Build a candidate list of 8-12 tickers in your style, then investigate EACH one: " "2. Build a candidate list of 8-12 tickers in your style, then investigate EACH one: "
"get_quote, get_bars (trend/momentum), get_news (catalysts), get_fundamentals " "get_quote, get_bars (trend/momentum), get_news (catalysts), get_fundamentals "
@@ -41,23 +41,67 @@ RESEARCH_KICKOFF = (
"call get_option_chain(underlying, 'call' or 'put') and copy an exact contract_symbol." "call get_option_chain(underlying, 'call' or 'put') and copy an exact contract_symbol."
) )
# Appended to the kickoff when the persona has an options quota (all built-ins do).
OPTIONS_MANDATE = (
"\n\nOPTIONS MANDATE — {opt} of your {n} picks MUST be a single-leg option trade, and it "
"must be your HIGHEST-probability idea, not an afterthought:\n"
"- Take your single strongest thesis and express it as a CALL (bullish) or PUT (bearish).\n"
"- Pull get_option_chain for it and evaluate 2-3 contracts: premium, bid/ask spread, "
"~30-45 days to expiration.\n"
"- Use run_python to compute the breakeven price and the premium at risk. The underlying "
"must have a specific catalyst INSIDE the contract window.\n"
"- BUDGET CHECK: one contract costs premium x 100, and the risk system caps an option "
"trade near 10% of your account equity (check equity via get_portfolio). If your favorite "
"contract doesn't fit, choose a different expression that does — further strike, nearer "
"expiry, or a cheaper underlying — your call based on the market.\n"
"- Only submit an option pick you'd give conviction >= 0.7 after pressure-testing; "
"low-conviction option picks are dropped by the risk system and waste a trade slot."
)
# Appended to every kickoff: how the two Python tools differ and how the sandbox behaves.
# Most historical agent errors were sandbox misuse (pandas in run_python, expecting files to
# persist, forgetting print) — this onboarding block prevents them up front.
SANDBOX_GUIDE = (
"\n\nPYTHON TOOLS GUIDE:\n"
"- run_python: quick exact math ONLY (stdlib: math, statistics). No pandas/yfinance here.\n"
"- run_analysis: full scripts with pandas, numpy, yfinance, matplotlib (Agg), scipy. Each "
"call runs in a FRESH empty directory — files do NOT persist between calls, so every "
"script must be self-contained (re-download data each time).\n"
"- Both: you MUST print() anything you want to see; save charts with plt.savefig('name.png')."
)
# Rotating directive nudges — cycled so a long (~2hr) research session stays PRODUCTIVE # Rotating directive nudges — cycled so a long (~2hr) research session stays PRODUCTIVE
# (does new, varied work) instead of looping "am I done?". Keep using tools. # (does new, varied work) instead of looping "am I done?". Phrased as coverage guidance,
# not commands — the agent keeps ownership of the plan and only fills genuine gaps.
DEEPEN_NUDGES = [ DEEPEN_NUDGES = [
"Not done yet. Screen 5-10 MORE candidate tickers in your style you haven't examined; pull " "There's time left — where is your coverage thinnest? If you've examined fewer than 8-10 "
"get_bars / get_news / get_fundamentals on them.", "candidates, screen a few MORE tickers in your style (get_bars / get_news / get_fundamentals).",
"Build the BEAR case for your current top pick: what would make it wrong? Find data that " "If you haven't yet stress-tested your favorite idea: build the BEAR case for it. What would "
"challenges your thesis (web_search + web_fetch recent coverage).", "make it wrong? Look for data that challenges the thesis (web_search + web_fetch).",
"Use run_analysis to write a real script: fetch prices with yfinance, compute momentum / " "Consider hard numbers for your leading idea: a run_analysis script (yfinance download, "
"moving averages / volatility / correlations, and save a chart to back your best idea.", "momentum / moving averages / volatility) is far more convincing than a headline.",
"Zoom out: check the macro + sector context (rates, the sector ETF, peers) via web_search and " "Have you checked the bigger picture? Macro + sector context (rates, the sector ETF, peers) "
"get_bars. Does it support or undercut your picks?", "via web_search and get_bars can confirm or undercut your picks.",
"Compare your leading picks head-to-head against 2-3 sector peers on valuation and momentum " "If your top pick hasn't been compared to alternatives: line it up against 2-3 sector peers "
"(get_fundamentals + run_analysis). Are you picking the best expression of the thesis?", "on valuation and momentum. Are you holding the best expression of the thesis?",
"Re-examine catalysts and timing for each candidate (earnings dates, events via get_news / " "Check timing if you haven't: catalysts and dates (earnings, events via get_news / "
"web_search) and refine entry, stop, and take-profit levels with run_python.", "web_search), then refine entry, stop, and take-profit levels with run_python.",
] ]
# Takes priority over the rotation while the options mandate has had NO research yet.
OPTIONS_NUDGE = (
"Before finalizing, remember the options mandate: your strongest thesis must also be "
"expressed as a single-leg option. Call get_option_chain on your best idea, compare 2-3 "
"contracts (premium, spread, ~30-45 DTE), and compute breakeven with run_python."
)
# Selected in the final stretch of the time floor: converge instead of adding breadth.
CONSOLIDATE_NUDGE = (
"You're in the final stretch. Stop adding new names — consolidate: rank your candidates, "
"confirm entry/stop/take numbers with run_python, and double-check the catalyst dates for "
"the picks you intend to submit."
)
FINALIZE_PROMPT = ( FINALIZE_PROMPT = (
"Research complete. Return a JSON object of the form {{\"picks\": [ ... ]}} containing about " "Research complete. Return a JSON object of the form {{\"picks\": [ ... ]}} containing about "
"{n} picks. Each pick object has these fields:\n" "{n} picks. Each pick object has these fields:\n"
@@ -200,34 +244,75 @@ def parse_picks(text: str, default_stop: float, default_take: float) -> tuple[li
return picks, errors return picks, errors
def _count_option_picks(picks: list[Pick]) -> int:
return sum(1 for p in picks if p.instrument == Instrument.OPTION)
def _finalize(persona: Persona, running_summary: str, reasoning_trace: list[str]): def _finalize(persona: Persona, running_summary: str, reasoning_trace: list[str]):
"""Ask the model for structured picks using a clean finalize context (not the persona's """Ask the model for structured picks using a clean finalize context (not the persona's
tool-pushing prompt), fed the research findings we gathered. Retries once.""" tool-pushing prompt), fed the research findings we gathered. Retries once on a parse
failure, and once more if the options quota is unmet and the trace actually contains
option-chain data to draw from. Fail-open: an unmet quota never discards the night."""
findings = "\n".join(reasoning_trace[-50:]) findings = "\n".join(reasoning_trace[-50:])
if running_summary: if running_summary:
findings = f"Summary:\n{running_summary}\n\nDetails:\n{findings}" findings = f"Summary:\n{running_summary}\n\nDetails:\n{findings}"
quota = persona.options_quota
option_line = (
f"\nAt least {quota} pick(s) must be OPTION picks (instrument \"option\") — your "
"highest-conviction thesis expressed as a call or put, using an exact contract_symbol "
"copied from your get_option_chain research. If your findings contain NO real contract "
"symbol from get_option_chain, do NOT fabricate one — submit stock picks instead; an "
"invented symbol is rejected and wastes the pick." if quota > 0 else ""
)
human = ( human = (
f"You are {persona.display_name}. Choose picks that fit your style " f"You are {persona.display_name}. Choose picks that fit your style "
f"(typical stop ~{persona.default_stop_pct * 100:.0f}%, " f"(typical stop ~{persona.default_stop_pct * 100:.0f}%, "
f"take ~{persona.default_take_pct * 100:.0f}%, horizon {persona.preferred_horizon}).\n\n" f"take ~{persona.default_take_pct * 100:.0f}%, horizon {persona.preferred_horizon}).\n\n"
f"RESEARCH FINDINGS:\n{findings or '(sparse — use your best judgment)'}\n\n" f"RESEARCH FINDINGS:\n{findings or '(sparse — use your best judgment)'}\n\n"
f"{FINALIZE_PROMPT.format(n=persona.target_pick_count)}" f"{FINALIZE_PROMPT.format(n=persona.target_pick_count)}{option_line}"
) )
llm = build_llm(max_tokens=4096) # no tools, no json_mode (json_mode returns empty on oMLX) llm = build_llm(max_tokens=4096) # no tools, no json_mode (json_mode returns empty on oMLX)
def _ask(extra: str = "") -> tuple[list[Pick], list[str]]:
resp = llm.invoke(
[SystemMessage(content=FINALIZE_SYSTEM), HumanMessage(content=human + extra)]
)
content = resp.content if isinstance(resp.content, str) else str(resp.content)
return parse_picks(content, persona.default_stop_pct, persona.default_take_pct)
errors: list[str] = [] errors: list[str] = []
picks: list[Pick] = []
for _ in range(2): for _ in range(2):
try: try:
resp = llm.invoke( picks, errs = _ask()
[SystemMessage(content=FINALIZE_SYSTEM), HumanMessage(content=human)]
)
content = resp.content if isinstance(resp.content, str) else str(resp.content)
picks, errs = parse_picks(content, persona.default_stop_pct, persona.default_take_pct)
if picks: if picks:
return picks, errs errors = errs
break
errors = errs errors = errs
except Exception as e: except Exception as e:
errors = [f"finalize llm error: {e}"] errors = [f"finalize llm error: {e}"]
return [], errors if not picks:
return [], errors
# options quota: one corrective retry, only if the trace has real contracts to use
if quota > 0 and _count_option_picks(picks) < quota:
trace_has_chain = any("get_option_chain" in line for line in reasoning_trace)
if trace_has_chain:
try:
retry_picks, retry_errs = _ask(
f"\n\nYour previous answer had too few option picks ({_count_option_picks(picks)}"
f"/{quota} required). Re-issue ALL picks, converting your strongest thesis into "
"a single-leg option pick using an exact contract_symbol that appears in the "
"research findings. Do not invent contract symbols."
)
if retry_picks and _count_option_picks(retry_picks) > _count_option_picks(picks):
return retry_picks, retry_errs
except Exception as e:
errors.append(f"options-quota retry failed: {e}")
errors.append(
f"options mandate unmet: {_count_option_picks(picks)}/{quota} option picks (fail-open)"
)
return picks, errors
def run_agent( def run_agent(
@@ -254,6 +339,9 @@ def run_agent(
date_str = datetime.now().astimezone().strftime("%A %Y-%m-%d") date_str = datetime.now().astimezone().strftime("%A %Y-%m-%d")
kickoff = RESEARCH_KICKOFF.format(date=date_str, n=persona.target_pick_count) kickoff = RESEARCH_KICKOFF.format(date=date_str, n=persona.target_pick_count)
if persona.options_quota > 0:
kickoff += OPTIONS_MANDATE.format(opt=persona.options_quota, n=persona.target_pick_count)
kickoff += SANDBOX_GUIDE
if context_blurb: # market regime + the agent's own track record (self-learning) if context_blurb: # market regime + the agent's own track record (self-learning)
kickoff = f"{context_blurb}\n\n{kickoff}" kickoff = f"{context_blurb}\n\n{kickoff}"
messages: list = [ messages: list = [
@@ -327,10 +415,19 @@ def run_agent(
tool_calls = getattr(ai, "tool_calls", None) tool_calls = getattr(ai, "tool_calls", None)
if not tool_calls: if not tool_calls:
# Force depth: if the model tries to finalize before the TIME floor (or step floor), # Force depth: if the model tries to finalize before the TIME floor (or step floor),
# inject a rotating directive nudge so it keeps doing NEW research. Bounded by deadline. # inject a directive nudge so it keeps doing NEW research. Bounded by deadline.
# Priority: unmet options mandate first, converge in the final stretch, else rotate.
elapsed = time.time() - start elapsed = time.time() - start
if (elapsed < min_seconds or steps_used < min_steps) and time.time() < deadline_ts: if (elapsed < min_seconds or steps_used < min_steps) and time.time() < deadline_ts:
nudge = DEEPEN_NUDGES[nudge_count % len(DEEPEN_NUDGES)] options_pending = persona.options_quota > 0 and not any(
"get_option_chain" in line for line in reasoning_trace
)
if options_pending:
nudge = OPTIONS_NUDGE
elif min_seconds and elapsed > 0.8 * min_seconds:
nudge = CONSOLIDATE_NUDGE
else:
nudge = DEEPEN_NUDGES[nudge_count % len(DEEPEN_NUDGES)]
nudge_count += 1 nudge_count += 1
messages.append(HumanMessage(content=nudge)) messages.append(HumanMessage(content=nudge))
reasoning_trace.append( reasoning_trace.append(
+23
View File
@@ -6,11 +6,17 @@ it back. Validators enforce basic sanity so garbage never reaches order placemen
from __future__ import annotations from __future__ import annotations
import re
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
# OCC option symbol: ROOT (<=6 chars) + YYMMDD + C/P + strike*1000 as 8 digits,
# e.g. AAPL260724C00295000. LLMs sometimes fabricate near-miss symbols; reject them
# here so an invented contract can never reach order placement.
_OCC_SYMBOL_RE = re.compile(r"^[A-Z.]{1,6}\d{6}[CP]\d{8}$")
class Direction(str, Enum): class Direction(str, Enum):
LONG = "long" LONG = "long"
@@ -87,9 +93,26 @@ class Pick(BaseModel):
if self.instrument == Instrument.OPTION: if self.instrument == Instrument.OPTION:
if not self.contract_symbol: if not self.contract_symbol:
raise ValueError("option pick requires contract_symbol (from get_option_chain)") raise ValueError("option pick requires contract_symbol (from get_option_chain)")
self.contract_symbol = self.contract_symbol.strip().upper()
if not _OCC_SYMBOL_RE.match(self.contract_symbol):
raise ValueError(
f"contract_symbol '{self.contract_symbol}' is not a valid OCC symbol — "
"copy it EXACTLY from get_option_chain output, never construct one"
)
if (self.option_right or "").lower() not in ("call", "put"): if (self.option_right or "").lower() not in ("call", "put"):
raise ValueError("option pick requires option_right 'call' or 'put'") raise ValueError("option pick requires option_right 'call' or 'put'")
self.option_right = self.option_right.lower() self.option_right = self.option_right.lower()
# We only BUY options. A put marked direction=short is the same bearish bet
# expressed sloppily (LLMs do this) — normalize to a long put. A short call has
# no long-only equivalent, so reject it outright.
if self.direction == Direction.SHORT:
if self.option_right == "put":
self.direction = Direction.LONG
else:
raise ValueError(
"cannot short a call (selling options unsupported) — express a "
"bearish view with a long put instead"
)
return self return self
@model_validator(mode="after") @model_validator(mode="after")
+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 — not just flavor text. Each persona's sizing/stop/take defaults feed both the prompt
AND the morning guardrail clamps in ``execution/guardrails.py``. 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 from __future__ import annotations
@@ -9,10 +14,20 @@ from pydantic import BaseModel
_COMMON_RULES = ( _COMMON_RULES = (
"\n\nHARD RULES:\n" "\n\nHARD RULES:\n"
"- You have tools for market data, web search/fetch, a portfolio view, and a Python " "- You have tools for market data, options chains, web search/fetch, a portfolio view, "
"calculator (`run_python`). ALWAYS use tools for prices, ratios, and any arithmetic. " "and two Python tools. ALWAYS use tools for prices, ratios, and any arithmetic. NEVER "
"NEVER estimate a number yourself.\n" "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" "- 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" "- 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 " "- When done researching, output your picks as structured JSON exactly matching the "
"requested schema — ticker, direction, conviction, thesis, stop/take, position size." "requested schema — ticker, direction, conviction, thesis, stop/take, position size."
@@ -29,68 +44,158 @@ class Persona(BaseModel):
default_take_pct: float default_take_pct: float
preferred_horizon: str preferred_horizon: str
tool_emphasis: list[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( NOVA = Persona(
id="nova", id="nova",
display_name="Nova — Momentum Growth Hunter", display_name="Nova — Momentum Growth Hunter",
system_prompt=( system_prompt=(
"You are Nova, an aggressive momentum growth trader. You hunt stocks with strong " "IDENTITY: You are Nova, an aggressive momentum growth trader.\n\n"
"upward momentum, relative strength versus the market, breakouts to new highs, and " "OBJECTIVE: Catch stocks that are moving NOW and ride the move. A good night ends "
"near-term catalysts — earnings beats, product launches, analyst upgrades, and sector " "with picks that have strong momentum, a live near-term catalyst, and a clear level "
"hype, especially in AI, semiconductors, and high-growth tech. You move fast and trade " "that proves you wrong. You win on hit rate times slugging: tight stops (~5%) on "
"with conviction. You prefer swing trades held days to a few weeks. You size winners " "every loser, winners run toward +20% or more.\n\n"
"aggressively but always protect capital with TIGHT stop-losses (~5%) and let winners " "PROCESS: (1) Check your portfolio and track record. (2) Screen for relative "
"run toward larger take-profits (~20%+). You are skeptical of stale, slow, low-volatility " "strength vs the market, breakouts to new highs, unusual volume, and catalysts — "
"names — you want where the flow is going now." + _COMMON_RULES "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, max_position_pct=0.10,
target_pick_count=3, target_pick_count=4,
default_stop_pct=0.05, default_stop_pct=0.05,
default_take_pct=0.20, default_take_pct=0.20,
preferred_horizon="swing", 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( WARREN_LITE = Persona(
id="warren_lite", id="warren_lite",
display_name="Warren-lite — Patient Value / Contrarian", display_name="Warren-lite — Patient Value / Contrarian",
system_prompt=( system_prompt=(
"You are Warren-lite, a patient value and contrarian investor. You buy quality " "IDENTITY: You are Warren-lite, a patient value and contrarian investor.\n\n"
"businesses trading cheaply relative to fundamentals — low P/E versus peers and history, " "OBJECTIVE: Buy quality businesses for less than they are worth and let the gap "
"strong free cash flow, healthy balance sheets, and temporary pessimism you believe is " "close. A good night ends with picks where you can state what the business is worth, "
"overdone. You IGNORE hype and momentum. You demand a margin of safety and think in weeks " "why the market is wrong today, and what closes the gap over weeks to months. You "
"to months. You size positions moderately and give theses room to work with WIDER " "win by rarely being badly wrong: margin of safety first, upside second.\n\n"
"stop-losses (~10-12%), targeting steady take-profits. You read fundamentals, earnings, " "PROCESS: (1) Check your portfolio and track record. (2) Hunt temporary pessimism in "
"and filings carefully and always ask: what is this business actually worth?" + _COMMON_RULES "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, max_position_pct=0.08,
target_pick_count=2, target_pick_count=4,
default_stop_pct=0.11, default_stop_pct=0.11,
default_take_pct=0.15, default_take_pct=0.15,
preferred_horizon="months", preferred_horizon="months",
tool_emphasis=["get_fundamentals", "web_fetch", "run_python"], 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( ATLAS = Persona(
id="atlas", id="atlas",
display_name="Atlas — Macro / Event-Driven Thematic", display_name="Atlas — Macro / Event-Driven Thematic",
system_prompt=( system_prompt=(
"You are Atlas, a macro and event-driven thematic trader. You think top-down: the macro " "IDENTITY: You are Atlas, a macro and event-driven thematic trader.\n\n"
"regime (rates, inflation, growth, the dollar, credit), sector rotation, policy and " "OBJECTIVE: Read the regime, find the theme it favors, and express that theme "
"geopolitical catalysts, and scheduled events (FOMC, CPI, jobs reports, major earnings). " "through the best 2-4 instruments. A good night ends with a coherent view — regime, "
"You build positions around a THEME and diversify across a few names or ETFs expressing " "catalyst calendar, positioning — and picks that all make money if the view is "
"one view. You size moderately and use medium stops and take-profits. You connect macro " "right. You win on being early to rotations, not on single-stock heroics.\n\n"
"dots to specific tickers. You always ask: what regime are we in, and what catalyst is " "PROCESS: (1) Check your portfolio and track record. (2) Establish the regime: "
"next?" + _COMMON_RULES "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, max_position_pct=0.09,
target_pick_count=3, target_pick_count=4,
default_stop_pct=0.08, default_stop_pct=0.08,
default_take_pct=0.15, default_take_pct=0.15,
preferred_horizon="weeks", 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} 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): class RiskConfig(BaseModel):
max_position_pct: float = 0.10 # cap per single position, % of equity 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_symbol_exposure_pct: float = 0.15 # aggregate cap per ticker across all agents
max_trades_per_agent: int = 3 max_trades_per_agent: int = 4 # 4 trades/day mandate (one of them an option)
max_trades_per_day: int = 8 # 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 allow_shorts: bool = True # shorts enabled; persona prompts decide when to use them
default_stop_pct: float = 0.07 default_stop_pct: float = 0.07
default_take_pct: float = 0.15 default_take_pct: float = 0.15
min_price: float = 3.0 # skip sub-$3 names min_price: float = 3.0 # skip sub-$3 names
max_price: float | None = None 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() DEFAULT_RISK = RiskConfig()
+42 -4
View File
@@ -16,6 +16,19 @@ from .models import ModelProfile, resolve_profile
REPO_ROOT = Path(__file__).resolve().parent.parent 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): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=REPO_ROOT / ".env", env_file=REPO_ROOT / ".env",
@@ -34,6 +47,8 @@ class Settings(BaseSettings):
alpaca_warren_lite_secret: str = "" alpaca_warren_lite_secret: str = ""
alpaca_atlas_key: str = "" alpaca_atlas_key: str = ""
alpaca_atlas_secret: str = "" alpaca_atlas_secret: str = ""
alpaca_maverick_key: str = ""
alpaca_maverick_secret: str = ""
# --- Finnhub --- # --- Finnhub ---
finnhub_api_key: str = "" finnhub_api_key: str = ""
@@ -133,16 +148,39 @@ class Settings(BaseSettings):
return self.active_profile.temperature return self.active_profile.temperature
def alpaca_creds(self, persona_id: str) -> tuple[str, str]: 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, 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 "" 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: if key and secret:
return key, secret return key, secret
return self.alpaca_api_key, self.alpaca_secret_key return self.alpaca_api_key, self.alpaca_secret_key
def alpaca_data_creds(self) -> tuple[str, str]: def alpaca_data_creds(self) -> tuple[str, str]:
"""Any valid creds — Alpaca market data is account-agnostic.""" """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) key, secret = self.alpaca_creds(pid)
if key and secret: if key and secret:
return key, secret return key, secret
+47 -10
View File
@@ -5,9 +5,14 @@ whole pipeline is deterministic and testable. Handles equities and single-leg lo
(sized by premium × 100). The LLM's requested sizing / stops are inputs that get clamped (sized by premium × 100). The LLM's requested sizing / stops are inputs that get clamped
here; nothing is trusted as-is. here; nothing is trusted as-is.
Pipeline: collect (long-only unless enabled) -> dedup across agents (highest conviction) -> Pipeline: collect (long-only unless enabled; options quality gate) -> dedup across agents
per-agent trade cap -> price/liquidity filter + skip already-held -> size & clamp weight -> (highest conviction) -> per-agent trade cap (option slot reserved) -> price/liquidity filter
per-day cap -> buying-power trim. + skip already-held -> size & clamp weight (options premium-capped) -> per-day cap ->
buying-power trim (reserved option dropped last).
The "reserved option": each night one options trade is mandated per agent, so the
highest-conviction option that passes the quality gate is protected from the caps and the
buying-power trim — otherwise conviction-ranked truncation would routinely cut it.
""" """
from __future__ import annotations from __future__ import annotations
@@ -59,6 +64,8 @@ def plan_orders(
notes: list[str] = [] notes: list[str] = []
# 1. collect candidates (long-only unless shorts allowed; options are long-only here) # 1. collect candidates (long-only unless shorts allowed; options are long-only here)
# + options quality gate: the mandate is a HIGH-PROBABILITY options trade, so option
# picks below the conviction floor never reach sizing.
candidates: list[tuple[str, Pick]] = [] candidates: list[tuple[str, Pick]] = []
for out in agent_outputs: for out in agent_outputs:
for pick in out.picks: for pick in out.picks:
@@ -68,6 +75,12 @@ def plan_orders(
if pick.instrument == Instrument.OPTION and pick.direction != Direction.LONG: if pick.instrument == Instrument.OPTION and pick.direction != Direction.LONG:
notes.append(f"drop {pick.ticker} ({out.persona_id}): only long options supported") notes.append(f"drop {pick.ticker} ({out.persona_id}): only long options supported")
continue continue
if pick.instrument == Instrument.OPTION and pick.conviction < risk.min_option_conviction:
notes.append(
f"drop {pick.ticker} option ({out.persona_id}): conviction "
f"{pick.conviction:.2f} < floor {risk.min_option_conviction}"
)
continue
candidates.append((out.persona_id, pick)) candidates.append((out.persona_id, pick))
# 2. dedup across agents — highest conviction wins the exec key # 2. dedup across agents — highest conviction wins the exec key
@@ -80,16 +93,22 @@ def plan_orders(
notes.append(f"dedup {pick.ticker}: {pid} outranks {cur[0]}") notes.append(f"dedup {pick.ticker}: {pid} outranks {cur[0]}")
best[k] = (pid, pick) best[k] = (pid, pick)
# 3. per-agent trade cap — keep each persona's top-conviction picks # 3. per-agent trade cap — keep each persona's top-conviction picks, but the persona's
# best option pick survives the cap (options-trade mandate)
by_persona: dict[str, list[Pick]] = {} by_persona: dict[str, list[Pick]] = {}
for pid, pick in best.values(): for pid, pick in best.values():
by_persona.setdefault(pid, []).append(pick) by_persona.setdefault(pid, []).append(pick)
kept: list[tuple[str, Pick]] = [] kept: list[tuple[str, Pick]] = []
for pid, picks in by_persona.items(): for pid, picks in by_persona.items():
picks.sort(key=lambda p: p.conviction, reverse=True) picks.sort(key=lambda p: p.conviction, reverse=True)
keep = picks[: risk.max_trades_per_agent]
if len(picks) > risk.max_trades_per_agent: if len(picks) > risk.max_trades_per_agent:
notes.append(f"{pid}: capped to {risk.max_trades_per_agent} trades") notes.append(f"{pid}: capped to {risk.max_trades_per_agent} trades")
kept.extend((pid, p) for p in picks[: risk.max_trades_per_agent]) options = [p for p in picks if p.instrument == Instrument.OPTION]
if options and not any(p.instrument == Instrument.OPTION for p in keep):
notes.append(f"{pid}: reserved option slot for {options[0].ticker}")
keep[-1] = options[0]
kept.extend((pid, p) for p in keep)
# 4. price / liquidity filter + skip already-held (min_price only applies to equities) # 4. price / liquidity filter + skip already-held (min_price only applies to equities)
priced: list[tuple[str, Pick, float]] = [] priced: list[tuple[str, Pick, float]] = []
@@ -107,7 +126,8 @@ def plan_orders(
else: else:
priced.append((pid, pick, px)) priced.append((pid, pick, px))
# 5. clamp weight, size to whole shares/contracts # 5. clamp weight, size to whole shares/contracts (options additionally premium-capped:
# weight = premium notional, i.e. the max loss — keep any single bet survivable)
orders: list[PlannedOrder] = [] orders: list[PlannedOrder] = []
for pid, pick, px in priced: for pid, pick, px in priced:
cap = min( cap = min(
@@ -115,6 +135,8 @@ def plan_orders(
risk.max_position_pct, risk.max_position_pct,
risk.max_symbol_exposure_pct, risk.max_symbol_exposure_pct,
) )
if pick.instrument == Instrument.OPTION:
cap = min(cap, risk.max_option_weight_pct)
weight = min(pick.requested_weight_pct, cap) weight = min(pick.requested_weight_pct, cap)
mult = _multiplier(pick) mult = _multiplier(pick)
qty = floor(weight * equity / (px * mult)) qty = floor(weight * equity / (px * mult))
@@ -132,19 +154,34 @@ def plan_orders(
) )
) )
# 6. per-day cap — rank all by conviction, truncate # the mandated trade: highest-conviction option that made it through sizing is protected
# from the day cap and the buying-power trim below
reserved = max(
(o for o in orders if o.instrument == "option"), key=lambda o: o.conviction, default=None
)
# 6. per-day cap — rank all by conviction, truncate (reserved option swapped back in)
orders.sort(key=lambda o: o.conviction, reverse=True) orders.sort(key=lambda o: o.conviction, reverse=True)
if len(orders) > risk.max_trades_per_day: if len(orders) > risk.max_trades_per_day:
keep, cut = orders[: risk.max_trades_per_day], orders[risk.max_trades_per_day :]
if reserved is not None and reserved in cut:
swapped = keep.pop()
cut.remove(reserved)
keep.append(reserved)
notes.append(f"day cap: reserved option {reserved.ticker} replaces {swapped.ticker}")
notes.append(f"day cap: {len(orders)} -> {risk.max_trades_per_day}") notes.append(f"day cap: {len(orders)} -> {risk.max_trades_per_day}")
orders = orders[: risk.max_trades_per_day] orders = keep
# 7. buying-power trim — drop lowest-conviction until it fits (notional = qty*price*mult) # 7. buying-power trim — drop lowest-conviction until it fits (notional = qty*price*mult);
# the reserved option goes last (it's also the cheapest kind of trade to keep)
def _notional(o: PlannedOrder) -> float: def _notional(o: PlannedOrder) -> float:
return o.qty * o.est_price * o.multiplier return o.qty * o.est_price * o.multiplier
total = sum(_notional(o) for o in orders) total = sum(_notional(o) for o in orders)
while orders and total > buying_power: while orders and total > buying_power:
dropped = orders.pop() droppable = [o for o in orders if o is not reserved] or orders
dropped = min(droppable, key=lambda o: o.conviction)
orders.remove(dropped)
total -= _notional(dropped) total -= _notional(dropped)
notes.append(f"drop {dropped.ticker}: buying power (need ${total:,.0f} > ${buying_power:,.0f})") notes.append(f"drop {dropped.ticker}: buying power (need ${total:,.0f} > ${buying_power:,.0f})")
+6 -4
View File
@@ -17,7 +17,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from loguru import logger # noqa: E402 from loguru import logger # noqa: E402
from agents.schemas import AgentOutput # noqa: E402 from agents.schemas import AgentOutput # noqa: E402
from config.personas import PERSONAS # noqa: E402 from config.agent_registry import load_personas # noqa: E402
from config.risk import DEFAULT_RISK # noqa: E402 from config.risk import DEFAULT_RISK # noqa: E402
from config.settings import get_settings # noqa: E402 from config.settings import get_settings # noqa: E402
from execution import broker as broker_mod # noqa: E402 from execution import broker as broker_mod # noqa: E402
@@ -111,9 +111,11 @@ def main(force: bool = False, date: str | None = None) -> int:
db.record_thesis(con, trade_date, o.persona_id, pick.model_dump(mode="json")) db.record_thesis(con, trade_date, o.persona_id, pick.model_dump(mode="json"))
con.commit() con.commit()
# 4. per-persona: own account, own guardrails, own orders # 4. per-persona: own account, own guardrails, own orders (registry: an agent disabled
# on the dashboard after its research ran will NOT have its picks executed)
personas = load_personas()
total_placed = 0 total_placed = 0
for persona in PERSONAS: for persona in personas:
out = by_persona.get(persona.id) out = by_persona.get(persona.id)
if not out or not out.picks: if not out or not out.picks:
continue continue
@@ -196,7 +198,7 @@ def main(force: bool = False, date: str | None = None) -> int:
(trade_date,), (trade_date,),
).fetchall() ).fetchall()
lines = [f"**{total_placed} orders placed** across accounts for {trade_date}.", ""] lines = [f"**{total_placed} orders placed** across accounts for {trade_date}.", ""]
for persona in PERSONAS: for persona in personas:
ords = [r for r in rows if r["agent"] == persona.id] ords = [r for r in rows if r["agent"] == persona.id]
snap = db.latest_snapshot(con2, persona.id) snap = db.latest_snapshot(con2, persona.id)
eq = f"${snap['equity']:,.2f}" if snap else "n/a" eq = f"${snap['equity']:,.2f}" if snap else "n/a"
+19 -3
View File
@@ -23,15 +23,19 @@ from config.settings import get_settings
from llm.client import build_llm from llm.client import build_llm
CRO_SYSTEM = ( CRO_SYSTEM = (
"You are the Chief Risk Officer of a small fund. Three analysts submitted tonight's picks. " "You are the Chief Risk Officer of a small fund. {n} analysts submitted tonight's picks. "
"Your job is capital preservation and portfolio coherence — NOT stock picking. Judge each " "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, " "pick on: thesis quality vs the market regime, concentration/overlap across the books, "
"crowding into one theme, position sizing sanity, and each analyst's recent track record. " "crowding into one theme, position sizing sanity, and each analyst's recent track record. "
"Each analyst has a declared risk style — judge every book against ITS OWN mandate: an "
"intentionally aggressive book is not vetoed for being aggressive, only for breaking its "
"own discipline (undefined risk, sloppy sizing, no catalyst). "
"Be decisive and specific. Output ONLY the requested JSON object." "Be decisive and specific. Output ONLY the requested JSON object."
) )
CRO_PROMPT = ( CRO_PROMPT = (
"{regime}\n\n" "{regime}\n\n"
"ANALYST MANDATES (risk styles):\n{styles}\n\n"
"ANALYST TRACK RECORDS:\n{records}\n\n" "ANALYST TRACK RECORDS:\n{records}\n\n"
"TONIGHT'S SUBMITTED PICKS:\n{picks}\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 ' 'Return a JSON object: {{"memo": "<a 150-250 word committee memo in plain prose: overall '
@@ -82,15 +86,27 @@ def run_committee(
return {} return {}
records_txt = "\n".join(f"[{k}]\n{v or '(no history yet)'}" for k, v in records.items()) records_txt = "\n".join(f"[{k}]\n{v or '(no history yet)'}" for k, v in records.items())
# style-aware review: the CRO judges each book against its own declared mandate
try:
from config.agent_registry import load_personas
styles = {p.id: p.risk_style for p in load_personas(include_disabled=True)}
except Exception:
styles = {}
styles_txt = "\n".join(
f"- {o.persona_id}: {styles.get(o.persona_id, 'balanced')}" for o in outputs
)
verdicts: dict = {} verdicts: dict = {}
memo = "" memo = ""
try: try:
resp = build_llm(max_tokens=4096).invoke( resp = build_llm(max_tokens=4096).invoke(
[ [
SystemMessage(content=CRO_SYSTEM), SystemMessage(content=CRO_SYSTEM.format(n=len(outputs))),
HumanMessage( HumanMessage(
content=CRO_PROMPT.format( content=CRO_PROMPT.format(
regime=regime or "(regime unavailable)", regime=regime or "(regime unavailable)",
styles=styles_txt or "(none declared)",
records=records_txt, records=records_txt,
picks=picks_txt, picks=picks_txt,
) )
+14 -6
View File
@@ -21,7 +21,7 @@ from loguru import logger # noqa: E402
from agents.graph import run_agent # noqa: E402 from agents.graph import run_agent # noqa: E402
from agents.schemas import RunManifest # noqa: E402 from agents.schemas import RunManifest # noqa: E402
from config.personas import PERSONAS # noqa: E402 from config.agent_registry import load_personas # noqa: E402
from config.settings import get_settings # noqa: E402 from config.settings import get_settings # noqa: E402
from llm import server # noqa: E402 from llm import server # noqa: E402
from llm.client import resolve_model_id # noqa: E402 from llm.client import resolve_model_id # noqa: E402
@@ -71,10 +71,12 @@ def main(compressed: bool = False) -> int:
cp.write_manifest(manifest) cp.write_manifest(manifest)
hard_stop = local_hard_stop_ts(s.hard_stop, s.local_tz) hard_stop = local_hard_stop_ts(s.hard_stop, s.local_tz)
per_agent_cap_s = 5 * 60 if compressed else s.per_agent_wallclock_min * 60
step_budget = 12 if compressed else s.per_agent_step_budget step_budget = 12 if compressed else s.per_agent_step_budget
min_steps = 4 if compressed else s.per_agent_min_steps min_steps = 4 if compressed else s.per_agent_min_steps
min_seconds = 60 if compressed else s.per_agent_min_minutes * 60
# dashboard-managed registry: built-ins + overrides + custom agents, disabled excluded
personas = load_personas()
logger.info("agents tonight: {}", ", ".join(p.id for p in personas))
# shared ground truth for all agents tonight (computed, deterministic) # shared ground truth for all agents tonight (computed, deterministic)
regime = market_regime() regime = market_regime()
@@ -83,12 +85,18 @@ def main(compressed: bool = False) -> int:
outputs = [] outputs = []
records: dict[str, str] = {} records: dict[str, str] = {}
for i, persona in enumerate(PERSONAS): for i, persona in enumerate(personas):
# per-agent budgets (dashboard-editable), falling back to the global settings
if compressed:
per_agent_cap_s, min_seconds = 5 * 60, 60
else:
per_agent_cap_s = (persona.time_budget_min or s.per_agent_wallclock_min) * 60
min_seconds = (persona.min_research_min or s.per_agent_min_minutes) * 60
now = time.time() now = time.time()
if now >= hard_stop: if now >= hard_stop:
logger.warning("hard stop reached; skipping {} remaining agents", len(PERSONAS) - i) logger.warning("hard stop reached; skipping {} remaining agents", len(personas) - i)
break break
deadline = next_deadline(now, hard_stop, len(PERSONAS) - i, per_agent_cap_s) deadline = next_deadline(now, hard_stop, len(personas) - i, per_agent_cap_s)
logger.info("--- {} until {} ---", persona.id, logger.info("--- {} until {} ---", persona.id,
datetime.fromtimestamp(deadline).strftime("%H:%M:%S")) datetime.fromtimestamp(deadline).strftime("%H:%M:%S"))
if not server.server_up(): # recover if a prior agent left oMLX unresponsive if not server.server_up(): # recover if a prior agent left oMLX unresponsive
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.
+58
View File
@@ -133,7 +133,9 @@ def test_option_sizing_by_premium():
o = orders[0] o = orders[0]
assert o.instrument == "option" and o.multiplier == 100 assert o.instrument == "option" and o.multiplier == 100
assert o.contract_symbol == "AAPL260724C00295000" assert o.contract_symbol == "AAPL260724C00295000"
# premium cap: weight = min(requested, persona cap, max_option_weight_pct=0.10)
# qty = floor(0.10 * 100000 / (7.39 * 100)) = floor(10000 / 739) = 13 # qty = floor(0.10 * 100000 / (7.39 * 100)) = floor(10000 / 739) = 13
assert abs(o.weight_pct - 0.10) < 1e-9
assert o.qty == 13 assert o.qty == 13
@@ -148,3 +150,59 @@ def test_buying_power_trim():
assert len(orders) == 1 assert len(orders) == 1
assert orders[0].ticker == "HI" assert orders[0].ticker == "HI"
assert any("buying power" in n for n in notes) assert any("buying power" in n for n in notes)
def _opt_pick(ticker="AAPL", conviction=0.8, weight=0.05, right="call"):
sym = f"{ticker}260724{'C' if right == 'call' else 'P'}00295000"
return Pick(
ticker=ticker, conviction=conviction, thesis="t", requested_weight_pct=weight,
instrument="option", option_right=right, contract_symbol=sym,
strike=295.0, expiry="2026-07-24",
)
def test_option_conviction_gate():
lo = _opt_pick("AAPL", conviction=0.4)
hi = _opt_pick("MSFT", conviction=0.8)
orders, notes = _plan(
[_out("nova", [lo, hi])],
{lo.contract_symbol: 3.0, hi.contract_symbol: 3.0},
)
assert {o.ticker for o in orders} == {"MSFT"}
assert any("conviction" in n and "floor" in n for n in notes)
def test_option_reserved_in_per_agent_cap():
# option is the LOWEST conviction of 5 picks with cap 4 — it must still survive
eq = [_pick(t, conviction=c) for t, c in [("A", 0.9), ("B", 0.85), ("C", 0.8), ("D", 0.75)]]
opt = _opt_pick("AAPL", conviction=0.7)
prices = {p.ticker: 100.0 for p in eq} | {opt.contract_symbol: 3.0}
orders, notes = _plan([_out("nova", [*eq, opt])], prices)
assert len(orders) == 4
assert any(o.instrument == "option" for o in orders)
assert any("reserved option slot" in n for n in notes)
def test_option_reserved_in_day_cap():
# day cap 2, option ranks 3rd by conviction -> swapped back in
eq = [_pick(t, conviction=c) for t, c in [("A", 0.9), ("B", 0.85)]]
opt = _opt_pick("AAPL", conviction=0.7)
prices = {p.ticker: 100.0 for p in eq} | {opt.contract_symbol: 3.0}
orders, notes = _plan(
[_out("nova", [*eq, opt])], prices,
risk=RiskConfig(max_trades_per_day=2, max_trades_per_agent=5),
)
assert len(orders) == 2
assert any(o.instrument == "option" for o in orders)
assert any("reserved option" in n for n in notes)
def test_option_survives_buying_power_trim():
# option has lowest conviction but equities get dropped first
eq = [_pick("BIG", conviction=0.9, weight=0.10)]
opt = _opt_pick("AAPL", conviction=0.7, weight=0.05)
prices = {"BIG": 200.0, opt.contract_symbol: 5.0}
# equity order = 50 * 200 = 10k; option = qty 10 * 5 * 100 = 5k; BP only fits the option
orders, notes = _plan([_out("nova", [*eq, opt])], prices, buying_power=6_000.0)
assert len(orders) == 1
assert orders[0].instrument == "option"
+86
View File
@@ -0,0 +1,86 @@
"""Agent registry merge/override semantics + creds fallback — no network, temp-dir isolated."""
from __future__ import annotations
import json
import pytest
from config import agent_registry as ar
from config.personas import PERSONAS
@pytest.fixture
def reg(tmp_path, monkeypatch):
"""Point the registry at a temp file so tests never touch state/agents.json."""
path = tmp_path / "agents.json"
monkeypatch.setattr(ar, "registry_path", lambda: path)
return path
def test_defaults_when_no_file(reg):
personas = ar.load_personas()
assert [p.id for p in personas] == [p.id for p in PERSONAS]
def test_builtin_override_merges(reg):
ar.upsert_agent("nova", {"time_budget_min": 90, "target_pick_count": 5})
nova = next(p for p in ar.load_personas() if p.id == "nova")
assert nova.time_budget_min == 90
assert nova.target_pick_count == 5
# untouched fields keep code defaults
assert nova.display_name.startswith("Nova")
def test_only_diff_is_stored_for_builtins(reg):
nova_defaults = next(p for p in PERSONAS if p.id == "nova")
ar.upsert_agent("nova", {"time_budget_min": 90, "max_position_pct": nova_defaults.max_position_pct})
stored = ar.load_overrides()["nova"]
assert stored == {"time_budget_min": 90} # same-as-default values not persisted
def test_disabled_agents_excluded(reg):
ar.upsert_agent("atlas", {"enabled": False})
assert "atlas" not in [p.id for p in ar.load_personas()]
assert "atlas" in [p.id for p in ar.load_personas(include_disabled=True)]
def test_custom_agent_created_and_deleted(reg):
ar.upsert_agent("scalper", {"display_name": "Scalper", "system_prompt": "scalp fast"})
scalper = next(p for p in ar.load_personas() if p.id == "scalper")
assert scalper.options_quota == 1 # custom base defaults applied
ar.delete_agent("scalper")
assert "scalper" not in [p.id for p in ar.load_personas()]
def test_custom_agent_requires_prompt_fields(reg):
with pytest.raises(Exception):
ar.upsert_agent("broken", {"display_name": "no prompt"})
def test_corrupt_file_falls_back_to_builtins(reg):
reg.write_text("{not json")
personas = ar.load_personas()
assert [p.id for p in personas] == [p.id for p in PERSONAS]
def test_invalid_override_falls_back_for_that_agent(reg):
reg.write_text(json.dumps({"agents": {"nova": {"max_position_pct": "not-a-number"}}}))
nova = next(p for p in ar.load_personas() if p.id == "nova")
assert nova.max_position_pct == next(p for p in PERSONAS if p.id == "nova").max_position_pct
def test_reset_builtin(reg):
ar.upsert_agent("nova", {"time_budget_min": 45})
ar.delete_agent("nova")
nova = next(p for p in ar.load_personas() if p.id == "nova")
assert nova.time_budget_min is None
def test_env_creds_fallback_for_custom_ids(monkeypatch):
from config.settings import get_settings
monkeypatch.setenv("ALPACA_SCALPER_KEY", "k123")
monkeypatch.setenv("ALPACA_SCALPER_SECRET", "s456")
key, secret = get_settings().alpaca_creds("scalper")
assert (key, secret) == ("k123", "s456")
+38
View File
@@ -71,3 +71,41 @@ def test_run_manifest_minimal():
) )
assert m.status == "running" assert m.status == "running"
assert m.agents == [] assert m.agents == []
def test_option_pick_rejects_fabricated_occ_symbol():
import pytest as _pytest
from agents.schemas import Pick
bad = dict(
ticker="JPM", conviction=0.8, thesis="t", requested_weight_pct=0.05,
instrument="option", option_right="call",
)
# missing underlying root (a real LLM fabrication seen in testing)
with _pytest.raises(Exception):
Pick(**bad, contract_symbol="20270119C0035000")
# 6-digit strike instead of 8 (another real fabrication)
with _pytest.raises(Exception):
Pick(**bad, contract_symbol="PFE260729C002500")
# valid OCC symbol passes and is normalized to uppercase
p = Pick(**bad, contract_symbol="jpm270115c00300000")
assert p.contract_symbol == "JPM270115C00300000"
def test_option_direction_normalization():
import pytest as _pytest
from agents.schemas import Direction, Pick
base = dict(
ticker="JNJ", conviction=0.7, thesis="bearish", requested_weight_pct=0.05,
instrument="option", contract_symbol="JNJ260715P00155000",
)
# short + put -> normalized to a long put (same economic view, buy-to-open)
p = Pick(**base, option_right="put", direction="short")
assert p.direction == Direction.LONG
# short + call has no long-only equivalent -> rejected
with _pytest.raises(Exception):
Pick(**{**base, "contract_symbol": "JNJ260715C00155000"},
option_right="call", direction="short")
+72 -34
View File
@@ -1,11 +1,15 @@
"""get_portfolio — persona-scoped. Each agent sees ONLY its own paper account's holdings """get_portfolio — persona-scoped. Each agent sees ONLY its own paper account.
(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. 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 from __future__ import annotations
import json import json
from datetime import datetime, timezone
from langchain_core.tools import tool from langchain_core.tools import tool
@@ -13,44 +17,78 @@ from config.settings import get_settings
from state import db as statedb 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): def make_get_portfolio(account: str):
"""Return a get_portfolio tool bound to one account (persona id).""" """Return a get_portfolio tool bound to one account (persona id)."""
@tool @tool
def get_portfolio() -> str: 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. Use this BEFORE proposing trades so you account for what you already own.
""" """
path = get_settings().db_path return _live_portfolio(account) or _snapshot_portfolio(account)
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 get_portfolio return get_portfolio
+3 -2
View File
@@ -19,8 +19,9 @@ _TIMEOUT_S = 5
def run_python(code: str) -> str: def run_python(code: str) -> str:
"""Execute a short Python snippet for EXACT math and return its stdout. """Execute a short Python snippet for EXACT math and return its stdout.
Use for ANY arithmetic: position sizing, percentages, P/E, returns, averages, ratios. Use for ANY arithmetic: position sizing, percentages, P/E, returns, averages, ratios,
You MUST print() the result. `math` and `statistics` are importable. 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. Example: `print(round(1000 / 23.55, 4))`. No access to secrets; 5s limit.
""" """
from tools.sandbox import scrubbed_env 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") _SECRET_PREFIXES = ("ALPACA", "FINNHUB", "OMLX", "EMAIL", "SMTP", "OPENAI", "ANTHROPIC")
_ARTIFACT_EXTS = {".png", ".jpg", ".jpeg", ".svg", ".csv", ".txt", ".html", ".md", ".json"} _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]: def scrubbed_env(home: str) -> dict[str, str]:
"""A minimal environment with NO secrets — built from scratch, not inherited.""" """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: def run_analysis(code: str) -> str:
"""Run a Python analysis script and return its stdout plus any files it wrote. """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 ENVIRONMENT — read once, avoid the classic errors:
real analyst: fetch data with yfinance, compute indicators/stats/backtests, and save charts - Installed: pandas, numpy, yfinance (market data), matplotlib (charts, headless Agg),
with `plt.savefig("chart.png")`. You MUST print() anything you want to read back. Runs in an scipy, plus the standard library. Nothing else — no requests/sklearn/talib.
isolated temp dir with a ~90s limit and NO access to account secrets. - 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: Example:
import yfinance as yf, matplotlib.pyplot as plt 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() df["SMA50"] = df["Close"].rolling(50).mean()
print(df[["Close","SMA50"]].tail()) print(df[["Close","SMA50"]].tail())
df[["Close","SMA50"]].plot(); plt.savefig("aapl.png") df[["Close","SMA50"]].plot(); plt.savefig("aapl.png")
@@ -89,8 +137,10 @@ def run_analysis(code: str) -> str:
] ]
if proc.returncode != 0: if proc.returncode != 0:
return f"ERROR (exit {proc.returncode}): {err[-1500:] or 'no stderr'}" return f"ERROR (exit {proc.returncode}): {err[-1500:] or 'no stderr'}{_hint_for(err)}"
result = out[:_STDOUT_LIMIT] or "(no stdout — did you print()?)" result = out[:_STDOUT_LIMIT] or (
"(no stdout — you must print() every value you want to read back)"
)
if len(out) > _STDOUT_LIMIT: if len(out) > _STDOUT_LIMIT:
result += "\n...[truncated]" result += "\n...[truncated]"
if err: if err:
Generated
+3 -3
View File
@@ -21,7 +21,7 @@ wheels = [
[[package]] [[package]]
name = "alpaca-py" name = "alpaca-py"
version = "0.43.4" version = "0.43.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "msgpack" }, { name = "msgpack" },
@@ -32,9 +32,9 @@ dependencies = [
{ name = "sseclient-py" }, { name = "sseclient-py" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/2f/9d/3003f661c15b8003655c447c187aec10f0843647e5c98b391701b04ac3d8/alpaca_py-0.43.4.tar.gz", hash = "sha256:7d529b3654d4e817d9fd7ab461131c4f06a315c736b6a9e4a87d5406bb71114a", size = 97990, upload-time = "2026-04-29T08:41:48.775Z" } sdist = { url = "https://files.pythonhosted.org/packages/b8/9c/0d5b0565f31a6a439876cb343282c1b7353f20ff90fefb57d33f94b4e215/alpaca_py-0.43.5.tar.gz", hash = "sha256:7b09ba442cb0c6f957c2c57a30c6035456e2e670e63e383d439c639ffdbf98ba", size = 98289, upload-time = "2026-07-02T07:22:07.016Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/d5/1f57cc03e7b5925a927cb7f8e7ee5f873e22632633778d28d5d23681c871/alpaca_py-0.43.4-py3-none-any.whl", hash = "sha256:dd49ac30e0f2a8f38550ef1f27a58e7fd8f3f3875deaa4e757443cdbd033a1b4", size = 122534, upload-time = "2026-04-29T08:41:50.149Z" }, { url = "https://files.pythonhosted.org/packages/02/40/83c7ef8bc3e74e73cda6308f7107fe57815dcfc808dc9cd03bd09aa22dd3/alpaca_py-0.43.5-py3-none-any.whl", hash = "sha256:0b4cac9b743851310f19f6a9aa84f57ddf95ae75b601350395746a893f54a2da", size = 122677, upload-time = "2026-07-02T07:22:08.06Z" },
] ]
[[package]] [[package]]
View File
Whitespace-only changes.
+68 -146
View File
@@ -1,183 +1,105 @@
"""ENTRYPOINT #4 — local Streamlit dashboard. """ENTRYPOINT #4 — local Streamlit dashboard.
Read-only over the state DB (positions, snapshots, orders, theses), the nightly output JSON, Read-only over the trading state (positions, snapshots, orders, theses, reports, logs) plus
and the log files. Everything the system did, in one place. one read-WRITE surface: the Agents tab, which manages the agent roster in state/agents.json
(add/edit/disable agents, per-agent research budgets) — the nightly run reads that registry.
uv run streamlit run web/dashboard.py uv run streamlit run web/dashboard.py
""" """
from __future__ import annotations from __future__ import annotations
import json
import sqlite3
import sys import sys
import time
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import pandas as pd # noqa: E402
import streamlit as st # noqa: E402 import streamlit as st # noqa: E402
from agents.schemas import AgentOutput # noqa: E402
from config.settings import get_settings # noqa: E402
S = get_settings()
st.set_page_config(page_title="Overnight Trading Agent", layout="wide") st.set_page_config(page_title="Overnight Trading Agent", layout="wide")
from web.views import agents_admin, logs, orders, portfolio, reports, theses # noqa: E402
from web.views.common import live_overview, query_df # noqa: E402
def _con() -> sqlite3.Connection | None: # ---------------- sidebar ----------------
if not S.db_path.exists(): _REFRESH = {"Off": 0, "30s": 30, "60s": 60, "5 min": 300}
return None refresh_choice = st.sidebar.selectbox("Auto-refresh", list(_REFRESH), index=0)
con = sqlite3.connect(str(S.db_path)) st.sidebar.caption("Alpaca is the source of truth for live positions.")
con.row_factory = sqlite3.Row
return con
def _query_df(sql: str, params: tuple = ()) -> pd.DataFrame:
con = _con()
if con is None:
return pd.DataFrame()
try:
return pd.read_sql_query(sql, con, params=params)
except Exception:
return pd.DataFrame()
finally:
con.close()
def _trade_dates() -> list[str]:
if not S.output_dir.exists():
return []
return sorted((p.name for p in S.output_dir.iterdir() if p.is_dir()), reverse=True)
# ---------------- header ---------------- # ---------------- header ----------------
st.title("🌙 Overnight Trading Agent") st.title("🌙 Overnight Trading Agent")
# latest snapshot per account (one book per persona) # latest snapshot per account (one book per persona), plus the prior day's close for deltas
_LATEST = """ _LATEST = """
SELECT s.account, s.equity, s.cash, s.buying_power FROM snapshots s SELECT s.account, s.equity, s.cash, s.buying_power FROM snapshots s
JOIN (SELECT account, MAX(ts) mt FROM snapshots GROUP BY account) m JOIN (SELECT account, MAX(ts) mt FROM snapshots GROUP BY account) m
ON s.account = m.account AND s.ts = m.mt ON s.account = m.account AND s.ts = m.mt
""" """
latest = _query_df(_LATEST) _PREV = """
c1, c2, c3, c4 = st.columns(4) SELECT s.account, s.equity FROM snapshots s
if not latest.empty: JOIN (
c1.metric("Total Equity", f"${latest['equity'].sum():,.0f}") SELECT account, MAX(ts) mt FROM snapshots
c2.metric("Total Cash", f"${latest['cash'].sum():,.0f}") WHERE DATE(ts) < (SELECT DATE(MAX(ts)) FROM snapshots)
c3.metric("Accounts", str(len(latest))) GROUP BY account
pos = _query_df("SELECT COALESCE(SUM(unrealized_pl),0) AS pl FROM positions") ) m ON s.account = m.account AND s.ts = m.mt
c4.metric("Unrealized PnL", f"${pos.iloc[0]['pl']:,.0f}" if not pos.empty else "$0") """
# LIVE-first: pull accounts + positions straight from Alpaca (60s cache); the DB snapshot
# (written at the last morning reconcile) is only the offline fallback. Deltas always
# compare against the previous day's DB snapshot.
live_acc, live_pos = live_overview()
if not live_acc.empty:
latest, source = live_acc, "live"
else: else:
c1.info("No account snapshot yet — run execution once Alpaca keys are set.") latest, source = query_df(_LATEST), "snapshot"
prev = query_df(_PREV)
prev_eq = dict(zip(prev["account"], prev["equity"])) if not prev.empty else {}
tab_port, tab_theses, tab_reports, tab_orders, tab_logs = st.tabs( if latest.empty:
["📊 Portfolio", "🧠 Tonight's Theses", "📝 Reports", "🧾 Orders", "📜 Logs"] st.info("No account data yet — set Alpaca keys in .env (live) or run execution once (snapshot).")
else:
st.caption("🟢 live from Alpaca (refreshes ≤60s)" if source == "live"
else "🟡 offline — showing last reconciled snapshot")
total_eq = latest["equity"].sum()
total_prev = sum(prev_eq.get(a, e) for a, e in zip(latest["account"], latest["equity"]))
if source == "live":
upl = live_pos["unrealized_pl"].sum() if not live_pos.empty else 0.0
else:
pos = query_df("SELECT COALESCE(SUM(unrealized_pl),0) AS pl FROM positions")
upl = pos.iloc[0]["pl"] if not pos.empty else 0.0
cols = st.columns(2 + len(latest))
cols[0].metric(
"Total Equity", f"${total_eq:,.0f}",
delta=f"${total_eq - total_prev:+,.0f}" if prev_eq else None,
)
cols[1].metric("Unrealized PnL", f"${upl:,.0f}")
for col, (_, acc) in zip(cols[2:], latest.iterrows()):
p = prev_eq.get(acc["account"])
col.metric(
acc["account"], f"${acc['equity']:,.0f}",
delta=f"${acc['equity'] - p:+,.0f}" if p is not None else None,
)
tab_port, tab_theses, tab_reports, tab_orders, tab_agents, tab_logs = st.tabs(
["📊 Portfolio", "🧠 Tonight's Theses", "📝 Reports", "🧾 Orders", "🤖 Agents", "📜 Logs"]
) )
# ---------------- portfolio ----------------
with tab_port: with tab_port:
if latest.empty: portfolio.render(latest, live_pos if source == "live" else None)
st.info("No positions recorded yet — run execution once Alpaca keys are set.")
else:
for _, acc in latest.iterrows():
st.subheader(f"{acc['account']} · ${acc['equity']:,.0f} equity · ${acc['cash']:,.0f} cash")
p = _query_df(
"SELECT symbol, qty, avg_entry, market_value, unrealized_pl FROM positions "
"WHERE account = ? ORDER BY market_value DESC",
(acc["account"],),
)
if p.empty:
st.caption("all cash — no open positions")
else:
st.dataframe(p, use_container_width=True, hide_index=True)
equity = _query_df("SELECT ts, account, equity FROM snapshots ORDER BY ts")
if len(equity) > 1:
st.subheader("Equity curves")
equity["ts"] = pd.to_datetime(equity["ts"])
st.line_chart(equity.pivot_table(index="ts", columns="account", values="equity"))
# ---------------- theses ----------------
with tab_theses: with tab_theses:
dates = _trade_dates() theses.render()
if not dates:
st.info("No nightly output yet. Run: `uv run python -m orchestration.run_nightly --compressed`")
else:
date = st.selectbox("Trade date", dates)
day_dir = S.output_dir / date
files = sorted(f for f in day_dir.glob("*.json") if not f.name.startswith("_"))
cols = st.columns(max(len(files), 1))
for col, f in zip(cols, files):
try:
out = AgentOutput.model_validate_json(f.read_text())
except Exception as e:
col.error(f"{f.name}: {e}")
continue
with col:
st.markdown(f"### {out.agent_name}")
st.caption(f"status **{out.status.value}** · {len(out.picks)} picks · "
f"{out.steps_used} steps · {out.wall_seconds:.0f}s")
for p in out.picks:
with st.expander(f"{p.ticker} · conv {p.conviction:.2f} · {p.time_horizon.value}"):
st.write(p.thesis)
st.caption(
f"weight {p.requested_weight_pct:.0%} · stop {p.stop_loss_pct} · "
f"take {p.take_profit_pct} · {p.direction.value}"
)
if p.catalysts:
st.caption("catalysts: " + ", ".join(p.catalysts))
if out.reasoning_trace:
with st.expander("🔍 reasoning trace"):
st.code("\n".join(out.reasoning_trace), language="text")
if out.errors:
st.warning("errors: " + "; ".join(out.errors))
# ---------------- reports ----------------
with tab_reports: with tab_reports:
rdir = S.reports_dir reports.render()
rdates = (
sorted((p.name for p in rdir.iterdir() if p.is_dir()), reverse=True)
if rdir.exists() else []
)
if not rdates:
st.info("No reports yet — generated during the nightly run (state/reports/).")
else:
rdate = st.selectbox("Report date", rdates, key="rdate")
ddir = rdir / rdate
# daily.md first, then each agent
mds = sorted(ddir.glob("*.md"), key=lambda p: (p.name != "daily.md", p.name))
for md in mds:
st.markdown(md.read_text())
pdf = md.with_suffix(".pdf")
if pdf.exists():
st.download_button(
f"⬇ Download {pdf.name}", pdf.read_bytes(), file_name=pdf.name,
mime="application/pdf", key=f"dl_{md.stem}",
)
for png in sorted(ddir.glob(f"{md.stem}_*.png")):
st.image(str(png))
st.divider()
# ---------------- orders ----------------
with tab_orders: with tab_orders:
orders = _query_df( orders.render()
"SELECT trade_date, agent, ticker, side, qty, est_price, stop_price, take_price, " with tab_agents:
"status, alpaca_order_id, submitted_at FROM orders ORDER BY submitted_at DESC LIMIT 200" agents_admin.render()
)
if orders.empty:
st.info("No orders placed yet.")
else:
st.dataframe(orders, use_container_width=True, hide_index=True)
# ---------------- logs ----------------
with tab_logs: with tab_logs:
log_files = sorted(S.log_dir.glob("*.log"), reverse=True) if S.log_dir.exists() else [] logs.render()
if not log_files:
st.info("No logs yet.")
else:
choice = st.selectbox("Log file", [f.name for f in log_files])
n = st.slider("Lines", 20, 1000, 200, 20)
text = (S.log_dir / choice).read_text().splitlines()[-n:]
st.code("\n".join(text), language="text")
st.caption("Read-only. Refresh the page to update. Alpaca is the source of truth for live positions.") # ---------------- auto-refresh ----------------
_interval = _REFRESH[refresh_choice]
if _interval:
time.sleep(_interval)
st.rerun()
View File
Whitespace-only changes.
+178
View File
@@ -0,0 +1,178 @@
"""Agents tab — the management console for the agent roster.
Everything here writes through ``config/agent_registry`` to ``state/agents.json``:
enable/disable agents, per-agent research time budgets, risk knobs, system prompts, and
adding brand-new agents (cloned from a template persona). The nightly run reads the same
registry, so changes take effect on the next run — no code edits needed.
"""
from __future__ import annotations
import os
import streamlit as st
from config import agent_registry
from config.personas import PERSONAS_BY_ID
from config.settings import _dotenv_extras, get_settings
_HORIZONS = ["intraday", "swing", "weeks", "months"]
def _has_dedicated_creds(pid: str) -> bool:
s = get_settings()
key_name = f"ALPACA_{pid.upper()}_KEY"
sec_name = f"ALPACA_{pid.upper()}_SECRET"
extras = _dotenv_extras()
key = getattr(s, f"alpaca_{pid}_key", "") or os.environ.get(key_name, "") or extras.get(key_name, "")
sec = getattr(s, f"alpaca_{pid}_secret", "") or os.environ.get(sec_name, "") or extras.get(sec_name, "")
return bool(key and sec)
def _lessons_snippet(pid: str) -> str:
try:
from reporting.journal import lessons_for
return lessons_for(pid)
except Exception:
return ""
def _agent_card(persona, s) -> None:
is_builtin = persona.id in PERSONAS_BY_ID
creds_ok = _has_dedicated_creds(persona.id)
status = "🟢" if persona.enabled else "⚪️ disabled"
creds = "🔑" if creds_ok else "❗️no keys"
with st.expander(f"{status} **{persona.display_name}** (`{persona.id}`) · {creds}"):
if not creds_ok:
st.warning(
f"No dedicated Alpaca paper account keys found. Add to `.env`:\n\n"
f"`ALPACA_{persona.id.upper()}_KEY` and `ALPACA_{persona.id.upper()}_SECRET`\n\n"
"(Until then this agent falls back to the shared/single-account keys.)"
)
with st.form(f"edit_{persona.id}"):
c1, c2, c3 = st.columns([2, 1, 1])
display_name = c1.text_input("Display name", persona.display_name)
enabled = c2.checkbox("Enabled", persona.enabled)
options_quota = c3.number_input("Options quota / night", 0, 4, persona.options_quota)
st.markdown("**Research time** (0 = use global default: "
f"{s.per_agent_wallclock_min} min cap / {s.per_agent_min_minutes} min floor)")
t1, t2 = st.columns(2)
time_budget = t1.number_input(
"Wall-clock cap (min)", 0, 360, persona.time_budget_min or 0, 5,
key=f"tb_{persona.id}",
)
min_research = t2.number_input(
"Research floor (min)", 0, 360, persona.min_research_min or 0, 5,
key=f"mr_{persona.id}",
)
st.markdown("**Risk knobs**")
r1, r2, r3, r4 = st.columns(4)
max_pos = r1.slider("Max position %", 0.01, 0.25, float(persona.max_position_pct), 0.01,
key=f"mp_{persona.id}")
picks = r2.number_input("Picks / night", 1, 8, persona.target_pick_count,
key=f"pk_{persona.id}")
stop = r3.slider("Default stop %", 0.02, 0.25, float(persona.default_stop_pct), 0.01,
key=f"st_{persona.id}")
take = r4.slider("Default take %", 0.05, 0.60, float(persona.default_take_pct), 0.01,
key=f"tk_{persona.id}")
horizon = st.selectbox(
"Preferred horizon", _HORIZONS,
index=_HORIZONS.index(persona.preferred_horizon)
if persona.preferred_horizon in _HORIZONS else 1,
key=f"hz_{persona.id}",
)
risk_style = st.text_input(
"Risk style (shown to the Investment Committee)", persona.risk_style,
key=f"rs_{persona.id}",
)
prompt = st.text_area("System prompt", persona.system_prompt, height=260,
key=f"sp_{persona.id}")
b1, b2 = st.columns([1, 1])
saved = b1.form_submit_button("💾 Save", type="primary")
reset = b2.form_submit_button(
"↩️ Reset to code defaults" if is_builtin else "🗑 Delete agent"
)
if saved:
agent_registry.upsert_agent(
persona.id,
{
"display_name": display_name,
"enabled": enabled,
"system_prompt": prompt,
"max_position_pct": float(max_pos),
"target_pick_count": int(picks),
"options_quota": int(options_quota),
"default_stop_pct": float(stop),
"default_take_pct": float(take),
"preferred_horizon": horizon,
"risk_style": risk_style,
"time_budget_min": int(time_budget) or None,
"min_research_min": int(min_research) or None,
},
)
st.success("Saved — applies from the next nightly run.")
st.rerun()
if reset:
agent_registry.delete_agent(persona.id)
st.success("Reset to code defaults." if is_builtin else "Agent deleted.")
st.rerun()
lessons = _lessons_snippet(persona.id)
if lessons:
st.caption("Track record")
st.code(lessons, language="text")
def _add_agent_form() -> None:
st.subheader("➕ Add a new agent")
templates = list(PERSONAS_BY_ID)
with st.form("add_agent"):
c1, c2 = st.columns([1, 2])
new_id = c1.text_input("Agent id (lowercase, e.g. `scalper`)")
new_name = c2.text_input("Display name")
template = st.selectbox("Clone style & knobs from", templates, index=0)
prompt = st.text_area(
"System prompt (leave blank to copy the template's prompt)", "", height=160,
)
create = st.form_submit_button("Create agent", type="primary")
if create:
if not new_id.strip() or not new_name.strip():
st.error("Both id and display name are required.")
return
base = PERSONAS_BY_ID[template].model_dump()
base.pop("id")
base["display_name"] = new_name.strip()
if prompt.strip():
base["system_prompt"] = prompt.strip()
try:
agent_registry.upsert_agent(new_id, base)
except Exception as e:
st.error(f"Could not create agent: {e}")
return
pid = new_id.strip().lower().replace(" ", "_").replace("-", "_")
st.success(
f"Agent `{pid}` created (enabled). Give it its own Alpaca paper account by adding "
f"`ALPACA_{pid.upper()}_KEY` and `ALPACA_{pid.upper()}_SECRET` to `.env` — "
"until then it uses the shared fallback keys."
)
st.rerun()
def render() -> None:
s = get_settings()
st.caption(
f"Roster and per-agent settings live in `state/agents.json` — the nightly run reads it "
f"on start. Registry file: `{agent_registry.registry_path()}`"
)
personas = agent_registry.load_personas(include_disabled=True)
enabled_n = sum(1 for p in personas if p.enabled)
st.markdown(f"**{len(personas)} agents** ({enabled_n} enabled)")
for persona in personas:
_agent_card(persona, s)
st.divider()
_add_agent_form()
+103
View File
@@ -0,0 +1,103 @@
"""Shared helpers for the dashboard views: DB access, live Alpaca overview, date listing,
OCC symbol parsing."""
from __future__ import annotations
import re
import sqlite3
import pandas as pd
import streamlit as st
from config.settings import get_settings
S = get_settings()
_OCC_RE = re.compile(r"^([A-Z]+)(\d{6})([CP])(\d{8})$")
def _con() -> sqlite3.Connection | None:
if not S.db_path.exists():
return None
con = sqlite3.connect(str(S.db_path))
con.row_factory = sqlite3.Row
return con
def query_df(sql: str, params: tuple = ()) -> pd.DataFrame:
con = _con()
if con is None:
return pd.DataFrame()
try:
return pd.read_sql_query(sql, con, params=params)
except Exception:
return pd.DataFrame()
finally:
con.close()
def trade_dates() -> list[str]:
if not S.output_dir.exists():
return []
return sorted((p.name for p in S.output_dir.iterdir() if p.is_dir()), reverse=True)
@st.cache_data(ttl=60, show_spinner=False)
def live_overview() -> tuple[pd.DataFrame, pd.DataFrame]:
"""Live per-persona account + position data straight from Alpaca (60s cache).
Returns (accounts_df, positions_df) shaped like the DB tables (account = persona id).
Personas without reachable creds are skipped; duplicate underlying accounts (shared-key
fallback) are deduped by Alpaca account number. Empty frames when fully offline —
callers fall back to the last DB snapshot.
"""
from config.agent_registry import load_personas
from execution.broker import for_persona
acc_rows, pos_rows, seen = [], [], set()
for p in load_personas():
try:
b = for_persona(p.id)
acct = b.get_account()
if acct.account_number in seen:
continue
seen.add(acct.account_number)
acc_rows.append(
{
"account": p.id,
"equity": float(acct.equity),
"cash": float(acct.cash),
"buying_power": float(acct.buying_power),
}
)
for pos in b.get_positions():
pos_rows.append(
{
"account": p.id,
"symbol": pos.symbol,
"qty": float(pos.qty),
"avg_entry": float(pos.avg_entry_price or 0),
"market_value": float(pos.market_value or 0),
"unrealized_pl": float(pos.unrealized_pl or 0),
}
)
except Exception:
continue
return pd.DataFrame(acc_rows), pd.DataFrame(pos_rows)
def describe_symbol(symbol: str) -> str:
"""Human-readable label for a position/order symbol. OCC option symbols become
'AAPL $295 call 2026-07-24'; plain tickers pass through unchanged."""
m = _OCC_RE.match(str(symbol).strip().upper())
if not m:
return symbol
underlying, yymmdd, right, strike = m.groups()
return (
f"{underlying} ${int(strike) / 1000:g} {'call' if right == 'C' else 'put'} "
f"20{yymmdd[:2]}-{yymmdd[2:4]}-{yymmdd[4:]}"
)
def is_option_symbol(symbol: str) -> bool:
return bool(_OCC_RE.match(str(symbol).strip().upper()))
+18
View File
@@ -0,0 +1,18 @@
"""Logs tab: tail any run log."""
from __future__ import annotations
import streamlit as st
from web.views.common import S
def render() -> None:
log_files = sorted(S.log_dir.glob("*.log"), reverse=True) if S.log_dir.exists() else []
if not log_files:
st.info("No logs yet.")
return
choice = st.selectbox("Log file", [f.name for f in log_files])
n = st.slider("Lines", 20, 1000, 200, 20)
text = (S.log_dir / choice).read_text().splitlines()[-n:]
st.code("\n".join(text), language="text")
+39
View File
@@ -0,0 +1,39 @@
"""Orders tab: order history with agent / status / date filters."""
from __future__ import annotations
import streamlit as st
from web.views.common import describe_symbol, query_df
def render() -> None:
orders = query_df(
"SELECT trade_date, agent, ticker, side, qty, est_price, stop_price, take_price, "
"status, alpaca_order_id, submitted_at FROM orders ORDER BY submitted_at DESC LIMIT 500"
)
if orders.empty:
st.info("No orders placed yet.")
return
c1, c2, c3 = st.columns(3)
agents = ["all", *sorted(orders["agent"].dropna().unique())]
dates = ["all", *sorted(orders["trade_date"].dropna().unique(), reverse=True)]
agent = c1.selectbox("Agent", agents)
date = c2.selectbox("Date", dates)
status = c3.selectbox("Status", ["all", "ok", "errors"])
view = orders
if agent != "all":
view = view[view["agent"] == agent]
if date != "all":
view = view[view["trade_date"] == date]
if status == "errors":
view = view[view["status"].astype(str).str.startswith("error")]
elif status == "ok":
view = view[~view["status"].astype(str).str.startswith("error")]
view = view.copy()
view.insert(3, "instrument", view["ticker"].map(describe_symbol))
st.caption(f"{len(view)} orders")
st.dataframe(view, use_container_width=True, hide_index=True)
+38
View File
@@ -0,0 +1,38 @@
"""Portfolio tab: per-account positions (options rendered readably) + equity curves."""
from __future__ import annotations
import pandas as pd
import streamlit as st
from web.views.common import describe_symbol, query_df
def render(latest: pd.DataFrame, live_pos: pd.DataFrame | None = None) -> None:
"""Per-account positions. ``live_pos`` (from Alpaca, via common.live_overview) when
online; falls back to the reconciled DB positions table when None."""
if latest.empty:
st.info("No positions recorded yet — run execution once Alpaca keys are set.")
return
for _, acc in latest.iterrows():
st.subheader(f"{acc['account']} · ${acc['equity']:,.0f} equity · ${acc['cash']:,.0f} cash")
if live_pos is not None:
p = live_pos[live_pos["account"] == acc["account"]] if not live_pos.empty else live_pos
p = p.drop(columns=["account"]).sort_values("market_value", ascending=False) if not p.empty else p
else:
p = query_df(
"SELECT symbol, qty, avg_entry, market_value, unrealized_pl FROM positions "
"WHERE account = ? ORDER BY market_value DESC",
(acc["account"],),
)
if p.empty:
st.caption("all cash — no open positions")
else:
p = p.copy()
p.insert(1, "instrument", p["symbol"].map(describe_symbol))
st.dataframe(p, use_container_width=True, hide_index=True)
equity = query_df("SELECT ts, account, equity FROM snapshots ORDER BY ts")
if len(equity) > 1:
st.subheader("Equity curves")
equity["ts"] = pd.to_datetime(equity["ts"])
st.line_chart(equity.pivot_table(index="ts", columns="account", values="equity"))
+33
View File
@@ -0,0 +1,33 @@
"""Reports tab: nightly research notes, committee memo, and PDFs per trade date."""
from __future__ import annotations
import streamlit as st
from web.views.common import S
def render() -> None:
rdir = S.reports_dir
rdates = (
sorted((p.name for p in rdir.iterdir() if p.is_dir()), reverse=True)
if rdir.exists() else []
)
if not rdates:
st.info("No reports yet — generated during the nightly run (state/reports/).")
return
rdate = st.selectbox("Report date", rdates, key="rdate")
ddir = rdir / rdate
# daily.md first, then each agent
mds = sorted(ddir.glob("*.md"), key=lambda p: (p.name != "daily.md", p.name))
for md in mds:
st.markdown(md.read_text())
pdf = md.with_suffix(".pdf")
if pdf.exists():
st.download_button(
f"⬇ Download {pdf.name}", pdf.read_bytes(), file_name=pdf.name,
mime="application/pdf", key=f"dl_{md.stem}",
)
for png in sorted(ddir.glob(f"{md.stem}_*.png")):
st.image(str(png))
st.divider()
+74
View File
@@ -0,0 +1,74 @@
"""Theses tab: each agent's overnight picks, with committee verdicts badged inline."""
from __future__ import annotations
import json
import streamlit as st
from agents.schemas import AgentOutput
from orchestration.committee import load_verdicts
from web.views.common import S, trade_dates
_BADGE = {"approve": "✅", "reduce": "⚠️ reduced", "veto": "🚫 VETOED"}
def _committee_memo(date: str) -> str:
path = S.output_dir / date / "_committee.json"
if not path.exists():
return ""
try:
return str(json.loads(path.read_text()).get("_memo", ""))
except Exception:
return ""
def render() -> None:
dates = trade_dates()
if not dates:
st.info("No nightly output yet. Run: `uv run python -m orchestration.run_nightly --compressed`")
return
date = st.selectbox("Trade date", dates)
verdicts = load_verdicts(date)
memo = _committee_memo(date)
if memo:
with st.expander("🏛️ Investment Committee memo", expanded=False):
st.write(memo)
for (agent, ticker), v in verdicts.items():
if v["verdict"] != "approve":
st.caption(f"{_BADGE.get(v['verdict'], v['verdict'])} **{ticker}** ({agent}) — {v['reason']}")
day_dir = S.output_dir / date
files = sorted(f for f in day_dir.glob("*.json") if not f.name.startswith("_"))
cols = st.columns(max(len(files), 1))
for col, f in zip(cols, files):
try:
out = AgentOutput.model_validate_json(f.read_text())
except Exception as e:
col.error(f"{f.name}: {e}")
continue
with col:
st.markdown(f"### {out.agent_name}")
st.caption(f"status **{out.status.value}** · {len(out.picks)} picks · "
f"{out.steps_used} steps · {out.wall_seconds:.0f}s")
for p in out.picks:
v = verdicts.get((out.persona_id, p.ticker))
badge = f" · {_BADGE[v['verdict']]}" if v and v["verdict"] != "approve" else ""
inst = " · 🎯 option" if p.instrument.value == "option" else ""
with st.expander(f"{p.ticker} · conv {p.conviction:.2f} · {p.time_horizon.value}{inst}{badge}"):
st.write(p.thesis)
if p.instrument.value == "option":
st.caption(f"{p.option_right} {p.contract_symbol} · strike {p.strike} · exp {p.expiry}")
st.caption(
f"weight {p.requested_weight_pct:.0%} · stop {p.stop_loss_pct} · "
f"take {p.take_profit_pct} · {p.direction.value}"
)
if p.catalysts:
st.caption("catalysts: " + ", ".join(p.catalysts))
if v and v["verdict"] != "approve":
st.warning(f"committee: {v['verdict']} — {v['reason']}")
if out.reasoning_trace:
with st.expander("🔍 reasoning trace"):
st.code("\n".join(out.reasoning_trace), language="text")
if out.errors:
st.warning("errors: " + "; ".join(out.errors))