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
+123
-26
@@ -18,7 +18,7 @@ from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
||||
from loguru import logger
|
||||
|
||||
from agents import compaction
|
||||
from agents.schemas import AgentOutput, AgentStatus, Pick
|
||||
from agents.schemas import AgentOutput, AgentStatus, Instrument, Pick
|
||||
from config.personas import Persona
|
||||
from config.settings import get_settings
|
||||
from llm.client import build_llm
|
||||
@@ -26,7 +26,7 @@ from tools import build_toolset
|
||||
|
||||
RESEARCH_KICKOFF = (
|
||||
"Today is {date}. This is DEEP overnight research — be exhaustive, not a quick scan. "
|
||||
"Choose {n} strong stocks that fit your style.\n\n"
|
||||
"Choose {n} strong picks that fit your style.\n\n"
|
||||
"1. Call get_portfolio first (your current holdings).\n"
|
||||
"2. Build a candidate list of 8-12 tickers in your style, then investigate EACH one: "
|
||||
"get_quote, get_bars (trend/momentum), get_news (catalysts), get_fundamentals "
|
||||
@@ -41,23 +41,67 @@ RESEARCH_KICKOFF = (
|
||||
"call get_option_chain(underlying, 'call' or 'put') and copy an exact contract_symbol."
|
||||
)
|
||||
|
||||
# Appended to the kickoff when the persona has an options quota (all built-ins do).
|
||||
OPTIONS_MANDATE = (
|
||||
"\n\nOPTIONS MANDATE — {opt} of your {n} picks MUST be a single-leg option trade, and it "
|
||||
"must be your HIGHEST-probability idea, not an afterthought:\n"
|
||||
"- Take your single strongest thesis and express it as a CALL (bullish) or PUT (bearish).\n"
|
||||
"- Pull get_option_chain for it and evaluate 2-3 contracts: premium, bid/ask spread, "
|
||||
"~30-45 days to expiration.\n"
|
||||
"- Use run_python to compute the breakeven price and the premium at risk. The underlying "
|
||||
"must have a specific catalyst INSIDE the contract window.\n"
|
||||
"- BUDGET CHECK: one contract costs premium x 100, and the risk system caps an option "
|
||||
"trade near 10% of your account equity (check equity via get_portfolio). If your favorite "
|
||||
"contract doesn't fit, choose a different expression that does — further strike, nearer "
|
||||
"expiry, or a cheaper underlying — your call based on the market.\n"
|
||||
"- Only submit an option pick you'd give conviction >= 0.7 after pressure-testing; "
|
||||
"low-conviction option picks are dropped by the risk system and waste a trade slot."
|
||||
)
|
||||
|
||||
# Appended to every kickoff: how the two Python tools differ and how the sandbox behaves.
|
||||
# Most historical agent errors were sandbox misuse (pandas in run_python, expecting files to
|
||||
# persist, forgetting print) — this onboarding block prevents them up front.
|
||||
SANDBOX_GUIDE = (
|
||||
"\n\nPYTHON TOOLS GUIDE:\n"
|
||||
"- run_python: quick exact math ONLY (stdlib: math, statistics). No pandas/yfinance here.\n"
|
||||
"- run_analysis: full scripts with pandas, numpy, yfinance, matplotlib (Agg), scipy. Each "
|
||||
"call runs in a FRESH empty directory — files do NOT persist between calls, so every "
|
||||
"script must be self-contained (re-download data each time).\n"
|
||||
"- Both: you MUST print() anything you want to see; save charts with plt.savefig('name.png')."
|
||||
)
|
||||
|
||||
# Rotating directive nudges — cycled so a long (~2hr) research session stays PRODUCTIVE
|
||||
# (does new, varied work) instead of looping "am I done?". Keep using tools.
|
||||
# (does new, varied work) instead of looping "am I done?". Phrased as coverage guidance,
|
||||
# not commands — the agent keeps ownership of the plan and only fills genuine gaps.
|
||||
DEEPEN_NUDGES = [
|
||||
"Not done yet. Screen 5-10 MORE candidate tickers in your style you haven't examined; pull "
|
||||
"get_bars / get_news / get_fundamentals on them.",
|
||||
"Build the BEAR case for your current top pick: what would make it wrong? Find data that "
|
||||
"challenges your thesis (web_search + web_fetch recent coverage).",
|
||||
"Use run_analysis to write a real script: fetch prices with yfinance, compute momentum / "
|
||||
"moving averages / volatility / correlations, and save a chart to back your best idea.",
|
||||
"Zoom out: check the macro + sector context (rates, the sector ETF, peers) via web_search and "
|
||||
"get_bars. Does it support or undercut your picks?",
|
||||
"Compare your leading picks head-to-head against 2-3 sector peers on valuation and momentum "
|
||||
"(get_fundamentals + run_analysis). Are you picking the best expression of the thesis?",
|
||||
"Re-examine catalysts and timing for each candidate (earnings dates, events via get_news / "
|
||||
"web_search) and refine entry, stop, and take-profit levels with run_python.",
|
||||
"There's time left — where is your coverage thinnest? If you've examined fewer than 8-10 "
|
||||
"candidates, screen a few MORE tickers in your style (get_bars / get_news / get_fundamentals).",
|
||||
"If you haven't yet stress-tested your favorite idea: build the BEAR case for it. What would "
|
||||
"make it wrong? Look for data that challenges the thesis (web_search + web_fetch).",
|
||||
"Consider hard numbers for your leading idea: a run_analysis script (yfinance download, "
|
||||
"momentum / moving averages / volatility) is far more convincing than a headline.",
|
||||
"Have you checked the bigger picture? Macro + sector context (rates, the sector ETF, peers) "
|
||||
"via web_search and get_bars can confirm or undercut your picks.",
|
||||
"If your top pick hasn't been compared to alternatives: line it up against 2-3 sector peers "
|
||||
"on valuation and momentum. Are you holding the best expression of the thesis?",
|
||||
"Check timing if you haven't: catalysts and dates (earnings, events via get_news / "
|
||||
"web_search), then refine entry, stop, and take-profit levels with run_python.",
|
||||
]
|
||||
|
||||
# Takes priority over the rotation while the options mandate has had NO research yet.
|
||||
OPTIONS_NUDGE = (
|
||||
"Before finalizing, remember the options mandate: your strongest thesis must also be "
|
||||
"expressed as a single-leg option. Call get_option_chain on your best idea, compare 2-3 "
|
||||
"contracts (premium, spread, ~30-45 DTE), and compute breakeven with run_python."
|
||||
)
|
||||
|
||||
# Selected in the final stretch of the time floor: converge instead of adding breadth.
|
||||
CONSOLIDATE_NUDGE = (
|
||||
"You're in the final stretch. Stop adding new names — consolidate: rank your candidates, "
|
||||
"confirm entry/stop/take numbers with run_python, and double-check the catalyst dates for "
|
||||
"the picks you intend to submit."
|
||||
)
|
||||
|
||||
FINALIZE_PROMPT = (
|
||||
"Research complete. Return a JSON object of the form {{\"picks\": [ ... ]}} containing about "
|
||||
"{n} picks. Each pick object has these fields:\n"
|
||||
@@ -200,34 +244,75 @@ def parse_picks(text: str, default_stop: float, default_take: float) -> tuple[li
|
||||
return picks, errors
|
||||
|
||||
|
||||
def _count_option_picks(picks: list[Pick]) -> int:
|
||||
return sum(1 for p in picks if p.instrument == Instrument.OPTION)
|
||||
|
||||
|
||||
def _finalize(persona: Persona, running_summary: str, reasoning_trace: list[str]):
|
||||
"""Ask the model for structured picks using a clean finalize context (not the persona's
|
||||
tool-pushing prompt), fed the research findings we gathered. Retries once."""
|
||||
tool-pushing prompt), fed the research findings we gathered. Retries once on a parse
|
||||
failure, and once more if the options quota is unmet and the trace actually contains
|
||||
option-chain data to draw from. Fail-open: an unmet quota never discards the night."""
|
||||
findings = "\n".join(reasoning_trace[-50:])
|
||||
if running_summary:
|
||||
findings = f"Summary:\n{running_summary}\n\nDetails:\n{findings}"
|
||||
quota = persona.options_quota
|
||||
option_line = (
|
||||
f"\nAt least {quota} pick(s) must be OPTION picks (instrument \"option\") — your "
|
||||
"highest-conviction thesis expressed as a call or put, using an exact contract_symbol "
|
||||
"copied from your get_option_chain research. If your findings contain NO real contract "
|
||||
"symbol from get_option_chain, do NOT fabricate one — submit stock picks instead; an "
|
||||
"invented symbol is rejected and wastes the pick." if quota > 0 else ""
|
||||
)
|
||||
human = (
|
||||
f"You are {persona.display_name}. Choose picks that fit your style "
|
||||
f"(typical stop ~{persona.default_stop_pct * 100:.0f}%, "
|
||||
f"take ~{persona.default_take_pct * 100:.0f}%, horizon {persona.preferred_horizon}).\n\n"
|
||||
f"RESEARCH FINDINGS:\n{findings or '(sparse — use your best judgment)'}\n\n"
|
||||
f"{FINALIZE_PROMPT.format(n=persona.target_pick_count)}"
|
||||
f"{FINALIZE_PROMPT.format(n=persona.target_pick_count)}{option_line}"
|
||||
)
|
||||
llm = build_llm(max_tokens=4096) # no tools, no json_mode (json_mode returns empty on oMLX)
|
||||
|
||||
def _ask(extra: str = "") -> tuple[list[Pick], list[str]]:
|
||||
resp = llm.invoke(
|
||||
[SystemMessage(content=FINALIZE_SYSTEM), HumanMessage(content=human + extra)]
|
||||
)
|
||||
content = resp.content if isinstance(resp.content, str) else str(resp.content)
|
||||
return parse_picks(content, persona.default_stop_pct, persona.default_take_pct)
|
||||
|
||||
errors: list[str] = []
|
||||
picks: list[Pick] = []
|
||||
for _ in range(2):
|
||||
try:
|
||||
resp = llm.invoke(
|
||||
[SystemMessage(content=FINALIZE_SYSTEM), HumanMessage(content=human)]
|
||||
)
|
||||
content = resp.content if isinstance(resp.content, str) else str(resp.content)
|
||||
picks, errs = parse_picks(content, persona.default_stop_pct, persona.default_take_pct)
|
||||
picks, errs = _ask()
|
||||
if picks:
|
||||
return picks, errs
|
||||
errors = errs
|
||||
break
|
||||
errors = errs
|
||||
except Exception as e:
|
||||
errors = [f"finalize llm error: {e}"]
|
||||
return [], errors
|
||||
if not picks:
|
||||
return [], errors
|
||||
|
||||
# options quota: one corrective retry, only if the trace has real contracts to use
|
||||
if quota > 0 and _count_option_picks(picks) < quota:
|
||||
trace_has_chain = any("get_option_chain" in line for line in reasoning_trace)
|
||||
if trace_has_chain:
|
||||
try:
|
||||
retry_picks, retry_errs = _ask(
|
||||
f"\n\nYour previous answer had too few option picks ({_count_option_picks(picks)}"
|
||||
f"/{quota} required). Re-issue ALL picks, converting your strongest thesis into "
|
||||
"a single-leg option pick using an exact contract_symbol that appears in the "
|
||||
"research findings. Do not invent contract symbols."
|
||||
)
|
||||
if retry_picks and _count_option_picks(retry_picks) > _count_option_picks(picks):
|
||||
return retry_picks, retry_errs
|
||||
except Exception as e:
|
||||
errors.append(f"options-quota retry failed: {e}")
|
||||
errors.append(
|
||||
f"options mandate unmet: {_count_option_picks(picks)}/{quota} option picks (fail-open)"
|
||||
)
|
||||
return picks, errors
|
||||
|
||||
|
||||
def run_agent(
|
||||
@@ -254,6 +339,9 @@ def run_agent(
|
||||
|
||||
date_str = datetime.now().astimezone().strftime("%A %Y-%m-%d")
|
||||
kickoff = RESEARCH_KICKOFF.format(date=date_str, n=persona.target_pick_count)
|
||||
if persona.options_quota > 0:
|
||||
kickoff += OPTIONS_MANDATE.format(opt=persona.options_quota, n=persona.target_pick_count)
|
||||
kickoff += SANDBOX_GUIDE
|
||||
if context_blurb: # market regime + the agent's own track record (self-learning)
|
||||
kickoff = f"{context_blurb}\n\n{kickoff}"
|
||||
messages: list = [
|
||||
@@ -327,10 +415,19 @@ def run_agent(
|
||||
tool_calls = getattr(ai, "tool_calls", None)
|
||||
if not tool_calls:
|
||||
# Force depth: if the model tries to finalize before the TIME floor (or step floor),
|
||||
# inject a rotating directive nudge so it keeps doing NEW research. Bounded by deadline.
|
||||
# inject a directive nudge so it keeps doing NEW research. Bounded by deadline.
|
||||
# Priority: unmet options mandate first, converge in the final stretch, else rotate.
|
||||
elapsed = time.time() - start
|
||||
if (elapsed < min_seconds or steps_used < min_steps) and time.time() < deadline_ts:
|
||||
nudge = DEEPEN_NUDGES[nudge_count % len(DEEPEN_NUDGES)]
|
||||
options_pending = persona.options_quota > 0 and not any(
|
||||
"get_option_chain" in line for line in reasoning_trace
|
||||
)
|
||||
if options_pending:
|
||||
nudge = OPTIONS_NUDGE
|
||||
elif min_seconds and elapsed > 0.8 * min_seconds:
|
||||
nudge = CONSOLIDATE_NUDGE
|
||||
else:
|
||||
nudge = DEEPEN_NUDGES[nudge_count % len(DEEPEN_NUDGES)]
|
||||
nudge_count += 1
|
||||
messages.append(HumanMessage(content=nudge))
|
||||
reasoning_trace.append(
|
||||
|
||||
@@ -6,11 +6,17 @@ it back. Validators enforce basic sanity so garbage never reaches order placemen
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
# OCC option symbol: ROOT (<=6 chars) + YYMMDD + C/P + strike*1000 as 8 digits,
|
||||
# e.g. AAPL260724C00295000. LLMs sometimes fabricate near-miss symbols; reject them
|
||||
# here so an invented contract can never reach order placement.
|
||||
_OCC_SYMBOL_RE = re.compile(r"^[A-Z.]{1,6}\d{6}[CP]\d{8}$")
|
||||
|
||||
|
||||
class Direction(str, Enum):
|
||||
LONG = "long"
|
||||
@@ -87,9 +93,26 @@ class Pick(BaseModel):
|
||||
if self.instrument == Instrument.OPTION:
|
||||
if not self.contract_symbol:
|
||||
raise ValueError("option pick requires contract_symbol (from get_option_chain)")
|
||||
self.contract_symbol = self.contract_symbol.strip().upper()
|
||||
if not _OCC_SYMBOL_RE.match(self.contract_symbol):
|
||||
raise ValueError(
|
||||
f"contract_symbol '{self.contract_symbol}' is not a valid OCC symbol — "
|
||||
"copy it EXACTLY from get_option_chain output, never construct one"
|
||||
)
|
||||
if (self.option_right or "").lower() not in ("call", "put"):
|
||||
raise ValueError("option pick requires option_right 'call' or 'put'")
|
||||
self.option_right = self.option_right.lower()
|
||||
# We only BUY options. A put marked direction=short is the same bearish bet
|
||||
# expressed sloppily (LLMs do this) — normalize to a long put. A short call has
|
||||
# no long-only equivalent, so reject it outright.
|
||||
if self.direction == Direction.SHORT:
|
||||
if self.option_right == "put":
|
||||
self.direction = Direction.LONG
|
||||
else:
|
||||
raise ValueError(
|
||||
"cannot short a call (selling options unsupported) — express a "
|
||||
"bearish view with a long put instead"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
||||
Reference in new issue
Block a user