diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..5be44ca --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..aed7c95 --- /dev/null +++ b/.claude/settings.local.json @@ -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 *)" + ] + } +} diff --git a/.gitignore b/.gitignore index d51b35a..602eea4 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 2f995af..08a3653 100644 --- a/README.md +++ b/README.md @@ -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//`. 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//`. 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__KEY` / `ALPACA__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 diff --git a/agents/graph.py b/agents/graph.py index 3c3aefd..da74d15 100644 --- a/agents/graph.py +++ b/agents/graph.py @@ -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( diff --git a/agents/schemas.py b/agents/schemas.py index b66bd49..6d6efd2 100644 --- a/agents/schemas.py +++ b/agents/schemas.py @@ -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") diff --git a/config/agent_registry.py b/config/agent_registry.py new file mode 100644 index 0000000..ce08c17 --- /dev/null +++ b/config/agent_registry.py @@ -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) diff --git a/config/personas.py b/config/personas.py index 1743ea6..0adbc92 100644 --- a/config/personas.py +++ b/config/personas.py @@ -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} diff --git a/config/risk.py b/config/risk.py index 25f5f95..bbd3609 100644 --- a/config/risk.py +++ b/config/risk.py @@ -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() diff --git a/config/settings.py b/config/settings.py index 24e7ddc..33a003b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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__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__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 diff --git a/execution/guardrails.py b/execution/guardrails.py index 9f0e84e..77df1a5 100644 --- a/execution/guardrails.py +++ b/execution/guardrails.py @@ -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})") diff --git a/execution/run_market_open.py b/execution/run_market_open.py index 7943830..c07051f 100644 --- a/execution/run_market_open.py +++ b/execution/run_market_open.py @@ -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" diff --git a/orchestration/committee.py b/orchestration/committee.py index 526f0fd..91383e6 100644 --- a/orchestration/committee.py +++ b/orchestration/committee.py @@ -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": " 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 diff --git a/scripts/execute.sh b/scripts/execute.sh old mode 100755 new mode 100644 diff --git a/scripts/install_launchd.sh b/scripts/install_launchd.sh old mode 100755 new mode 100644 diff --git a/scripts/nightly.sh b/scripts/nightly.sh old mode 100755 new mode 100644 diff --git a/scripts/run_dashboard.sh b/scripts/run_dashboard.sh old mode 100755 new mode 100644 diff --git a/scripts/uninstall_launchd.sh b/scripts/uninstall_launchd.sh old mode 100755 new mode 100644 diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index 57eae92..467cbae 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -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" diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..5112f6b --- /dev/null +++ b/tests/test_registry.py @@ -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") diff --git a/tests/test_schemas.py b/tests/test_schemas.py index e80e530..8629951 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -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") diff --git a/tools/portfolio.py b/tools/portfolio.py index 19dcbfb..e8522d2 100644 --- a/tools/portfolio.py +++ b/tools/portfolio.py @@ -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 diff --git a/tools/python_exec.py b/tools/python_exec.py index 5077750..982d84f 100644 --- a/tools/python_exec.py +++ b/tools/python_exec.py @@ -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 diff --git a/tools/sandbox.py b/tools/sandbox.py index 7bde1f0..833fa5c 100644 --- a/tools/sandbox.py +++ b/tools/sandbox.py @@ -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: diff --git a/uv.lock b/uv.lock index 9c4db27..a7d5712 100644 --- a/uv.lock +++ b/uv.lock @@ -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]] diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/dashboard.py b/web/dashboard.py index d40f0ff..8475524 100644 --- a/web/dashboard.py +++ b/web/dashboard.py @@ -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() diff --git a/web/views/__init__.py b/web/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/views/agents_admin.py b/web/views/agents_admin.py new file mode 100644 index 0000000..f082bd1 --- /dev/null +++ b/web/views/agents_admin.py @@ -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() diff --git a/web/views/common.py b/web/views/common.py new file mode 100644 index 0000000..3a04e85 --- /dev/null +++ b/web/views/common.py @@ -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())) diff --git a/web/views/logs.py b/web/views/logs.py new file mode 100644 index 0000000..20641b5 --- /dev/null +++ b/web/views/logs.py @@ -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") diff --git a/web/views/orders.py b/web/views/orders.py new file mode 100644 index 0000000..2b402b0 --- /dev/null +++ b/web/views/orders.py @@ -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) diff --git a/web/views/portfolio.py b/web/views/portfolio.py new file mode 100644 index 0000000..e4fdfc9 --- /dev/null +++ b/web/views/portfolio.py @@ -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")) diff --git a/web/views/reports.py b/web/views/reports.py new file mode 100644 index 0000000..08efac1 --- /dev/null +++ b/web/views/reports.py @@ -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() diff --git a/web/views/theses.py b/web/views/theses.py new file mode 100644 index 0000000..81e4d13 --- /dev/null +++ b/web/views/theses.py @@ -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))