Add dynamic agent registry and options mandate
Introduces a dashboard-managed agent registry (`state/agents.json`) used by nightly research and market-open execution, including support for custom/disabled agents and per-agent time budgets. Expands personas to four agents (adds Maverick), shifts pick targets to include mandatory options, and updates prompts/finalization nudges to enforce option research without hard-failing runs. Strengthens execution safety by adding option-specific guardrails (conviction floor, premium cap, reserved option slot through trade/day/buying-power caps), validates OCC option symbols in schemas, and normalizes invalid option direction cases. The dashboard was refactored into modular views, adds an Agents admin tab, and improves live portfolio visibility with Alpaca-first data and snapshot fallback.
This commit is contained in:
1 parent
b3821abd69
commit
e1ac514f30
36 files changed
+1476
-294
No files matched your search
Whitespace-only changes.
+68
-146
@@ -1,183 +1,105 @@
|
||||
"""ENTRYPOINT #4 — local Streamlit dashboard.
|
||||
|
||||
Read-only over the state DB (positions, snapshots, orders, theses), the nightly output JSON,
|
||||
and the log files. Everything the system did, in one place.
|
||||
Read-only over the trading state (positions, snapshots, orders, theses, reports, logs) plus
|
||||
one read-WRITE surface: the Agents tab, which manages the agent roster in state/agents.json
|
||||
(add/edit/disable agents, per-agent research budgets) — the nightly run reads that registry.
|
||||
|
||||
uv run streamlit run web/dashboard.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pandas as pd # noqa: E402
|
||||
import streamlit as st # noqa: E402
|
||||
|
||||
from agents.schemas import AgentOutput # noqa: E402
|
||||
from config.settings import get_settings # noqa: E402
|
||||
|
||||
S = get_settings()
|
||||
st.set_page_config(page_title="Overnight Trading Agent", layout="wide")
|
||||
|
||||
from web.views import agents_admin, logs, orders, portfolio, reports, theses # noqa: E402
|
||||
from web.views.common import live_overview, query_df # noqa: E402
|
||||
|
||||
def _con() -> sqlite3.Connection | None:
|
||||
if not S.db_path.exists():
|
||||
return None
|
||||
con = sqlite3.connect(str(S.db_path))
|
||||
con.row_factory = sqlite3.Row
|
||||
return con
|
||||
|
||||
|
||||
def _query_df(sql: str, params: tuple = ()) -> pd.DataFrame:
|
||||
con = _con()
|
||||
if con is None:
|
||||
return pd.DataFrame()
|
||||
try:
|
||||
return pd.read_sql_query(sql, con, params=params)
|
||||
except Exception:
|
||||
return pd.DataFrame()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def _trade_dates() -> list[str]:
|
||||
if not S.output_dir.exists():
|
||||
return []
|
||||
return sorted((p.name for p in S.output_dir.iterdir() if p.is_dir()), reverse=True)
|
||||
|
||||
# ---------------- sidebar ----------------
|
||||
_REFRESH = {"Off": 0, "30s": 30, "60s": 60, "5 min": 300}
|
||||
refresh_choice = st.sidebar.selectbox("Auto-refresh", list(_REFRESH), index=0)
|
||||
st.sidebar.caption("Alpaca is the source of truth for live positions.")
|
||||
|
||||
# ---------------- header ----------------
|
||||
st.title("🌙 Overnight Trading Agent")
|
||||
|
||||
# latest snapshot per account (one book per persona)
|
||||
# latest snapshot per account (one book per persona), plus the prior day's close for deltas
|
||||
_LATEST = """
|
||||
SELECT s.account, s.equity, s.cash, s.buying_power FROM snapshots s
|
||||
JOIN (SELECT account, MAX(ts) mt FROM snapshots GROUP BY account) m
|
||||
ON s.account = m.account AND s.ts = m.mt
|
||||
"""
|
||||
latest = _query_df(_LATEST)
|
||||
c1, c2, c3, c4 = st.columns(4)
|
||||
if not latest.empty:
|
||||
c1.metric("Total Equity", f"${latest['equity'].sum():,.0f}")
|
||||
c2.metric("Total Cash", f"${latest['cash'].sum():,.0f}")
|
||||
c3.metric("Accounts", str(len(latest)))
|
||||
pos = _query_df("SELECT COALESCE(SUM(unrealized_pl),0) AS pl FROM positions")
|
||||
c4.metric("Unrealized PnL", f"${pos.iloc[0]['pl']:,.0f}" if not pos.empty else "$0")
|
||||
_PREV = """
|
||||
SELECT s.account, s.equity FROM snapshots s
|
||||
JOIN (
|
||||
SELECT account, MAX(ts) mt FROM snapshots
|
||||
WHERE DATE(ts) < (SELECT DATE(MAX(ts)) FROM snapshots)
|
||||
GROUP BY account
|
||||
) m ON s.account = m.account AND s.ts = m.mt
|
||||
"""
|
||||
# LIVE-first: pull accounts + positions straight from Alpaca (60s cache); the DB snapshot
|
||||
# (written at the last morning reconcile) is only the offline fallback. Deltas always
|
||||
# compare against the previous day's DB snapshot.
|
||||
live_acc, live_pos = live_overview()
|
||||
if not live_acc.empty:
|
||||
latest, source = live_acc, "live"
|
||||
else:
|
||||
c1.info("No account snapshot yet — run execution once Alpaca keys are set.")
|
||||
latest, source = query_df(_LATEST), "snapshot"
|
||||
prev = query_df(_PREV)
|
||||
prev_eq = dict(zip(prev["account"], prev["equity"])) if not prev.empty else {}
|
||||
|
||||
tab_port, tab_theses, tab_reports, tab_orders, tab_logs = st.tabs(
|
||||
["📊 Portfolio", "🧠 Tonight's Theses", "📝 Reports", "🧾 Orders", "📜 Logs"]
|
||||
if latest.empty:
|
||||
st.info("No account data yet — set Alpaca keys in .env (live) or run execution once (snapshot).")
|
||||
else:
|
||||
st.caption("🟢 live from Alpaca (refreshes ≤60s)" if source == "live"
|
||||
else "🟡 offline — showing last reconciled snapshot")
|
||||
total_eq = latest["equity"].sum()
|
||||
total_prev = sum(prev_eq.get(a, e) for a, e in zip(latest["account"], latest["equity"]))
|
||||
if source == "live":
|
||||
upl = live_pos["unrealized_pl"].sum() if not live_pos.empty else 0.0
|
||||
else:
|
||||
pos = query_df("SELECT COALESCE(SUM(unrealized_pl),0) AS pl FROM positions")
|
||||
upl = pos.iloc[0]["pl"] if not pos.empty else 0.0
|
||||
|
||||
cols = st.columns(2 + len(latest))
|
||||
cols[0].metric(
|
||||
"Total Equity", f"${total_eq:,.0f}",
|
||||
delta=f"${total_eq - total_prev:+,.0f}" if prev_eq else None,
|
||||
)
|
||||
cols[1].metric("Unrealized PnL", f"${upl:,.0f}")
|
||||
for col, (_, acc) in zip(cols[2:], latest.iterrows()):
|
||||
p = prev_eq.get(acc["account"])
|
||||
col.metric(
|
||||
acc["account"], f"${acc['equity']:,.0f}",
|
||||
delta=f"${acc['equity'] - p:+,.0f}" if p is not None else None,
|
||||
)
|
||||
|
||||
tab_port, tab_theses, tab_reports, tab_orders, tab_agents, tab_logs = st.tabs(
|
||||
["📊 Portfolio", "🧠 Tonight's Theses", "📝 Reports", "🧾 Orders", "🤖 Agents", "📜 Logs"]
|
||||
)
|
||||
|
||||
# ---------------- portfolio ----------------
|
||||
with tab_port:
|
||||
if latest.empty:
|
||||
st.info("No positions recorded yet — run execution once Alpaca keys are set.")
|
||||
else:
|
||||
for _, acc in latest.iterrows():
|
||||
st.subheader(f"{acc['account']} · ${acc['equity']:,.0f} equity · ${acc['cash']:,.0f} cash")
|
||||
p = _query_df(
|
||||
"SELECT symbol, qty, avg_entry, market_value, unrealized_pl FROM positions "
|
||||
"WHERE account = ? ORDER BY market_value DESC",
|
||||
(acc["account"],),
|
||||
)
|
||||
if p.empty:
|
||||
st.caption("all cash — no open positions")
|
||||
else:
|
||||
st.dataframe(p, use_container_width=True, hide_index=True)
|
||||
equity = _query_df("SELECT ts, account, equity FROM snapshots ORDER BY ts")
|
||||
if len(equity) > 1:
|
||||
st.subheader("Equity curves")
|
||||
equity["ts"] = pd.to_datetime(equity["ts"])
|
||||
st.line_chart(equity.pivot_table(index="ts", columns="account", values="equity"))
|
||||
|
||||
# ---------------- theses ----------------
|
||||
portfolio.render(latest, live_pos if source == "live" else None)
|
||||
with tab_theses:
|
||||
dates = _trade_dates()
|
||||
if not dates:
|
||||
st.info("No nightly output yet. Run: `uv run python -m orchestration.run_nightly --compressed`")
|
||||
else:
|
||||
date = st.selectbox("Trade date", dates)
|
||||
day_dir = S.output_dir / date
|
||||
files = sorted(f for f in day_dir.glob("*.json") if not f.name.startswith("_"))
|
||||
cols = st.columns(max(len(files), 1))
|
||||
for col, f in zip(cols, files):
|
||||
try:
|
||||
out = AgentOutput.model_validate_json(f.read_text())
|
||||
except Exception as e:
|
||||
col.error(f"{f.name}: {e}")
|
||||
continue
|
||||
with col:
|
||||
st.markdown(f"### {out.agent_name}")
|
||||
st.caption(f"status **{out.status.value}** · {len(out.picks)} picks · "
|
||||
f"{out.steps_used} steps · {out.wall_seconds:.0f}s")
|
||||
for p in out.picks:
|
||||
with st.expander(f"{p.ticker} · conv {p.conviction:.2f} · {p.time_horizon.value}"):
|
||||
st.write(p.thesis)
|
||||
st.caption(
|
||||
f"weight {p.requested_weight_pct:.0%} · stop {p.stop_loss_pct} · "
|
||||
f"take {p.take_profit_pct} · {p.direction.value}"
|
||||
)
|
||||
if p.catalysts:
|
||||
st.caption("catalysts: " + ", ".join(p.catalysts))
|
||||
if out.reasoning_trace:
|
||||
with st.expander("🔍 reasoning trace"):
|
||||
st.code("\n".join(out.reasoning_trace), language="text")
|
||||
if out.errors:
|
||||
st.warning("errors: " + "; ".join(out.errors))
|
||||
|
||||
# ---------------- reports ----------------
|
||||
theses.render()
|
||||
with tab_reports:
|
||||
rdir = S.reports_dir
|
||||
rdates = (
|
||||
sorted((p.name for p in rdir.iterdir() if p.is_dir()), reverse=True)
|
||||
if rdir.exists() else []
|
||||
)
|
||||
if not rdates:
|
||||
st.info("No reports yet — generated during the nightly run (state/reports/).")
|
||||
else:
|
||||
rdate = st.selectbox("Report date", rdates, key="rdate")
|
||||
ddir = rdir / rdate
|
||||
# daily.md first, then each agent
|
||||
mds = sorted(ddir.glob("*.md"), key=lambda p: (p.name != "daily.md", p.name))
|
||||
for md in mds:
|
||||
st.markdown(md.read_text())
|
||||
pdf = md.with_suffix(".pdf")
|
||||
if pdf.exists():
|
||||
st.download_button(
|
||||
f"⬇ Download {pdf.name}", pdf.read_bytes(), file_name=pdf.name,
|
||||
mime="application/pdf", key=f"dl_{md.stem}",
|
||||
)
|
||||
for png in sorted(ddir.glob(f"{md.stem}_*.png")):
|
||||
st.image(str(png))
|
||||
st.divider()
|
||||
|
||||
# ---------------- orders ----------------
|
||||
reports.render()
|
||||
with tab_orders:
|
||||
orders = _query_df(
|
||||
"SELECT trade_date, agent, ticker, side, qty, est_price, stop_price, take_price, "
|
||||
"status, alpaca_order_id, submitted_at FROM orders ORDER BY submitted_at DESC LIMIT 200"
|
||||
)
|
||||
if orders.empty:
|
||||
st.info("No orders placed yet.")
|
||||
else:
|
||||
st.dataframe(orders, use_container_width=True, hide_index=True)
|
||||
|
||||
# ---------------- logs ----------------
|
||||
orders.render()
|
||||
with tab_agents:
|
||||
agents_admin.render()
|
||||
with tab_logs:
|
||||
log_files = sorted(S.log_dir.glob("*.log"), reverse=True) if S.log_dir.exists() else []
|
||||
if not log_files:
|
||||
st.info("No logs yet.")
|
||||
else:
|
||||
choice = st.selectbox("Log file", [f.name for f in log_files])
|
||||
n = st.slider("Lines", 20, 1000, 200, 20)
|
||||
text = (S.log_dir / choice).read_text().splitlines()[-n:]
|
||||
st.code("\n".join(text), language="text")
|
||||
logs.render()
|
||||
|
||||
st.caption("Read-only. Refresh the page to update. Alpaca is the source of truth for live positions.")
|
||||
# ---------------- auto-refresh ----------------
|
||||
_interval = _REFRESH[refresh_choice]
|
||||
if _interval:
|
||||
time.sleep(_interval)
|
||||
st.rerun()
|
||||
Whitespace-only changes.
@@ -0,0 +1,178 @@
|
||||
"""Agents tab — the management console for the agent roster.
|
||||
|
||||
Everything here writes through ``config/agent_registry`` to ``state/agents.json``:
|
||||
enable/disable agents, per-agent research time budgets, risk knobs, system prompts, and
|
||||
adding brand-new agents (cloned from a template persona). The nightly run reads the same
|
||||
registry, so changes take effect on the next run — no code edits needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from config import agent_registry
|
||||
from config.personas import PERSONAS_BY_ID
|
||||
from config.settings import _dotenv_extras, get_settings
|
||||
|
||||
_HORIZONS = ["intraday", "swing", "weeks", "months"]
|
||||
|
||||
|
||||
def _has_dedicated_creds(pid: str) -> bool:
|
||||
s = get_settings()
|
||||
key_name = f"ALPACA_{pid.upper()}_KEY"
|
||||
sec_name = f"ALPACA_{pid.upper()}_SECRET"
|
||||
extras = _dotenv_extras()
|
||||
key = getattr(s, f"alpaca_{pid}_key", "") or os.environ.get(key_name, "") or extras.get(key_name, "")
|
||||
sec = getattr(s, f"alpaca_{pid}_secret", "") or os.environ.get(sec_name, "") or extras.get(sec_name, "")
|
||||
return bool(key and sec)
|
||||
|
||||
|
||||
def _lessons_snippet(pid: str) -> str:
|
||||
try:
|
||||
from reporting.journal import lessons_for
|
||||
|
||||
return lessons_for(pid)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _agent_card(persona, s) -> None:
|
||||
is_builtin = persona.id in PERSONAS_BY_ID
|
||||
creds_ok = _has_dedicated_creds(persona.id)
|
||||
status = "🟢" if persona.enabled else "⚪️ disabled"
|
||||
creds = "🔑" if creds_ok else "❗️no keys"
|
||||
with st.expander(f"{status} **{persona.display_name}** (`{persona.id}`) · {creds}"):
|
||||
if not creds_ok:
|
||||
st.warning(
|
||||
f"No dedicated Alpaca paper account keys found. Add to `.env`:\n\n"
|
||||
f"`ALPACA_{persona.id.upper()}_KEY` and `ALPACA_{persona.id.upper()}_SECRET`\n\n"
|
||||
"(Until then this agent falls back to the shared/single-account keys.)"
|
||||
)
|
||||
with st.form(f"edit_{persona.id}"):
|
||||
c1, c2, c3 = st.columns([2, 1, 1])
|
||||
display_name = c1.text_input("Display name", persona.display_name)
|
||||
enabled = c2.checkbox("Enabled", persona.enabled)
|
||||
options_quota = c3.number_input("Options quota / night", 0, 4, persona.options_quota)
|
||||
|
||||
st.markdown("**Research time** (0 = use global default: "
|
||||
f"{s.per_agent_wallclock_min} min cap / {s.per_agent_min_minutes} min floor)")
|
||||
t1, t2 = st.columns(2)
|
||||
time_budget = t1.number_input(
|
||||
"Wall-clock cap (min)", 0, 360, persona.time_budget_min or 0, 5,
|
||||
key=f"tb_{persona.id}",
|
||||
)
|
||||
min_research = t2.number_input(
|
||||
"Research floor (min)", 0, 360, persona.min_research_min or 0, 5,
|
||||
key=f"mr_{persona.id}",
|
||||
)
|
||||
|
||||
st.markdown("**Risk knobs**")
|
||||
r1, r2, r3, r4 = st.columns(4)
|
||||
max_pos = r1.slider("Max position %", 0.01, 0.25, float(persona.max_position_pct), 0.01,
|
||||
key=f"mp_{persona.id}")
|
||||
picks = r2.number_input("Picks / night", 1, 8, persona.target_pick_count,
|
||||
key=f"pk_{persona.id}")
|
||||
stop = r3.slider("Default stop %", 0.02, 0.25, float(persona.default_stop_pct), 0.01,
|
||||
key=f"st_{persona.id}")
|
||||
take = r4.slider("Default take %", 0.05, 0.60, float(persona.default_take_pct), 0.01,
|
||||
key=f"tk_{persona.id}")
|
||||
horizon = st.selectbox(
|
||||
"Preferred horizon", _HORIZONS,
|
||||
index=_HORIZONS.index(persona.preferred_horizon)
|
||||
if persona.preferred_horizon in _HORIZONS else 1,
|
||||
key=f"hz_{persona.id}",
|
||||
)
|
||||
risk_style = st.text_input(
|
||||
"Risk style (shown to the Investment Committee)", persona.risk_style,
|
||||
key=f"rs_{persona.id}",
|
||||
)
|
||||
prompt = st.text_area("System prompt", persona.system_prompt, height=260,
|
||||
key=f"sp_{persona.id}")
|
||||
|
||||
b1, b2 = st.columns([1, 1])
|
||||
saved = b1.form_submit_button("💾 Save", type="primary")
|
||||
reset = b2.form_submit_button(
|
||||
"↩️ Reset to code defaults" if is_builtin else "🗑 Delete agent"
|
||||
)
|
||||
|
||||
if saved:
|
||||
agent_registry.upsert_agent(
|
||||
persona.id,
|
||||
{
|
||||
"display_name": display_name,
|
||||
"enabled": enabled,
|
||||
"system_prompt": prompt,
|
||||
"max_position_pct": float(max_pos),
|
||||
"target_pick_count": int(picks),
|
||||
"options_quota": int(options_quota),
|
||||
"default_stop_pct": float(stop),
|
||||
"default_take_pct": float(take),
|
||||
"preferred_horizon": horizon,
|
||||
"risk_style": risk_style,
|
||||
"time_budget_min": int(time_budget) or None,
|
||||
"min_research_min": int(min_research) or None,
|
||||
},
|
||||
)
|
||||
st.success("Saved — applies from the next nightly run.")
|
||||
st.rerun()
|
||||
if reset:
|
||||
agent_registry.delete_agent(persona.id)
|
||||
st.success("Reset to code defaults." if is_builtin else "Agent deleted.")
|
||||
st.rerun()
|
||||
|
||||
lessons = _lessons_snippet(persona.id)
|
||||
if lessons:
|
||||
st.caption("Track record")
|
||||
st.code(lessons, language="text")
|
||||
|
||||
|
||||
def _add_agent_form() -> None:
|
||||
st.subheader("➕ Add a new agent")
|
||||
templates = list(PERSONAS_BY_ID)
|
||||
with st.form("add_agent"):
|
||||
c1, c2 = st.columns([1, 2])
|
||||
new_id = c1.text_input("Agent id (lowercase, e.g. `scalper`)")
|
||||
new_name = c2.text_input("Display name")
|
||||
template = st.selectbox("Clone style & knobs from", templates, index=0)
|
||||
prompt = st.text_area(
|
||||
"System prompt (leave blank to copy the template's prompt)", "", height=160,
|
||||
)
|
||||
create = st.form_submit_button("Create agent", type="primary")
|
||||
if create:
|
||||
if not new_id.strip() or not new_name.strip():
|
||||
st.error("Both id and display name are required.")
|
||||
return
|
||||
base = PERSONAS_BY_ID[template].model_dump()
|
||||
base.pop("id")
|
||||
base["display_name"] = new_name.strip()
|
||||
if prompt.strip():
|
||||
base["system_prompt"] = prompt.strip()
|
||||
try:
|
||||
agent_registry.upsert_agent(new_id, base)
|
||||
except Exception as e:
|
||||
st.error(f"Could not create agent: {e}")
|
||||
return
|
||||
pid = new_id.strip().lower().replace(" ", "_").replace("-", "_")
|
||||
st.success(
|
||||
f"Agent `{pid}` created (enabled). Give it its own Alpaca paper account by adding "
|
||||
f"`ALPACA_{pid.upper()}_KEY` and `ALPACA_{pid.upper()}_SECRET` to `.env` — "
|
||||
"until then it uses the shared fallback keys."
|
||||
)
|
||||
st.rerun()
|
||||
|
||||
|
||||
def render() -> None:
|
||||
s = get_settings()
|
||||
st.caption(
|
||||
f"Roster and per-agent settings live in `state/agents.json` — the nightly run reads it "
|
||||
f"on start. Registry file: `{agent_registry.registry_path()}`"
|
||||
)
|
||||
personas = agent_registry.load_personas(include_disabled=True)
|
||||
enabled_n = sum(1 for p in personas if p.enabled)
|
||||
st.markdown(f"**{len(personas)} agents** ({enabled_n} enabled)")
|
||||
for persona in personas:
|
||||
_agent_card(persona, s)
|
||||
st.divider()
|
||||
_add_agent_form()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Shared helpers for the dashboard views: DB access, live Alpaca overview, date listing,
|
||||
OCC symbol parsing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from config.settings import get_settings
|
||||
|
||||
S = get_settings()
|
||||
|
||||
_OCC_RE = re.compile(r"^([A-Z]+)(\d{6})([CP])(\d{8})$")
|
||||
|
||||
|
||||
def _con() -> sqlite3.Connection | None:
|
||||
if not S.db_path.exists():
|
||||
return None
|
||||
con = sqlite3.connect(str(S.db_path))
|
||||
con.row_factory = sqlite3.Row
|
||||
return con
|
||||
|
||||
|
||||
def query_df(sql: str, params: tuple = ()) -> pd.DataFrame:
|
||||
con = _con()
|
||||
if con is None:
|
||||
return pd.DataFrame()
|
||||
try:
|
||||
return pd.read_sql_query(sql, con, params=params)
|
||||
except Exception:
|
||||
return pd.DataFrame()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def trade_dates() -> list[str]:
|
||||
if not S.output_dir.exists():
|
||||
return []
|
||||
return sorted((p.name for p in S.output_dir.iterdir() if p.is_dir()), reverse=True)
|
||||
|
||||
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def live_overview() -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""Live per-persona account + position data straight from Alpaca (60s cache).
|
||||
|
||||
Returns (accounts_df, positions_df) shaped like the DB tables (account = persona id).
|
||||
Personas without reachable creds are skipped; duplicate underlying accounts (shared-key
|
||||
fallback) are deduped by Alpaca account number. Empty frames when fully offline —
|
||||
callers fall back to the last DB snapshot.
|
||||
"""
|
||||
from config.agent_registry import load_personas
|
||||
from execution.broker import for_persona
|
||||
|
||||
acc_rows, pos_rows, seen = [], [], set()
|
||||
for p in load_personas():
|
||||
try:
|
||||
b = for_persona(p.id)
|
||||
acct = b.get_account()
|
||||
if acct.account_number in seen:
|
||||
continue
|
||||
seen.add(acct.account_number)
|
||||
acc_rows.append(
|
||||
{
|
||||
"account": p.id,
|
||||
"equity": float(acct.equity),
|
||||
"cash": float(acct.cash),
|
||||
"buying_power": float(acct.buying_power),
|
||||
}
|
||||
)
|
||||
for pos in b.get_positions():
|
||||
pos_rows.append(
|
||||
{
|
||||
"account": p.id,
|
||||
"symbol": pos.symbol,
|
||||
"qty": float(pos.qty),
|
||||
"avg_entry": float(pos.avg_entry_price or 0),
|
||||
"market_value": float(pos.market_value or 0),
|
||||
"unrealized_pl": float(pos.unrealized_pl or 0),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return pd.DataFrame(acc_rows), pd.DataFrame(pos_rows)
|
||||
|
||||
|
||||
def describe_symbol(symbol: str) -> str:
|
||||
"""Human-readable label for a position/order symbol. OCC option symbols become
|
||||
'AAPL $295 call 2026-07-24'; plain tickers pass through unchanged."""
|
||||
m = _OCC_RE.match(str(symbol).strip().upper())
|
||||
if not m:
|
||||
return symbol
|
||||
underlying, yymmdd, right, strike = m.groups()
|
||||
return (
|
||||
f"{underlying} ${int(strike) / 1000:g} {'call' if right == 'C' else 'put'} "
|
||||
f"20{yymmdd[:2]}-{yymmdd[2:4]}-{yymmdd[4:]}"
|
||||
)
|
||||
|
||||
|
||||
def is_option_symbol(symbol: str) -> bool:
|
||||
return bool(_OCC_RE.match(str(symbol).strip().upper()))
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Logs tab: tail any run log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from web.views.common import S
|
||||
|
||||
|
||||
def render() -> None:
|
||||
log_files = sorted(S.log_dir.glob("*.log"), reverse=True) if S.log_dir.exists() else []
|
||||
if not log_files:
|
||||
st.info("No logs yet.")
|
||||
return
|
||||
choice = st.selectbox("Log file", [f.name for f in log_files])
|
||||
n = st.slider("Lines", 20, 1000, 200, 20)
|
||||
text = (S.log_dir / choice).read_text().splitlines()[-n:]
|
||||
st.code("\n".join(text), language="text")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Orders tab: order history with agent / status / date filters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from web.views.common import describe_symbol, query_df
|
||||
|
||||
|
||||
def render() -> None:
|
||||
orders = query_df(
|
||||
"SELECT trade_date, agent, ticker, side, qty, est_price, stop_price, take_price, "
|
||||
"status, alpaca_order_id, submitted_at FROM orders ORDER BY submitted_at DESC LIMIT 500"
|
||||
)
|
||||
if orders.empty:
|
||||
st.info("No orders placed yet.")
|
||||
return
|
||||
|
||||
c1, c2, c3 = st.columns(3)
|
||||
agents = ["all", *sorted(orders["agent"].dropna().unique())]
|
||||
dates = ["all", *sorted(orders["trade_date"].dropna().unique(), reverse=True)]
|
||||
agent = c1.selectbox("Agent", agents)
|
||||
date = c2.selectbox("Date", dates)
|
||||
status = c3.selectbox("Status", ["all", "ok", "errors"])
|
||||
|
||||
view = orders
|
||||
if agent != "all":
|
||||
view = view[view["agent"] == agent]
|
||||
if date != "all":
|
||||
view = view[view["trade_date"] == date]
|
||||
if status == "errors":
|
||||
view = view[view["status"].astype(str).str.startswith("error")]
|
||||
elif status == "ok":
|
||||
view = view[~view["status"].astype(str).str.startswith("error")]
|
||||
|
||||
view = view.copy()
|
||||
view.insert(3, "instrument", view["ticker"].map(describe_symbol))
|
||||
st.caption(f"{len(view)} orders")
|
||||
st.dataframe(view, use_container_width=True, hide_index=True)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Portfolio tab: per-account positions (options rendered readably) + equity curves."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from web.views.common import describe_symbol, query_df
|
||||
|
||||
|
||||
def render(latest: pd.DataFrame, live_pos: pd.DataFrame | None = None) -> None:
|
||||
"""Per-account positions. ``live_pos`` (from Alpaca, via common.live_overview) when
|
||||
online; falls back to the reconciled DB positions table when None."""
|
||||
if latest.empty:
|
||||
st.info("No positions recorded yet — run execution once Alpaca keys are set.")
|
||||
return
|
||||
for _, acc in latest.iterrows():
|
||||
st.subheader(f"{acc['account']} · ${acc['equity']:,.0f} equity · ${acc['cash']:,.0f} cash")
|
||||
if live_pos is not None:
|
||||
p = live_pos[live_pos["account"] == acc["account"]] if not live_pos.empty else live_pos
|
||||
p = p.drop(columns=["account"]).sort_values("market_value", ascending=False) if not p.empty else p
|
||||
else:
|
||||
p = query_df(
|
||||
"SELECT symbol, qty, avg_entry, market_value, unrealized_pl FROM positions "
|
||||
"WHERE account = ? ORDER BY market_value DESC",
|
||||
(acc["account"],),
|
||||
)
|
||||
if p.empty:
|
||||
st.caption("all cash — no open positions")
|
||||
else:
|
||||
p = p.copy()
|
||||
p.insert(1, "instrument", p["symbol"].map(describe_symbol))
|
||||
st.dataframe(p, use_container_width=True, hide_index=True)
|
||||
equity = query_df("SELECT ts, account, equity FROM snapshots ORDER BY ts")
|
||||
if len(equity) > 1:
|
||||
st.subheader("Equity curves")
|
||||
equity["ts"] = pd.to_datetime(equity["ts"])
|
||||
st.line_chart(equity.pivot_table(index="ts", columns="account", values="equity"))
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Reports tab: nightly research notes, committee memo, and PDFs per trade date."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from web.views.common import S
|
||||
|
||||
|
||||
def render() -> None:
|
||||
rdir = S.reports_dir
|
||||
rdates = (
|
||||
sorted((p.name for p in rdir.iterdir() if p.is_dir()), reverse=True)
|
||||
if rdir.exists() else []
|
||||
)
|
||||
if not rdates:
|
||||
st.info("No reports yet — generated during the nightly run (state/reports/).")
|
||||
return
|
||||
rdate = st.selectbox("Report date", rdates, key="rdate")
|
||||
ddir = rdir / rdate
|
||||
# daily.md first, then each agent
|
||||
mds = sorted(ddir.glob("*.md"), key=lambda p: (p.name != "daily.md", p.name))
|
||||
for md in mds:
|
||||
st.markdown(md.read_text())
|
||||
pdf = md.with_suffix(".pdf")
|
||||
if pdf.exists():
|
||||
st.download_button(
|
||||
f"⬇ Download {pdf.name}", pdf.read_bytes(), file_name=pdf.name,
|
||||
mime="application/pdf", key=f"dl_{md.stem}",
|
||||
)
|
||||
for png in sorted(ddir.glob(f"{md.stem}_*.png")):
|
||||
st.image(str(png))
|
||||
st.divider()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Theses tab: each agent's overnight picks, with committee verdicts badged inline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from agents.schemas import AgentOutput
|
||||
from orchestration.committee import load_verdicts
|
||||
from web.views.common import S, trade_dates
|
||||
|
||||
_BADGE = {"approve": "✅", "reduce": "⚠️ reduced", "veto": "🚫 VETOED"}
|
||||
|
||||
|
||||
def _committee_memo(date: str) -> str:
|
||||
path = S.output_dir / date / "_committee.json"
|
||||
if not path.exists():
|
||||
return ""
|
||||
try:
|
||||
return str(json.loads(path.read_text()).get("_memo", ""))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def render() -> None:
|
||||
dates = trade_dates()
|
||||
if not dates:
|
||||
st.info("No nightly output yet. Run: `uv run python -m orchestration.run_nightly --compressed`")
|
||||
return
|
||||
date = st.selectbox("Trade date", dates)
|
||||
verdicts = load_verdicts(date)
|
||||
memo = _committee_memo(date)
|
||||
if memo:
|
||||
with st.expander("🏛️ Investment Committee memo", expanded=False):
|
||||
st.write(memo)
|
||||
for (agent, ticker), v in verdicts.items():
|
||||
if v["verdict"] != "approve":
|
||||
st.caption(f"{_BADGE.get(v['verdict'], v['verdict'])} **{ticker}** ({agent}) — {v['reason']}")
|
||||
|
||||
day_dir = S.output_dir / date
|
||||
files = sorted(f for f in day_dir.glob("*.json") if not f.name.startswith("_"))
|
||||
cols = st.columns(max(len(files), 1))
|
||||
for col, f in zip(cols, files):
|
||||
try:
|
||||
out = AgentOutput.model_validate_json(f.read_text())
|
||||
except Exception as e:
|
||||
col.error(f"{f.name}: {e}")
|
||||
continue
|
||||
with col:
|
||||
st.markdown(f"### {out.agent_name}")
|
||||
st.caption(f"status **{out.status.value}** · {len(out.picks)} picks · "
|
||||
f"{out.steps_used} steps · {out.wall_seconds:.0f}s")
|
||||
for p in out.picks:
|
||||
v = verdicts.get((out.persona_id, p.ticker))
|
||||
badge = f" · {_BADGE[v['verdict']]}" if v and v["verdict"] != "approve" else ""
|
||||
inst = " · 🎯 option" if p.instrument.value == "option" else ""
|
||||
with st.expander(f"{p.ticker} · conv {p.conviction:.2f} · {p.time_horizon.value}{inst}{badge}"):
|
||||
st.write(p.thesis)
|
||||
if p.instrument.value == "option":
|
||||
st.caption(f"{p.option_right} {p.contract_symbol} · strike {p.strike} · exp {p.expiry}")
|
||||
st.caption(
|
||||
f"weight {p.requested_weight_pct:.0%} · stop {p.stop_loss_pct} · "
|
||||
f"take {p.take_profit_pct} · {p.direction.value}"
|
||||
)
|
||||
if p.catalysts:
|
||||
st.caption("catalysts: " + ", ".join(p.catalysts))
|
||||
if v and v["verdict"] != "approve":
|
||||
st.warning(f"committee: {v['verdict']} — {v['reason']}")
|
||||
if out.reasoning_trace:
|
||||
with st.expander("🔍 reasoning trace"):
|
||||
st.code("\n".join(out.reasoning_trace), language="text")
|
||||
if out.errors:
|
||||
st.warning("errors: " + "; ".join(out.errors))
|
||||
Reference in new issue
Block a user