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:
1 parent
b3821abd69
commit
e1ac514f30
36 files changed
+1476
-294
No files matched your search
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
|
||||
# runtime state (keep dirs via .gitkeep, ignore contents)
|
||||
state/agents.json
|
||||
state/trading.db
|
||||
state/*.db
|
||||
state/output/
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# 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
|
||||
web + live market data, forms a stock thesis, and writes JSON. In the morning at market open a
|
||||
separate job reads that JSON and places
|
||||
web + live market data, forms up to **4 trade theses — at least one of them an options trade** —
|
||||
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
|
||||
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
|
||||
> 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
|
||||
| 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 streamlit run web/dashboard.py` | Dashboard: positions, PnL, theses, orders, logs |
|
||||
| `uv run python -m reporting.report` | CLI summary |
|
||||
|
||||
## How it works
|
||||
`run_nightly` starts oMLX, then runs Nova → Warren-lite → Atlas **sequentially** (one model,
|
||||
time-sliced with rollover budgets). Each agent researches with tools (`run_python` for all math,
|
||||
web search/fetch, Alpaca/Finnhub/yfinance market data, `get_portfolio` for continuity) and its
|
||||
output is checkpointed incrementally to `state/output/<date>/`. A min-steps nudge keeps agents
|
||||
researching broadly instead of stopping early.
|
||||
`run_nightly` starts oMLX, then runs Nova → Warren-lite → Atlas → Maverick **sequentially** (one
|
||||
model, time-sliced with rollover budgets; per-agent budgets are dashboard-editable). The roster
|
||||
comes from `config/agent_registry.py` (built-ins merged with `state/agents.json`). Each agent
|
||||
researches with tools (`run_python` for math, `run_analysis` for data/charts, web search/fetch,
|
||||
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),
|
||||
so the three books — and their PnL — stay separate and comparable. `run_market_open` gates on
|
||||
**Each persona trades its own Alpaca paper account** (Nova→agent1, Warren-lite→agent2,
|
||||
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,
|
||||
buying-power) and places **GTC bracket** orders (marketable-limit entry so stop/take legs survive
|
||||
multi-day holds).
|
||||
buying-power, an options conviction floor + premium cap, and a reserved slot so the mandated
|
||||
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
|
||||
- **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.
|
||||
|
||||
## 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).
|
||||
|
||||
```bash
|
||||
|
||||
+123
-26
@@ -18,7 +18,7 @@ from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
||||
from loguru import logger
|
||||
|
||||
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.settings import get_settings
|
||||
from llm.client import build_llm
|
||||
@@ -26,7 +26,7 @@ from tools import build_toolset
|
||||
|
||||
RESEARCH_KICKOFF = (
|
||||
"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"
|
||||
"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 "
|
||||
@@ -41,23 +41,67 @@ RESEARCH_KICKOFF = (
|
||||
"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
|
||||
# (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 = [
|
||||
"Not done yet. Screen 5-10 MORE candidate tickers in your style you haven't examined; pull "
|
||||
"get_bars / get_news / get_fundamentals on them.",
|
||||
"Build the BEAR case for your current top pick: what would make it wrong? Find data that "
|
||||
"challenges your thesis (web_search + web_fetch recent coverage).",
|
||||
"Use run_analysis to write a real script: fetch prices with yfinance, compute momentum / "
|
||||
"moving averages / volatility / correlations, and save a chart to back your best idea.",
|
||||
"Zoom out: check the macro + sector context (rates, the sector ETF, peers) via web_search and "
|
||||
"get_bars. Does it support or undercut your picks?",
|
||||
"Compare your leading picks head-to-head against 2-3 sector peers on valuation and momentum "
|
||||
"(get_fundamentals + run_analysis). Are you picking the best expression of the thesis?",
|
||||
"Re-examine catalysts and timing for each candidate (earnings dates, events via get_news / "
|
||||
"web_search) and refine entry, stop, and take-profit levels with run_python.",
|
||||
"There's time left — where is your coverage thinnest? If you've examined fewer than 8-10 "
|
||||
"candidates, screen a few MORE tickers in your style (get_bars / get_news / get_fundamentals).",
|
||||
"If you haven't yet stress-tested your favorite idea: build the BEAR case for it. What would "
|
||||
"make it wrong? Look for data that challenges the thesis (web_search + web_fetch).",
|
||||
"Consider hard numbers for your leading idea: a run_analysis script (yfinance download, "
|
||||
"momentum / moving averages / volatility) is far more convincing than a headline.",
|
||||
"Have you checked the bigger picture? Macro + sector context (rates, the sector ETF, peers) "
|
||||
"via web_search and get_bars can confirm or undercut your picks.",
|
||||
"If your top pick hasn't been compared to alternatives: line it up against 2-3 sector peers "
|
||||
"on valuation and momentum. Are you holding the best expression of the thesis?",
|
||||
"Check timing if you haven't: catalysts and dates (earnings, events via get_news / "
|
||||
"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 = (
|
||||
"Research complete. Return a JSON object of the form {{\"picks\": [ ... ]}} containing about "
|
||||
"{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
|
||||
|
||||
|
||||
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]):
|
||||
"""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:])
|
||||
if running_summary:
|
||||
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 = (
|
||||
f"You are {persona.display_name}. Choose picks that fit your style "
|
||||
f"(typical stop ~{persona.default_stop_pct * 100:.0f}%, "
|
||||
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"{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)
|
||||
|
||||
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] = []
|
||||
picks: list[Pick] = []
|
||||
for _ in range(2):
|
||||
try:
|
||||
resp = llm.invoke(
|
||||
[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)
|
||||
picks, errs = _ask()
|
||||
if picks:
|
||||
return picks, errs
|
||||
errors = errs
|
||||
break
|
||||
errors = errs
|
||||
except Exception as 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(
|
||||
@@ -254,6 +339,9 @@ def run_agent(
|
||||
|
||||
date_str = datetime.now().astimezone().strftime("%A %Y-%m-%d")
|
||||
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)
|
||||
kickoff = f"{context_blurb}\n\n{kickoff}"
|
||||
messages: list = [
|
||||
@@ -327,10 +415,19 @@ def run_agent(
|
||||
tool_calls = getattr(ai, "tool_calls", None)
|
||||
if not tool_calls:
|
||||
# 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
|
||||
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
|
||||
messages.append(HumanMessage(content=nudge))
|
||||
reasoning_trace.append(
|
||||
|
||||
@@ -6,11 +6,17 @@ it back. Validators enforce basic sanity so garbage never reaches order placemen
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
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):
|
||||
LONG = "long"
|
||||
@@ -87,9 +93,26 @@ class Pick(BaseModel):
|
||||
if self.instrument == Instrument.OPTION:
|
||||
if not self.contract_symbol:
|
||||
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"):
|
||||
raise ValueError("option pick requires option_right 'call' or 'put'")
|
||||
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
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
||||
@@ -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
@@ -1,6 +1,11 @@
|
||||
"""The three trader personas. Differ by risk knobs, research focus, and prompt voice
|
||||
"""The trader personas. Differ by risk knobs, research focus, and prompt voice
|
||||
— not just flavor text. Each persona's sizing/stop/take defaults feed both the prompt
|
||||
AND the morning guardrail clamps in ``execution/guardrails.py``.
|
||||
|
||||
Prompts follow one template so behavior is consistent across agents:
|
||||
IDENTITY -> OBJECTIVE -> PROCESS -> INSTRUMENT PLAYBOOK -> RISK DISCIPLINE, plus the
|
||||
shared HARD RULES. The built-ins here are defaults; ``config/agent_registry.py`` merges
|
||||
overrides and custom agents from ``state/agents.json`` (edited via the dashboard).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -9,10 +14,20 @@ from pydantic import BaseModel
|
||||
|
||||
_COMMON_RULES = (
|
||||
"\n\nHARD RULES:\n"
|
||||
"- You have tools for market data, web search/fetch, a portfolio view, and a Python "
|
||||
"calculator (`run_python`). ALWAYS use tools for prices, ratios, and any arithmetic. "
|
||||
"NEVER estimate a number yourself.\n"
|
||||
"- You have tools for market data, options chains, web search/fetch, a portfolio view, "
|
||||
"and two Python tools. ALWAYS use tools for prices, ratios, and any arithmetic. NEVER "
|
||||
"estimate a number yourself.\n"
|
||||
"- Python tools: `run_python` = quick stdlib-only math (position sizing, percentages — "
|
||||
"no pandas/yfinance there). `run_analysis` = full analysis scripts with pandas/numpy/"
|
||||
"yfinance/matplotlib/scipy; each call runs in a FRESH empty directory, so every script "
|
||||
"must be self-contained, and you must print() anything you want to read back.\n"
|
||||
"- Ground every claim in data you actually fetched. Cite tickers, prices, and sources.\n"
|
||||
"- OPTIONS: you may buy single-leg CALLS (bullish) or PUTS (bearish). Max loss = the "
|
||||
"premium paid, so an option pick is defined-risk — but premiums decay, so demand a "
|
||||
"specific catalyst inside the option's lifetime. Get real contracts and premiums from "
|
||||
"get_option_chain; never invent a contract symbol.\n"
|
||||
"- SHORTS: you may short stock when your thesis is bearish. Short losses are UNBOUNDED — "
|
||||
"shorts always carry tighter stops than longs.\n"
|
||||
"- You are trading a PAPER account. Be decisive but honest about uncertainty.\n"
|
||||
"- When done researching, output your picks as structured JSON exactly matching the "
|
||||
"requested schema — ticker, direction, conviction, thesis, stop/take, position size."
|
||||
@@ -29,68 +44,158 @@ class Persona(BaseModel):
|
||||
default_take_pct: float
|
||||
preferred_horizon: str
|
||||
tool_emphasis: list[str]
|
||||
# --- registry / scheduling knobs (dashboard-editable via state/agents.json) ---
|
||||
enabled: bool = True
|
||||
time_budget_min: int | None = None # per-agent wall-clock cap; None -> global setting
|
||||
min_research_min: int | None = None # per-agent time floor; None -> global setting
|
||||
options_quota: int = 1 # mandatory option picks per night (0 = options optional)
|
||||
risk_style: str = "balanced" # one-liner for the committee: how to judge this book
|
||||
|
||||
|
||||
NOVA = Persona(
|
||||
id="nova",
|
||||
display_name="Nova — Momentum Growth Hunter",
|
||||
system_prompt=(
|
||||
"You are Nova, an aggressive momentum growth trader. You hunt stocks with strong "
|
||||
"upward momentum, relative strength versus the market, breakouts to new highs, and "
|
||||
"near-term catalysts — earnings beats, product launches, analyst upgrades, and sector "
|
||||
"hype, especially in AI, semiconductors, and high-growth tech. You move fast and trade "
|
||||
"with conviction. You prefer swing trades held days to a few weeks. You size winners "
|
||||
"aggressively but always protect capital with TIGHT stop-losses (~5%) and let winners "
|
||||
"run toward larger take-profits (~20%+). You are skeptical of stale, slow, low-volatility "
|
||||
"names — you want where the flow is going now." + _COMMON_RULES
|
||||
"IDENTITY: You are Nova, an aggressive momentum growth trader.\n\n"
|
||||
"OBJECTIVE: Catch stocks that are moving NOW and ride the move. A good night ends "
|
||||
"with picks that have strong momentum, a live near-term catalyst, and a clear level "
|
||||
"that proves you wrong. You win on hit rate times slugging: tight stops (~5%) on "
|
||||
"every loser, winners run toward +20% or more.\n\n"
|
||||
"PROCESS: (1) Check your portfolio and track record. (2) Screen for relative "
|
||||
"strength vs the market, breakouts to new highs, unusual volume, and catalysts — "
|
||||
"earnings beats, product launches, upgrades, sector hype (AI, semis, high-growth "
|
||||
"tech). (3) For each candidate confirm the trend with get_bars, the catalyst with "
|
||||
"get_news / web_search, and the risk level with run_analysis (moving averages, "
|
||||
"volatility). (4) Pressure-test: what kills this trade, and how fast?\n\n"
|
||||
"INSTRUMENT PLAYBOOK: Default to stock longs on confirmed momentum. Use a long CALL "
|
||||
"instead when a dated catalyst (earnings, launch) lands inside the contract window "
|
||||
"and implied upside is a multiple of the premium. Short stock (or buy a PUT) only on "
|
||||
"clear breakdowns of former leaders. Options are your leverage — spend premium like "
|
||||
"it will expire, because it will.\n\n"
|
||||
"RISK DISCIPLINE: Size winners aggressively but cap any single position near 10% of "
|
||||
"the book. Stops are TIGHT (~5%) and non-negotiable; takes are wide (~20%+). Never "
|
||||
"average down into a falling momentum name — momentum broken is thesis broken."
|
||||
+ _COMMON_RULES
|
||||
),
|
||||
max_position_pct=0.10,
|
||||
target_pick_count=3,
|
||||
target_pick_count=4,
|
||||
default_stop_pct=0.05,
|
||||
default_take_pct=0.20,
|
||||
preferred_horizon="swing",
|
||||
tool_emphasis=["get_bars", "get_news", "web_search"],
|
||||
tool_emphasis=["get_bars", "get_news", "web_search", "get_option_chain"],
|
||||
options_quota=1,
|
||||
risk_style="fast-turn momentum book; expects tight stops and quick exits — judge on stop discipline and catalyst quality",
|
||||
)
|
||||
|
||||
WARREN_LITE = Persona(
|
||||
id="warren_lite",
|
||||
display_name="Warren-lite — Patient Value / Contrarian",
|
||||
system_prompt=(
|
||||
"You are Warren-lite, a patient value and contrarian investor. You buy quality "
|
||||
"businesses trading cheaply relative to fundamentals — low P/E versus peers and history, "
|
||||
"strong free cash flow, healthy balance sheets, and temporary pessimism you believe is "
|
||||
"overdone. You IGNORE hype and momentum. You demand a margin of safety and think in weeks "
|
||||
"to months. You size positions moderately and give theses room to work with WIDER "
|
||||
"stop-losses (~10-12%), targeting steady take-profits. You read fundamentals, earnings, "
|
||||
"and filings carefully and always ask: what is this business actually worth?" + _COMMON_RULES
|
||||
"IDENTITY: You are Warren-lite, a patient value and contrarian investor.\n\n"
|
||||
"OBJECTIVE: Buy quality businesses for less than they are worth and let the gap "
|
||||
"close. A good night ends with picks where you can state what the business is worth, "
|
||||
"why the market is wrong today, and what closes the gap over weeks to months. You "
|
||||
"win by rarely being badly wrong: margin of safety first, upside second.\n\n"
|
||||
"PROCESS: (1) Check your portfolio and track record. (2) Hunt temporary pessimism in "
|
||||
"good businesses: post-selloff quality names, hated sectors, guidance resets the "
|
||||
"market over-punished. (3) For each candidate pull get_fundamentals (P/E vs peers "
|
||||
"and history, free cash flow, balance sheet), read the actual story via web_fetch, "
|
||||
"and value it with run_python / run_analysis — never eyeball a multiple. (4) Ask the "
|
||||
"contrarian question: if this is so cheap, why? Only proceed when the market's "
|
||||
"reason is temporary and yours is structural.\n\n"
|
||||
"INSTRUMENT PLAYBOOK: Default to stock longs with room to work. Use a long CALL with "
|
||||
"distant expiry when a mispriced name has a dated re-rating catalyst (earnings, "
|
||||
"spin-off, guidance reset) — premium buys time for the market to agree. Buy a PUT "
|
||||
"(rarely) when a popular name's fundamentals are visibly deteriorating under hype. "
|
||||
"You IGNORE momentum for momentum's sake.\n\n"
|
||||
"RISK DISCIPLINE: Moderate sizes (cap ~8%), WIDE stops (~10-12%) because your edge "
|
||||
"needs time, steady takes (~15%). A value thesis is invalidated by the business "
|
||||
"getting worse, not by the price wiggling." + _COMMON_RULES
|
||||
),
|
||||
max_position_pct=0.08,
|
||||
target_pick_count=2,
|
||||
target_pick_count=4,
|
||||
default_stop_pct=0.11,
|
||||
default_take_pct=0.15,
|
||||
preferred_horizon="months",
|
||||
tool_emphasis=["get_fundamentals", "web_fetch", "run_python"],
|
||||
options_quota=1,
|
||||
risk_style="patient value book; wide stops are intentional — judge on valuation work and thesis durability, not turnover",
|
||||
)
|
||||
|
||||
ATLAS = Persona(
|
||||
id="atlas",
|
||||
display_name="Atlas — Macro / Event-Driven Thematic",
|
||||
system_prompt=(
|
||||
"You are Atlas, a macro and event-driven thematic trader. You think top-down: the macro "
|
||||
"regime (rates, inflation, growth, the dollar, credit), sector rotation, policy and "
|
||||
"geopolitical catalysts, and scheduled events (FOMC, CPI, jobs reports, major earnings). "
|
||||
"You build positions around a THEME and diversify across a few names or ETFs expressing "
|
||||
"one view. You size moderately and use medium stops and take-profits. You connect macro "
|
||||
"dots to specific tickers. You always ask: what regime are we in, and what catalyst is "
|
||||
"next?" + _COMMON_RULES
|
||||
"IDENTITY: You are Atlas, a macro and event-driven thematic trader.\n\n"
|
||||
"OBJECTIVE: Read the regime, find the theme it favors, and express that theme "
|
||||
"through the best 2-4 instruments. A good night ends with a coherent view — regime, "
|
||||
"catalyst calendar, positioning — and picks that all make money if the view is "
|
||||
"right. You win on being early to rotations, not on single-stock heroics.\n\n"
|
||||
"PROCESS: (1) Check your portfolio and track record. (2) Establish the regime: "
|
||||
"rates, inflation, growth, the dollar, credit — web_search current data and the "
|
||||
"event calendar (FOMC, CPI, jobs, major earnings). (3) Identify which sectors and "
|
||||
"factors the regime rewards; verify with sector ETF price action via get_bars. "
|
||||
"(4) Pick the cleanest expressions — ETFs or leaders — and check each with get_news "
|
||||
"and run_analysis (relative strength vs SPY, correlations). (5) Pressure-test "
|
||||
"against the next scheduled event: does the calendar help or hurt?\n\n"
|
||||
"INSTRUMENT PLAYBOOK: Stock/ETF longs for favored themes; short stock or ETFs for "
|
||||
"themes the regime punishes. Use a long CALL or PUT when a scheduled event (FOMC, "
|
||||
"CPI, earnings) inside the contract window should reprice the theme sharply — "
|
||||
"defined-risk event bets are your specialty.\n\n"
|
||||
"RISK DISCIPLINE: Moderate sizes (cap ~9%), medium stops (~8%) and takes (~15%). "
|
||||
"Diversify within the theme, never all-in on one expression. If the regime read "
|
||||
"changes, the whole theme exits — no orphan positions." + _COMMON_RULES
|
||||
),
|
||||
max_position_pct=0.09,
|
||||
target_pick_count=3,
|
||||
target_pick_count=4,
|
||||
default_stop_pct=0.08,
|
||||
default_take_pct=0.15,
|
||||
preferred_horizon="weeks",
|
||||
tool_emphasis=["web_search", "get_news", "web_fetch"],
|
||||
tool_emphasis=["web_search", "get_news", "web_fetch", "get_option_chain"],
|
||||
options_quota=1,
|
||||
risk_style="top-down thematic book; positions cluster by design — judge theme coherence, flag true over-concentration only",
|
||||
)
|
||||
|
||||
PERSONAS: list[Persona] = [NOVA, WARREN_LITE, ATLAS]
|
||||
MAVERICK = Persona(
|
||||
id="maverick",
|
||||
display_name="Maverick — High-Conviction Options & Momentum Shorts",
|
||||
system_prompt=(
|
||||
"IDENTITY: You are Maverick, the fund's high-risk, high-reward specialist. You take "
|
||||
"the bold shots the other analysts won't — asymmetric bets where being right pays "
|
||||
"for several misses.\n\n"
|
||||
"OBJECTIVE: Maximize absolute returns. A good night ends with a small number of "
|
||||
"aggressive, ASYMMETRIC bets: long options where the payoff is a multiple of the "
|
||||
"premium, and shorts on names that are breaking down. You are aggressive about "
|
||||
"UPSIDE, never careless about downside — every bet has a defined, pre-computed "
|
||||
"maximum loss. Bold thesis, strict math.\n\n"
|
||||
"PROCESS: (1) Check your portfolio and track record. (2) Hunt asymmetry: names with "
|
||||
"imminent catalysts (earnings, FDA/product events, squeezes, guidance), parabolic "
|
||||
"moves near exhaustion, broken stories the market still prices for perfection. "
|
||||
"(3) For each candidate quantify the payoff with real numbers: get_option_chain for "
|
||||
"live premiums, run_python for breakeven, payoff-at-target, and premium-at-risk. "
|
||||
"(4) Kill anything that needs everything to go right — you want bets that pay 3x+ "
|
||||
"the risk if the ONE key thing happens.\n\n"
|
||||
"INSTRUMENT PLAYBOOK: Long CALLS on explosive upside setups with a dated catalyst. "
|
||||
"Long PUTS on breakdowns and over-loved names losing their story — a put IS your "
|
||||
"preferred short when borrow risk or squeeze risk is real, because its loss is "
|
||||
"capped at premium. Short STOCK only on orderly breakdowns with high conviction and "
|
||||
"a tight stop (~6%) — short losses are unbounded, so shorts get the tightest leash "
|
||||
"in your book. Stock longs only when an option on the same idea is overpriced.\n\n"
|
||||
"RISK DISCIPLINE: Bold but bounded: any single position caps near 12% of the book, "
|
||||
"and an option's premium is its max loss — size the premium so a total loss stings "
|
||||
"but never cripples. Let winners run hard (takes ~35%). Never add to a losing "
|
||||
"aggressive bet; the asymmetry is gone once the catalyst path breaks."
|
||||
+ _COMMON_RULES
|
||||
),
|
||||
max_position_pct=0.12,
|
||||
target_pick_count=4,
|
||||
default_stop_pct=0.06,
|
||||
default_take_pct=0.35,
|
||||
preferred_horizon="swing",
|
||||
tool_emphasis=["get_option_chain", "get_option_quote", "get_bars", "get_news"],
|
||||
options_quota=2,
|
||||
risk_style="deliberately aggressive high-beta book (options + shorts); judge on defined-risk discipline and payoff math, not on audacity",
|
||||
)
|
||||
|
||||
PERSONAS: list[Persona] = [NOVA, WARREN_LITE, ATLAS, MAVERICK]
|
||||
PERSONAS_BY_ID: dict[str, Persona] = {p.id: p for p in PERSONAS}
|
||||
+9
-2
@@ -12,13 +12,20 @@ from pydantic import BaseModel
|
||||
class RiskConfig(BaseModel):
|
||||
max_position_pct: float = 0.10 # cap per single position, % of equity
|
||||
max_symbol_exposure_pct: float = 0.15 # aggregate cap per ticker across all agents
|
||||
max_trades_per_agent: int = 3
|
||||
max_trades_per_day: int = 8
|
||||
max_trades_per_agent: int = 4 # 4 trades/day mandate (one of them an option)
|
||||
# NOTE: plan_orders runs once per persona against its own account, so this "day" cap
|
||||
# acts PER ACCOUNT. Kept equal to the per-agent cap on purpose.
|
||||
max_trades_per_day: int = 4
|
||||
allow_shorts: bool = True # shorts enabled; persona prompts decide when to use them
|
||||
default_stop_pct: float = 0.07
|
||||
default_take_pct: float = 0.15
|
||||
min_price: float = 3.0 # skip sub-$3 names
|
||||
max_price: float | None = None
|
||||
# --- options quality gate (the "high probability" mandate, enforced not just prompted) ---
|
||||
# Premium notional cap per option trade (% of equity). 0.10 so one large-cap 30-45DTE
|
||||
# contract (~$600-1000 premium) fits a $10k book — 0.05 made most contracts qty<1.
|
||||
max_option_weight_pct: float = 0.10
|
||||
min_option_conviction: float = 0.6 # drop option picks the agent itself isn't sure about
|
||||
|
||||
|
||||
DEFAULT_RISK = RiskConfig()
|
||||
+42
-4
@@ -16,6 +16,19 @@ from .models import ModelProfile, resolve_profile
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _dotenv_extras() -> dict[str, str]:
|
||||
"""Raw .env entries (uppercased keys). pydantic-settings drops keys that have no
|
||||
Settings field (extra='ignore') and does NOT export them to os.environ — but
|
||||
dashboard-added agents need their ALPACA_<ID>_KEY/SECRET found anyway."""
|
||||
try:
|
||||
from dotenv import dotenv_values
|
||||
|
||||
return {k.upper(): (v or "") for k, v in dotenv_values(REPO_ROOT / ".env").items()}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=REPO_ROOT / ".env",
|
||||
@@ -34,6 +47,8 @@ class Settings(BaseSettings):
|
||||
alpaca_warren_lite_secret: str = ""
|
||||
alpaca_atlas_key: str = ""
|
||||
alpaca_atlas_secret: str = ""
|
||||
alpaca_maverick_key: str = ""
|
||||
alpaca_maverick_secret: str = ""
|
||||
|
||||
# --- Finnhub ---
|
||||
finnhub_api_key: str = ""
|
||||
@@ -133,16 +148,39 @@ class Settings(BaseSettings):
|
||||
return self.active_profile.temperature
|
||||
|
||||
def alpaca_creds(self, persona_id: str) -> tuple[str, str]:
|
||||
"""(key, secret) for a persona's paper account, falling back to the single-account keys."""
|
||||
key = getattr(self, f"alpaca_{persona_id}_key", "") or ""
|
||||
secret = getattr(self, f"alpaca_{persona_id}_secret", "") or ""
|
||||
"""(key, secret) for a persona's paper account, falling back to the single-account keys.
|
||||
|
||||
Dashboard-added agents have no Settings field, so ``ALPACA_<ID>_KEY/SECRET`` is
|
||||
also looked up in the process environment and the raw .env file.
|
||||
"""
|
||||
import os
|
||||
|
||||
pid = persona_id.strip().lower()
|
||||
env_key, env_secret = f"ALPACA_{pid.upper()}_KEY", f"ALPACA_{pid.upper()}_SECRET"
|
||||
extras = _dotenv_extras()
|
||||
key = (
|
||||
getattr(self, f"alpaca_{pid}_key", "")
|
||||
or os.environ.get(env_key, "")
|
||||
or extras.get(env_key, "")
|
||||
)
|
||||
secret = (
|
||||
getattr(self, f"alpaca_{pid}_secret", "")
|
||||
or os.environ.get(env_secret, "")
|
||||
or extras.get(env_secret, "")
|
||||
)
|
||||
if key and secret:
|
||||
return key, secret
|
||||
return self.alpaca_api_key, self.alpaca_secret_key
|
||||
|
||||
def alpaca_data_creds(self) -> tuple[str, str]:
|
||||
"""Any valid creds — Alpaca market data is account-agnostic."""
|
||||
for pid in ("nova", "warren_lite", "atlas"):
|
||||
try:
|
||||
from config.agent_registry import load_personas # lazy: registry imports settings
|
||||
|
||||
ids = [p.id for p in load_personas(include_disabled=True)]
|
||||
except Exception:
|
||||
ids = ["nova", "warren_lite", "atlas", "maverick"]
|
||||
for pid in ids:
|
||||
key, secret = self.alpaca_creds(pid)
|
||||
if key and secret:
|
||||
return key, secret
|
||||
|
||||
+47
-10
@@ -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
|
||||
here; nothing is trusted as-is.
|
||||
|
||||
Pipeline: collect (long-only unless enabled) -> dedup across agents (highest conviction) ->
|
||||
per-agent trade cap -> price/liquidity filter + skip already-held -> size & clamp weight ->
|
||||
per-day cap -> buying-power trim.
|
||||
Pipeline: collect (long-only unless enabled; options quality gate) -> dedup across agents
|
||||
(highest conviction) -> per-agent trade cap (option slot reserved) -> price/liquidity filter
|
||||
+ 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
|
||||
@@ -59,6 +64,8 @@ def plan_orders(
|
||||
notes: list[str] = []
|
||||
|
||||
# 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]] = []
|
||||
for out in agent_outputs:
|
||||
for pick in out.picks:
|
||||
@@ -68,6 +75,12 @@ def plan_orders(
|
||||
if pick.instrument == Instrument.OPTION and pick.direction != Direction.LONG:
|
||||
notes.append(f"drop {pick.ticker} ({out.persona_id}): only long options supported")
|
||||
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))
|
||||
|
||||
# 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]}")
|
||||
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]] = {}
|
||||
for pid, pick in best.values():
|
||||
by_persona.setdefault(pid, []).append(pick)
|
||||
kept: list[tuple[str, Pick]] = []
|
||||
for pid, picks in by_persona.items():
|
||||
picks.sort(key=lambda p: p.conviction, reverse=True)
|
||||
keep = 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")
|
||||
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)
|
||||
priced: list[tuple[str, Pick, float]] = []
|
||||
@@ -107,7 +126,8 @@ def plan_orders(
|
||||
else:
|
||||
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] = []
|
||||
for pid, pick, px in priced:
|
||||
cap = min(
|
||||
@@ -115,6 +135,8 @@ def plan_orders(
|
||||
risk.max_position_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)
|
||||
mult = _multiplier(pick)
|
||||
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)
|
||||
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}")
|
||||
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:
|
||||
return o.qty * o.est_price * o.multiplier
|
||||
|
||||
total = sum(_notional(o) for o in orders)
|
||||
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)
|
||||
notes.append(f"drop {dropped.ticker}: buying power (need ${total:,.0f} > ${buying_power:,.0f})")
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from loguru import logger # 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.settings import get_settings # 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"))
|
||||
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
|
||||
for persona in PERSONAS:
|
||||
for persona in personas:
|
||||
out = by_persona.get(persona.id)
|
||||
if not out or not out.picks:
|
||||
continue
|
||||
@@ -196,7 +198,7 @@ def main(force: bool = False, date: str | None = None) -> int:
|
||||
(trade_date,),
|
||||
).fetchall()
|
||||
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]
|
||||
snap = db.latest_snapshot(con2, persona.id)
|
||||
eq = f"${snap['equity']:,.2f}" if snap else "n/a"
|
||||
|
||||
@@ -23,15 +23,19 @@ from config.settings import get_settings
|
||||
from llm.client import build_llm
|
||||
|
||||
CRO_SYSTEM = (
|
||||
"You are the Chief Risk Officer of a small fund. Three analysts submitted tonight's picks. "
|
||||
"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 "
|
||||
"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. "
|
||||
"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."
|
||||
)
|
||||
|
||||
CRO_PROMPT = (
|
||||
"{regime}\n\n"
|
||||
"ANALYST MANDATES (risk styles):\n{styles}\n\n"
|
||||
"ANALYST TRACK RECORDS:\n{records}\n\n"
|
||||
"TONIGHT'S SUBMITTED PICKS:\n{picks}\n\n"
|
||||
'Return a JSON object: {{"memo": "<a 150-250 word committee memo in plain prose: overall '
|
||||
@@ -82,15 +86,27 @@ def run_committee(
|
||||
return {}
|
||||
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 = {}
|
||||
memo = ""
|
||||
try:
|
||||
resp = build_llm(max_tokens=4096).invoke(
|
||||
[
|
||||
SystemMessage(content=CRO_SYSTEM),
|
||||
SystemMessage(content=CRO_SYSTEM.format(n=len(outputs))),
|
||||
HumanMessage(
|
||||
content=CRO_PROMPT.format(
|
||||
regime=regime or "(regime unavailable)",
|
||||
styles=styles_txt or "(none declared)",
|
||||
records=records_txt,
|
||||
picks=picks_txt,
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ from loguru import logger # noqa: E402
|
||||
|
||||
from agents.graph import run_agent # 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 llm import server # 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)
|
||||
|
||||
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
|
||||
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)
|
||||
regime = market_regime()
|
||||
@@ -83,12 +85,18 @@ def main(compressed: bool = False) -> int:
|
||||
|
||||
outputs = []
|
||||
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()
|
||||
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
|
||||
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,
|
||||
datetime.fromtimestamp(deadline).strftime("%H:%M:%S"))
|
||||
if not server.server_up(): # recover if a prior agent left oMLX unresponsive
|
||||
|
||||
Executable → Regular
File mode changed.
Executable → Regular
File mode changed.
Executable → Regular
File mode changed.
Executable → Regular
File mode changed.
Executable → Regular
File mode changed.
@@ -133,7 +133,9 @@ def test_option_sizing_by_premium():
|
||||
o = orders[0]
|
||||
assert o.instrument == "option" and o.multiplier == 100
|
||||
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
|
||||
assert abs(o.weight_pct - 0.10) < 1e-9
|
||||
assert o.qty == 13
|
||||
|
||||
|
||||
@@ -148,3 +150,59 @@ def test_buying_power_trim():
|
||||
assert len(orders) == 1
|
||||
assert orders[0].ticker == "HI"
|
||||
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"
|
||||
@@ -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")
|
||||
@@ -71,3 +71,41 @@ def test_run_manifest_minimal():
|
||||
)
|
||||
assert m.status == "running"
|
||||
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
@@ -1,11 +1,15 @@
|
||||
"""get_portfolio — persona-scoped. Each agent sees ONLY its own paper account's holdings
|
||||
(written by execution/reconcile.py, keyed by persona id), so picks build on that book.
|
||||
Built per-persona via make_get_portfolio() in tools/build_toolset.
|
||||
"""get_portfolio — persona-scoped. Each agent sees ONLY its own paper account.
|
||||
|
||||
LIVE-first: pulls the account + positions straight from Alpaca at call time, so overnight
|
||||
research reflects intraday bracket exits (a stop/take that filled at 2pm is gone from the
|
||||
book by night). Falls back to the last reconciled DB snapshot (execution/reconcile.py)
|
||||
if Alpaca is unreachable — clearly labeled as cached so the agent knows the freshness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@@ -13,44 +17,78 @@ from config.settings import get_settings
|
||||
from state import db as statedb
|
||||
|
||||
|
||||
def _live_portfolio(account: str) -> str | None:
|
||||
"""Live account + positions from Alpaca. None if unreachable/unconfigured."""
|
||||
try:
|
||||
from execution.broker import for_persona
|
||||
|
||||
b = for_persona(account)
|
||||
acct = b.get_account()
|
||||
positions = b.get_positions()
|
||||
except Exception:
|
||||
return None
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
lines = [
|
||||
f"LIVE from Alpaca as of {now}: equity=${float(acct.equity):,.2f}, "
|
||||
f"cash=${float(acct.cash):,.2f}, buying_power=${float(acct.buying_power):,.2f}"
|
||||
]
|
||||
if not positions:
|
||||
lines.append("Holdings: none (all cash).")
|
||||
else:
|
||||
lines.append("Holdings:")
|
||||
for p in positions:
|
||||
lines.append(
|
||||
f" {p.symbol}: {p.qty} @ {p.avg_entry_price} "
|
||||
f"-> mv={p.market_value}, unrealized_pl={p.unrealized_pl}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _snapshot_portfolio(account: str) -> str:
|
||||
"""Last reconciled DB snapshot — the offline fallback."""
|
||||
path = get_settings().db_path
|
||||
if not path.exists():
|
||||
return "No portfolio data yet (fresh account, no trades recorded)."
|
||||
try:
|
||||
con = statedb.connect(path)
|
||||
snap = statedb.latest_snapshot(con, account or None)
|
||||
con.close()
|
||||
except Exception as e:
|
||||
return f"No portfolio data yet ({e})."
|
||||
if snap is None:
|
||||
return "No holdings yet (fresh account, all cash)."
|
||||
|
||||
lines = [
|
||||
f"CACHED snapshot from {snap['ts']} (live fetch unavailable — intraday fills after "
|
||||
f"that time are NOT reflected): equity=${snap['equity']:,.2f}, "
|
||||
f"cash=${snap['cash']:,.2f}, buying_power=${snap['buying_power']:,.2f}"
|
||||
]
|
||||
try:
|
||||
positions = json.loads(snap["positions_json"] or "[]")
|
||||
except Exception:
|
||||
positions = []
|
||||
if not positions:
|
||||
lines.append("Holdings: none (all cash).")
|
||||
else:
|
||||
lines.append("Holdings:")
|
||||
for p in positions:
|
||||
lines.append(
|
||||
f" {p.get('symbol')}: {p.get('qty')} @ {p.get('avg_entry')} "
|
||||
f"-> mv={p.get('market_value')}, unrealized_pl={p.get('unrealized_pl')}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def make_get_portfolio(account: str):
|
||||
"""Return a get_portfolio tool bound to one account (persona id)."""
|
||||
|
||||
@tool
|
||||
def get_portfolio() -> str:
|
||||
"""Return YOUR current paper-account holdings, unrealized PnL, and cash.
|
||||
"""Return YOUR current paper-account holdings, unrealized PnL, and cash
|
||||
(live from Alpaca; falls back to the last recorded snapshot if offline).
|
||||
|
||||
Use this BEFORE proposing trades so you account for what you already own.
|
||||
"""
|
||||
path = get_settings().db_path
|
||||
if not path.exists():
|
||||
return "No portfolio data yet (fresh account, no trades recorded)."
|
||||
try:
|
||||
con = statedb.connect(path)
|
||||
snap = statedb.latest_snapshot(con, account or None)
|
||||
con.close()
|
||||
except Exception as e:
|
||||
return f"No portfolio data yet ({e})."
|
||||
if snap is None:
|
||||
return "No holdings yet (fresh account, all cash)."
|
||||
|
||||
lines = [
|
||||
f"As of {snap['ts']}: equity=${snap['equity']:,.2f}, "
|
||||
f"cash=${snap['cash']:,.2f}, buying_power=${snap['buying_power']:,.2f}"
|
||||
]
|
||||
try:
|
||||
positions = json.loads(snap["positions_json"] or "[]")
|
||||
except Exception:
|
||||
positions = []
|
||||
if not positions:
|
||||
lines.append("Holdings: none (all cash).")
|
||||
else:
|
||||
lines.append("Holdings:")
|
||||
for p in positions:
|
||||
lines.append(
|
||||
f" {p.get('symbol')}: {p.get('qty')} @ {p.get('avg_entry')} "
|
||||
f"-> mv={p.get('market_value')}, unrealized_pl={p.get('unrealized_pl')}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
return _live_portfolio(account) or _snapshot_portfolio(account)
|
||||
|
||||
return get_portfolio
|
||||
@@ -19,8 +19,9 @@ _TIMEOUT_S = 5
|
||||
def run_python(code: str) -> str:
|
||||
"""Execute a short Python snippet for EXACT math and return its stdout.
|
||||
|
||||
Use for ANY arithmetic: position sizing, percentages, P/E, returns, averages, ratios.
|
||||
You MUST print() the result. `math` and `statistics` are importable.
|
||||
Use for ANY arithmetic: position sizing, percentages, P/E, returns, averages, ratios,
|
||||
option breakevens. STANDARD LIBRARY ONLY (`math`, `statistics`) — NO pandas, numpy, or
|
||||
yfinance here; for data analysis use run_analysis instead. You MUST print() the result.
|
||||
Example: `print(round(1000 / 23.55, 4))`. No access to secrets; 5s limit.
|
||||
"""
|
||||
from tools.sandbox import scrubbed_env
|
||||
|
||||
+57
-7
@@ -26,6 +26,44 @@ _STDOUT_LIMIT = 4000
|
||||
_SECRET_PREFIXES = ("ALPACA", "FINNHUB", "OMLX", "EMAIL", "SMTP", "OPENAI", "ANTHROPIC")
|
||||
_ARTIFACT_EXTS = {".png", ".jpg", ".jpeg", ".svg", ".csv", ".txt", ".html", ".md", ".json"}
|
||||
|
||||
_AVAILABLE = "pandas, numpy, yfinance, matplotlib (Agg), scipy + the standard library"
|
||||
|
||||
# Common failure -> actionable hint, appended to the ERROR the agent reads back. Turns the
|
||||
# usual retry-loop ("same error again") into a one-shot self-correction.
|
||||
_ERROR_HINTS: list[tuple[str, str]] = [
|
||||
("ModuleNotFoundError", f"HINT: only {_AVAILABLE} are installed — rewrite using those."),
|
||||
(
|
||||
"KeyError",
|
||||
"HINT: yf.download() returns MultiIndex columns by default. Flatten first: "
|
||||
"df.columns = df.columns.get_level_values(0) — or download with "
|
||||
"yf.download(ticker, period=..., multi_level_index=False).",
|
||||
),
|
||||
(
|
||||
"FileNotFoundError",
|
||||
"HINT: each run_analysis call starts in a FRESH empty directory — files from earlier "
|
||||
"calls are gone. Make the script self-contained (re-download data in this script).",
|
||||
),
|
||||
(
|
||||
"NameError",
|
||||
"HINT: nothing persists between run_analysis calls — define every variable and import "
|
||||
"in THIS script.",
|
||||
),
|
||||
(
|
||||
"JSONDecodeError",
|
||||
"HINT: a data request likely returned empty/error content — print the raw response "
|
||||
"first, and add a fallback ticker/period.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _hint_for(stderr: str) -> str:
|
||||
for needle, hint in _ERROR_HINTS:
|
||||
if needle in stderr:
|
||||
return "\n" + hint
|
||||
if "No data found" in stderr or "no price data" in stderr.lower():
|
||||
return "\nHINT: yfinance returned no data — check the ticker symbol and use a simple period like '6mo'."
|
||||
return ""
|
||||
|
||||
|
||||
def scrubbed_env(home: str) -> dict[str, str]:
|
||||
"""A minimal environment with NO secrets — built from scratch, not inherited."""
|
||||
@@ -45,14 +83,24 @@ def scrubbed_env(home: str) -> dict[str, str]:
|
||||
def run_analysis(code: str) -> str:
|
||||
"""Run a Python analysis script and return its stdout plus any files it wrote.
|
||||
|
||||
Available: pandas, numpy, yfinance (data), matplotlib (charts, Agg), scipy. Use this like a
|
||||
real analyst: fetch data with yfinance, compute indicators/stats/backtests, and save charts
|
||||
with `plt.savefig("chart.png")`. You MUST print() anything you want to read back. Runs in an
|
||||
isolated temp dir with a ~90s limit and NO access to account secrets.
|
||||
ENVIRONMENT — read once, avoid the classic errors:
|
||||
- Installed: pandas, numpy, yfinance (market data), matplotlib (charts, headless Agg),
|
||||
scipy, plus the standard library. Nothing else — no requests/sklearn/talib.
|
||||
- Each call runs in a FRESH EMPTY temp directory. Files and variables do NOT persist
|
||||
between calls: every script must be fully self-contained (imports + data download +
|
||||
computation in one script).
|
||||
- You MUST print() every value you want to read back; save charts with
|
||||
plt.savefig("name.png") (saved files are listed in the reply).
|
||||
- ~90s wall-clock limit; no access to account secrets.
|
||||
- yfinance pitfalls: yf.download() returns MultiIndex columns — flatten with
|
||||
`df.columns = df.columns.get_level_values(0)` (or pass multi_level_index=False);
|
||||
it also auto-adjusts prices by default (auto_adjust=True).
|
||||
|
||||
Use it like a real analyst: fetch data, compute indicators/stats/backtests, save charts.
|
||||
|
||||
Example:
|
||||
import yfinance as yf, matplotlib.pyplot as plt
|
||||
df = yf.download("AAPL", period="6mo")
|
||||
df = yf.download("AAPL", period="6mo", multi_level_index=False)
|
||||
df["SMA50"] = df["Close"].rolling(50).mean()
|
||||
print(df[["Close","SMA50"]].tail())
|
||||
df[["Close","SMA50"]].plot(); plt.savefig("aapl.png")
|
||||
@@ -89,8 +137,10 @@ def run_analysis(code: str) -> str:
|
||||
]
|
||||
|
||||
if proc.returncode != 0:
|
||||
return f"ERROR (exit {proc.returncode}): {err[-1500:] or 'no stderr'}"
|
||||
result = out[:_STDOUT_LIMIT] or "(no stdout — did you print()?)"
|
||||
return f"ERROR (exit {proc.returncode}): {err[-1500:] or 'no stderr'}{_hint_for(err)}"
|
||||
result = out[:_STDOUT_LIMIT] or (
|
||||
"(no stdout — you must print() every value you want to read back)"
|
||||
)
|
||||
if len(out) > _STDOUT_LIMIT:
|
||||
result += "\n...[truncated]"
|
||||
if err:
|
||||
|
||||
@@ -21,7 +21,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "alpaca-py"
|
||||
version = "0.43.4"
|
||||
version = "0.43.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "msgpack" },
|
||||
@@ -32,9 +32,9 @@ dependencies = [
|
||||
{ name = "sseclient-py" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
|
||||
Whitespace-only changes.
+68
-146
@@ -1,183 +1,105 @@
|
||||
"""ENTRYPOINT #4 — local Streamlit dashboard.
|
||||
|
||||
Read-only over the state DB (positions, snapshots, orders, theses), the nightly output JSON,
|
||||
and the log files. Everything the system did, in one place.
|
||||
Read-only over the trading state (positions, snapshots, orders, theses, reports, logs) plus
|
||||
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
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pandas as pd # 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")
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
# ---------------- sidebar ----------------
|
||||
_REFRESH = {"Off": 0, "30s": 30, "60s": 60, "5 min": 300}
|
||||
refresh_choice = st.sidebar.selectbox("Auto-refresh", list(_REFRESH), index=0)
|
||||
st.sidebar.caption("Alpaca is the source of truth for live positions.")
|
||||
|
||||
# ---------------- header ----------------
|
||||
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 = """
|
||||
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
|
||||
ON s.account = m.account AND s.ts = m.mt
|
||||
"""
|
||||
latest = _query_df(_LATEST)
|
||||
c1, c2, c3, c4 = st.columns(4)
|
||||
if not latest.empty:
|
||||
c1.metric("Total Equity", f"${latest['equity'].sum():,.0f}")
|
||||
c2.metric("Total Cash", f"${latest['cash'].sum():,.0f}")
|
||||
c3.metric("Accounts", str(len(latest)))
|
||||
pos = _query_df("SELECT COALESCE(SUM(unrealized_pl),0) AS pl FROM positions")
|
||||
c4.metric("Unrealized PnL", f"${pos.iloc[0]['pl']:,.0f}" if not pos.empty else "$0")
|
||||
_PREV = """
|
||||
SELECT s.account, s.equity FROM snapshots s
|
||||
JOIN (
|
||||
SELECT account, MAX(ts) mt FROM snapshots
|
||||
WHERE DATE(ts) < (SELECT DATE(MAX(ts)) FROM snapshots)
|
||||
GROUP BY account
|
||||
) m ON s.account = m.account AND s.ts = m.mt
|
||||
"""
|
||||
# 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:
|
||||
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(
|
||||
["📊 Portfolio", "🧠 Tonight's Theses", "📝 Reports", "🧾 Orders", "📜 Logs"]
|
||||
if latest.empty:
|
||||
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:
|
||||
if latest.empty:
|
||||
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 ----------------
|
||||
portfolio.render(latest, live_pos if source == "live" else None)
|
||||
with tab_theses:
|
||||
dates = _trade_dates()
|
||||
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 ----------------
|
||||
theses.render()
|
||||
with tab_reports:
|
||||
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/).")
|
||||
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 ----------------
|
||||
reports.render()
|
||||
with tab_orders:
|
||||
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 200"
|
||||
)
|
||||
if orders.empty:
|
||||
st.info("No orders placed yet.")
|
||||
else:
|
||||
st.dataframe(orders, use_container_width=True, hide_index=True)
|
||||
|
||||
# ---------------- logs ----------------
|
||||
orders.render()
|
||||
with tab_agents:
|
||||
agents_admin.render()
|
||||
with tab_logs:
|
||||
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.")
|
||||
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")
|
||||
logs.render()
|
||||
|
||||
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()
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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()))
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user