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.
226 lines
9.7 KiB
Python
226 lines
9.7 KiB
Python
"""ENTRYPOINT #2 — morning execution, one account per persona.
|
|
|
|
Gated on Alpaca's clock. For each persona: read its picks, apply the risk guardrails against
|
|
ITS OWN account (equity/buying-power/holdings — no cross-agent dedup, since the books are
|
|
separate), place GTC bracket paper orders in that account, record, and reconcile.
|
|
|
|
uv run python -m execution.run_market_open [--force] [--date YYYY-MM-DD]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
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.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
|
|
from execution import reconcile as reconcile_mod # noqa: E402
|
|
from execution.guardrails import plan_orders # noqa: E402
|
|
from orchestration.budget import next_market_trade_date # noqa: E402
|
|
from orchestration.committee import load_verdicts # noqa: E402
|
|
from reporting import email_report, report_writer # noqa: E402
|
|
from state import db # noqa: E402
|
|
from tools.market_data import latest_price # noqa: E402
|
|
from tools.options import option_premium # noqa: E402
|
|
|
|
|
|
def _load_outputs(output_dir: Path, trade_date: str) -> list[AgentOutput]:
|
|
day_dir = output_dir / trade_date
|
|
outputs = []
|
|
if not day_dir.exists():
|
|
logger.warning("no output dir for {}", trade_date)
|
|
return outputs
|
|
for f in sorted(day_dir.glob("*.json")):
|
|
if f.name.startswith("_"):
|
|
continue
|
|
try:
|
|
outputs.append(AgentOutput.model_validate_json(f.read_text()))
|
|
except Exception as e:
|
|
logger.warning("skip {}: {}", f.name, e)
|
|
return outputs
|
|
|
|
|
|
def main(force: bool = False, date: str | None = None) -> int:
|
|
s = get_settings()
|
|
s.ensure_dirs()
|
|
db.init_db(s.db_path)
|
|
trade_date = date or next_market_trade_date(s.market_tz)
|
|
logger.add(s.log_dir / f"execution_{trade_date}.log", rotation="10 MB", retention="14 days")
|
|
logger.info("=== market-open execution trade_date={} force={} ===", trade_date, force)
|
|
|
|
# 1. market gate (account-agnostic clock)
|
|
try:
|
|
clock = broker_mod.any_broker().get_clock()
|
|
except Exception as e:
|
|
logger.error("cannot reach Alpaca (keys + secrets set in .env?): {}", e)
|
|
return 1
|
|
if not clock.is_open and not force:
|
|
logger.info("market closed (next open {}); nothing to do.", getattr(clock, "next_open", "?"))
|
|
return 0
|
|
|
|
# 2. load the night's picks, index by persona
|
|
outputs = _load_outputs(s.output_dir, trade_date)
|
|
if not outputs:
|
|
logger.warning("no agent outputs to act on.")
|
|
return 0
|
|
|
|
# enforce Investment Committee verdicts: veto = drop, reduce = halve size
|
|
verdicts = load_verdicts(trade_date)
|
|
if verdicts:
|
|
for o in outputs:
|
|
kept = []
|
|
for p in o.picks:
|
|
v = verdicts.get((o.persona_id, p.ticker))
|
|
if v and v["verdict"] == "veto":
|
|
logger.info("committee VETO {} {} — {}", o.persona_id, p.ticker, v["reason"])
|
|
continue
|
|
if v and v["verdict"] == "reduce":
|
|
p.requested_weight_pct = round(p.requested_weight_pct / 2, 4)
|
|
logger.info("committee REDUCE {} {} — {}", o.persona_id, p.ticker, v["reason"])
|
|
kept.append(p)
|
|
o.picks = kept
|
|
by_persona = {o.persona_id: o for o in outputs}
|
|
|
|
# 3. fresh prices — equity by ticker, options by contract premium (per share)
|
|
prices: dict[str, float] = {}
|
|
eq_tickers = {p.ticker for o in outputs for p in o.picks if p.instrument.value == "equity"}
|
|
opt_syms = {
|
|
p.contract_symbol
|
|
for o in outputs for p in o.picks
|
|
if p.instrument.value == "option" and p.contract_symbol
|
|
}
|
|
for t in eq_tickers:
|
|
px = latest_price(t)
|
|
if px:
|
|
prices[t] = px
|
|
for cs in opt_syms:
|
|
prem = option_premium(cs)
|
|
if prem:
|
|
prices[cs] = prem
|
|
|
|
con = db.connect(s.db_path)
|
|
for o in outputs:
|
|
for pick in o.picks:
|
|
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 (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:
|
|
out = by_persona.get(persona.id)
|
|
if not out or not out.picks:
|
|
continue
|
|
try:
|
|
pbroker = broker_mod.for_persona(persona.id)
|
|
acct = pbroker.get_account()
|
|
held = pbroker.held_symbols()
|
|
except Exception as e:
|
|
logger.error("{}: account unreachable (secret set?): {}", persona.id, e)
|
|
continue
|
|
|
|
orders, notes = plan_orders(
|
|
[out], equity=float(acct.equity), buying_power=float(acct.buying_power),
|
|
prices=prices, held_symbols=held, risk=DEFAULT_RISK,
|
|
persona_caps={persona.id: persona.max_position_pct},
|
|
)
|
|
for n in notes:
|
|
logger.info("{} guardrail: {}", persona.id, n)
|
|
logger.info("{}: planned {} orders (equity ${:.0f})", persona.id, len(orders), float(acct.equity))
|
|
|
|
for o in orders:
|
|
# options: buy-to-open the contract (defined risk = premium), entry-only
|
|
if o.instrument == "option":
|
|
try:
|
|
# marketable limit: 5% over mid to clear the spread and fill
|
|
ores = pbroker.submit_option(o.contract_symbol, o.qty, o.est_price * 1.05)
|
|
db.record_order(
|
|
con, trade_date=trade_date, agent=persona.id, ticker=o.contract_symbol,
|
|
side="buy", qty=o.qty, entry_type=f"option_{o.option_right}",
|
|
est_price=o.est_price, stop_price=None, take_price=None,
|
|
alpaca_order_id=ores.alpaca_order_id, status=ores.status,
|
|
)
|
|
total_placed += 1
|
|
except Exception as e:
|
|
logger.error("{} option order failed for {}: {}", persona.id, o.contract_symbol, e)
|
|
db.record_order(
|
|
con, trade_date=trade_date, agent=persona.id, ticker=o.contract_symbol,
|
|
side="buy", qty=o.qty, entry_type=f"option_{o.option_right}",
|
|
est_price=o.est_price, stop_price=None, take_price=None,
|
|
alpaca_order_id=None, status=f"error: {e}",
|
|
)
|
|
continue
|
|
|
|
stop_pct = o.stop_pct if o.stop_pct is not None else DEFAULT_RISK.default_stop_pct
|
|
take_pct = o.take_pct if o.take_pct is not None else DEFAULT_RISK.default_take_pct
|
|
side = "sell" if o.direction == "short" else "buy"
|
|
try:
|
|
res = pbroker.submit_bracket(
|
|
o.ticker, o.qty, o.est_price, stop_pct, take_pct, direction=o.direction
|
|
)
|
|
db.record_order(
|
|
con, trade_date=trade_date, agent=persona.id, ticker=o.ticker, side=side,
|
|
qty=o.qty, entry_type=f"bracket_{o.direction}", est_price=o.est_price,
|
|
stop_price=res.stop_price, take_price=res.take_price,
|
|
alpaca_order_id=res.alpaca_order_id, status=res.status,
|
|
)
|
|
total_placed += 1
|
|
except Exception as e:
|
|
logger.error("{} order failed for {}: {}", persona.id, o.ticker, e)
|
|
db.record_order(
|
|
con, trade_date=trade_date, agent=persona.id, ticker=o.ticker, side=side,
|
|
qty=o.qty, entry_type=f"bracket_{o.direction}", est_price=o.est_price,
|
|
stop_price=None, take_price=None, alpaca_order_id=None, status=f"error: {e}",
|
|
)
|
|
con.commit()
|
|
try:
|
|
reconcile_mod.reconcile(s.db_path, persona.id, pbroker)
|
|
except Exception as e:
|
|
logger.warning("{} reconcile failed: {}", persona.id, e)
|
|
|
|
con.close()
|
|
logger.info("=== execution complete: {} orders placed across accounts ===", total_placed)
|
|
|
|
# daily report: append execution results, render PDF, email it
|
|
try:
|
|
con2 = db.connect(s.db_path)
|
|
rows = con2.execute(
|
|
"SELECT agent, ticker, side, qty, status FROM orders WHERE trade_date=? "
|
|
"ORDER BY agent, ticker",
|
|
(trade_date,),
|
|
).fetchall()
|
|
lines = [f"**{total_placed} orders placed** across accounts for {trade_date}.", ""]
|
|
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"
|
|
detail = ", ".join(
|
|
f"{r['side']} {r['qty']} {r['ticker']} [{r['status']}]" for r in ords
|
|
) or "no orders"
|
|
lines.append(f"- **{persona.id}** (equity {eq}): {detail}")
|
|
con2.close()
|
|
exec_summary = "\n".join(lines)
|
|
daily = report_writer.write_daily_report(trade_date, outputs, exec_summary=exec_summary)
|
|
pdf = report_writer.to_pdf(daily)
|
|
email_report.send_daily(
|
|
trade_date, pdf, summary=f"{total_placed} orders placed for {trade_date}."
|
|
)
|
|
except Exception as e:
|
|
logger.warning("daily report/email step failed: {}", e)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
argv = sys.argv[1:]
|
|
date_arg = argv[argv.index("--date") + 1] if "--date" in argv else None
|
|
raise SystemExit(main(force="--force" in argv, date=date_arg))
|