From 709870b1d5caf9097383e998391b3f90e03d682d Mon Sep 17 00:00:00 2001 From: Aryan Singh Date: Wed, 1 Jul 2026 16:29:40 -0500 Subject: [PATCH] feat(execution): per-account Alpaca GTC brackets, guardrails, shorts, options, reconcile Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016DvdRZkyCy9NJpHYDdPqmS --- execution/__init__.py | 0 execution/broker.py | 153 ++++++++++++++++++++++++++++++ execution/guardrails.py | 151 ++++++++++++++++++++++++++++++ execution/reconcile.py | 46 +++++++++ execution/run_market_open.py | 177 +++++++++++++++++++++++++++++++++++ state/db.py | 108 +++++++++++++++++++++ tests/test_broker.py | 28 ++++++ tests/test_guardrails.py | 150 +++++++++++++++++++++++++++++ 8 files changed, 813 insertions(+) create mode 100644 execution/__init__.py create mode 100644 execution/broker.py create mode 100644 execution/guardrails.py create mode 100644 execution/reconcile.py create mode 100644 execution/run_market_open.py create mode 100644 state/db.py create mode 100644 tests/test_broker.py create mode 100644 tests/test_guardrails.py diff --git a/execution/__init__.py b/execution/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/execution/broker.py b/execution/broker.py new file mode 100644 index 0000000..597e048 --- /dev/null +++ b/execution/broker.py @@ -0,0 +1,153 @@ +"""Alpaca paper-trading wrapper — one Broker per persona account. + +Each persona trades its own paper account (see settings.alpaca_creds). Bracket design +(resolves the market-entry TIF trap): the entry is a *marketable limit* (limit set a hair +above current price so it fills like a market order at the open) with time_in_force=GTC, so +the take-profit and stop-loss legs are GTC too and survive multi-day holds instead of being +cancelled at session close. Stop/take are derived from the FRESH price at execution time. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from loguru import logger + +from config.settings import get_settings + +_ENTRY_BUFFER = 0.02 # 2% above ref so a limit buy fills promptly at the open + + +@dataclass +class BracketResult: + ticker: str + qty: int + alpaca_order_id: str + status: str + entry_limit: float + stop_price: float + take_price: float + + +@dataclass +class OptionResult: + contract_symbol: str + qty: int + alpaca_order_id: str + status: str + + +def bracket_levels(ref_price: float, stop_pct: float, take_pct: float, direction: str = "long"): + """Pure level math for a marketable-limit bracket. Returns (side, entry, stop, take). + + long: buy entry just ABOVE ref; stop below, take above (stop < entry < take) + short: sell entry just BELOW ref; stop above, take below (take < entry < stop) + Adaptive buffer keeps entry strictly inside the stop/take band. + """ + buffer = min(_ENTRY_BUFFER, take_pct * 0.4, stop_pct * 0.4) + if direction == "short": + entry = round(ref_price * (1 - buffer), 2) + take = round(ref_price * (1 - take_pct), 2) + stop = round(ref_price * (1 + stop_pct), 2) + if not (take < entry < stop): + raise ValueError(f"invalid short bracket: take={take} entry={entry} stop={stop}") + return "sell", entry, stop, take + entry = round(ref_price * (1 + buffer), 2) + take = round(ref_price * (1 + take_pct), 2) + stop = round(ref_price * (1 - stop_pct), 2) + if not (stop < entry < take): + raise ValueError(f"invalid long bracket: stop={stop} entry={entry} take={take}") + return "buy", entry, stop, take + + +class Broker: + def __init__(self, key: str, secret: str, paper: bool = True): + from alpaca.trading.client import TradingClient + + self.client = TradingClient(key, secret, paper=paper) + + def get_clock(self): + return self.client.get_clock() + + def get_account(self): + return self.client.get_account() + + def get_positions(self) -> list: + return self.client.get_all_positions() + + def held_symbols(self) -> set[str]: + try: + return {p.symbol for p in self.get_positions()} + except Exception: + return set() + + def is_tradable(self, symbol: str) -> bool: + try: + return bool(self.client.get_asset(symbol).tradable) + except Exception: + return False + + def submit_bracket( + self, ticker: str, qty: int, ref_price: float, stop_pct: float, take_pct: float, + direction: str = "long", + ) -> BracketResult: + """Submit a marketable-limit GTC bracket around ref_price (long or short).""" + from alpaca.trading.enums import OrderClass, OrderSide, TimeInForce + from alpaca.trading.requests import ( + LimitOrderRequest, + StopLossRequest, + TakeProfitRequest, + ) + + side_str, entry, stop, take = bracket_levels(ref_price, stop_pct, take_pct, direction) + side = OrderSide.SELL if side_str == "sell" else OrderSide.BUY + + req = LimitOrderRequest( + symbol=ticker, + qty=qty, + side=side, + time_in_force=TimeInForce.GTC, # exits persist across sessions + order_class=OrderClass.BRACKET, + limit_price=entry, + take_profit=TakeProfitRequest(limit_price=take), + stop_loss=StopLossRequest(stop_price=stop), + ) + order = self.client.submit_order(req) + logger.info("submitted {} {} x{} bracket entry={} stop={} take={} id={}", + direction, ticker, qty, entry, stop, take, order.id) + return BracketResult( + ticker=ticker, qty=qty, alpaca_order_id=str(order.id), + status=str(order.status), entry_limit=entry, stop_price=stop, take_price=take, + ) + + def submit_option(self, contract_symbol: str, qty: int, limit_price: float) -> OptionResult: + """Buy-to-open a single-leg long option (call/put) with a marketable LIMIT (Alpaca + rejects options market orders). Max loss = premium paid, so no stop leg; Alpaca has no + bracket class for options, so this is entry-only (Phase 6a MVP).""" + from alpaca.trading.enums import OrderSide, TimeInForce + from alpaca.trading.requests import LimitOrderRequest + + req = LimitOrderRequest( + symbol=contract_symbol, qty=qty, side=OrderSide.BUY, + time_in_force=TimeInForce.DAY, limit_price=round(limit_price, 2), + ) + order = self.client.submit_order(req) + logger.info("submitted option BUY {} x{} limit={} id={}", + contract_symbol, qty, round(limit_price, 2), order.id) + return OptionResult( + contract_symbol=contract_symbol, qty=qty, + alpaca_order_id=str(order.id), status=str(order.status), + ) + + +def for_persona(persona_id: str) -> Broker: + s = get_settings() + key, secret = s.alpaca_creds(persona_id) + return Broker(key, secret, paper=s.alpaca_paper) + + +def any_broker() -> Broker: + """Broker for account-agnostic calls (e.g. the market clock). Uses any configured account.""" + s = get_settings() + key, secret = s.alpaca_data_creds() + return Broker(key, secret, paper=s.alpaca_paper) diff --git a/execution/guardrails.py b/execution/guardrails.py new file mode 100644 index 0000000..9f0e84e --- /dev/null +++ b/execution/guardrails.py @@ -0,0 +1,151 @@ +"""Pure, unit-tested risk guardrails. Turns the agents' picks into a safe order plan. + +No network, no Alpaca objects — takes plain data (equity, prices, held symbols) so the +whole pipeline is deterministic and testable. Handles equities and single-leg long options +(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. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import floor + +from agents.schemas import AgentOutput, Direction, Instrument, Pick +from config.risk import RiskConfig + + +@dataclass +class PlannedOrder: + ticker: str + persona_id: str + qty: int + est_price: float # share price (equity) or premium per share (option) + weight_pct: float + conviction: float + stop_pct: float | None + take_pct: float | None + entry_type: str + direction: str = "long" + instrument: str = "equity" + contract_symbol: str | None = None + multiplier: int = 1 # 100 for options + option_right: str | None = None + + +def _exec_key(pick: Pick) -> str: + """The symbol we price/dedup/hold on: contract for options, ticker for equities.""" + return pick.contract_symbol if pick.instrument == Instrument.OPTION else pick.ticker + + +def _multiplier(pick: Pick) -> int: + return 100 if pick.instrument == Instrument.OPTION else 1 + + +def plan_orders( + agent_outputs: list[AgentOutput], + *, + equity: float, + buying_power: float, + prices: dict[str, float], + held_symbols: set[str], + risk: RiskConfig, + persona_caps: dict[str, float], +) -> tuple[list[PlannedOrder], list[str]]: + notes: list[str] = [] + + # 1. collect candidates (long-only unless shorts allowed; options are long-only here) + candidates: list[tuple[str, Pick]] = [] + for out in agent_outputs: + for pick in out.picks: + if pick.direction != Direction.LONG and not risk.allow_shorts: + notes.append(f"drop {pick.ticker} ({out.persona_id}): shorts disabled") + continue + if pick.instrument == Instrument.OPTION and pick.direction != Direction.LONG: + notes.append(f"drop {pick.ticker} ({out.persona_id}): only long options supported") + continue + candidates.append((out.persona_id, pick)) + + # 2. dedup across agents — highest conviction wins the exec key + best: dict[str, tuple[str, Pick]] = {} + for pid, pick in candidates: + k = _exec_key(pick) + cur = best.get(k) + if cur is None or pick.conviction > cur[1].conviction: + if cur is not None: + 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 + 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) + 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]) + + # 4. price / liquidity filter + skip already-held (min_price only applies to equities) + priced: list[tuple[str, Pick, float]] = [] + for pid, pick in kept: + key = _exec_key(pick) + px = prices.get(key) + if not px or px <= 0: + notes.append(f"drop {pick.ticker}: no price") + elif pick.instrument == Instrument.EQUITY and px < risk.min_price: + notes.append(f"drop {pick.ticker}: price {px:.2f} < min {risk.min_price}") + elif pick.instrument == Instrument.EQUITY and risk.max_price and px > risk.max_price: + notes.append(f"drop {pick.ticker}: price {px:.2f} > max {risk.max_price}") + elif key in held_symbols: + notes.append(f"skip {pick.ticker}: already held") + else: + priced.append((pid, pick, px)) + + # 5. clamp weight, size to whole shares/contracts + orders: list[PlannedOrder] = [] + for pid, pick, px in priced: + cap = min( + persona_caps.get(pid, risk.max_position_pct), + risk.max_position_pct, + risk.max_symbol_exposure_pct, + ) + weight = min(pick.requested_weight_pct, cap) + mult = _multiplier(pick) + qty = floor(weight * equity / (px * mult)) + if qty < 1: + notes.append(f"drop {pick.ticker}: qty<1 at weight {weight:.3f}") + continue + orders.append( + PlannedOrder( + ticker=pick.ticker, persona_id=pid, qty=qty, est_price=px, + weight_pct=weight, conviction=pick.conviction, + stop_pct=pick.stop_loss_pct, take_pct=pick.take_profit_pct, + entry_type=pick.entry_type.value, direction=pick.direction.value, + instrument=pick.instrument.value, contract_symbol=pick.contract_symbol, + multiplier=mult, option_right=pick.option_right, + ) + ) + + # 6. per-day cap — rank all by conviction, truncate + orders.sort(key=lambda o: o.conviction, reverse=True) + if len(orders) > risk.max_trades_per_day: + notes.append(f"day cap: {len(orders)} -> {risk.max_trades_per_day}") + orders = orders[: risk.max_trades_per_day] + + # 7. buying-power trim — drop lowest-conviction until it fits (notional = qty*price*mult) + 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() + total -= _notional(dropped) + notes.append(f"drop {dropped.ticker}: buying power (need ${total:,.0f} > ${buying_power:,.0f})") + + return orders, notes diff --git a/execution/reconcile.py b/execution/reconcile.py new file mode 100644 index 0000000..cfd005c --- /dev/null +++ b/execution/reconcile.py @@ -0,0 +1,46 @@ +"""Snapshot one persona's Alpaca account into the state DB (keyed by persona id), so the +dashboard and the next nightly run (via get_portfolio) see that account's holdings + PnL. +""" + +from __future__ import annotations + +from loguru import logger + +from execution.broker import Broker +from state import db + + +def _f(x, default=0.0) -> float: + try: + return float(x) + except (TypeError, ValueError): + return default + + +def reconcile(db_path, account: str, broker: Broker) -> dict: + acct = broker.get_account() + positions = broker.get_positions() + + pos_list = [ + { + "symbol": p.symbol, + "qty": _f(p.qty), + "avg_entry": _f(p.avg_entry_price), + "market_value": _f(p.market_value), + "unrealized_pl": _f(p.unrealized_pl), + } + for p in positions + ] + + con = db.connect(db_path) + db.record_snapshot( + con, account=account, equity=_f(acct.equity), cash=_f(acct.cash), + buying_power=_f(acct.buying_power), positions=pos_list, + ) + for p in pos_list: + db.upsert_position(con, account=account, **p) + con.commit() + con.close() + + logger.info("reconciled {}: equity=${:.2f} positions={}", account, _f(acct.equity), len(pos_list)) + return {"account": account, "equity": _f(acct.equity), "positions": pos_list} diff --git a/execution/run_market_open.py b/execution/run_market_open.py new file mode 100644 index 0000000..658ebec --- /dev/null +++ b/execution/run_market_open.py @@ -0,0 +1,177 @@ +"""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.personas import 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 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 + 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 + 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) + 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)) diff --git a/state/db.py b/state/db.py new file mode 100644 index 0000000..3b41dac --- /dev/null +++ b/state/db.py @@ -0,0 +1,108 @@ +"""SQLite state: audit log + context cache, dimensioned by account (persona). + +Alpaca remains the source of truth for live positions; this DB records what each agent +proposed and did, and caches each account's latest snapshot so a nightly agent can see its +own holdings (tools/portfolio.py) and the dashboard can show all three books. +""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, trade_date TEXT, started_at TEXT, ended_at TEXT, status TEXT +); +CREATE TABLE IF NOT EXISTS theses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_date TEXT, agent TEXT, ticker TEXT, direction TEXT, conviction REAL, + thesis TEXT, requested_weight_pct REAL, stop_pct REAL, take_pct REAL, horizon TEXT, + json_blob TEXT +); +CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_date TEXT, agent TEXT, ticker TEXT, side TEXT, qty INTEGER, entry_type TEXT, + est_price REAL, stop_price REAL, take_price REAL, + alpaca_order_id TEXT, status TEXT, avg_fill_price REAL, submitted_at TEXT +); +CREATE TABLE IF NOT EXISTS snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT, account TEXT, equity REAL, cash REAL, buying_power REAL, positions_json TEXT +); +CREATE TABLE IF NOT EXISTS positions ( + symbol TEXT, account TEXT, qty REAL, avg_entry REAL, market_value REAL, + unrealized_pl REAL, as_of TEXT, PRIMARY KEY (symbol, account) +); +""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def connect(path: str | Path) -> sqlite3.Connection: + con = sqlite3.connect(str(path)) + con.row_factory = sqlite3.Row + return con + + +def init_db(path: str | Path) -> None: + con = connect(path) + con.executescript(SCHEMA) + con.commit() + con.close() + + +def record_thesis(con, trade_date, agent, pick_dict: dict) -> None: + con.execute( + "INSERT INTO theses (trade_date, agent, ticker, direction, conviction, thesis, " + "requested_weight_pct, stop_pct, take_pct, horizon, json_blob) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?)", + ( + trade_date, agent, pick_dict.get("ticker"), pick_dict.get("direction"), + pick_dict.get("conviction"), pick_dict.get("thesis"), + pick_dict.get("requested_weight_pct"), pick_dict.get("stop_loss_pct"), + pick_dict.get("take_profit_pct"), pick_dict.get("time_horizon"), + json.dumps(pick_dict), + ), + ) + + +def record_order(con, *, trade_date, agent, ticker, side, qty, entry_type, est_price, + stop_price, take_price, alpaca_order_id, status, avg_fill_price=None) -> None: + con.execute( + "INSERT INTO orders (trade_date, agent, ticker, side, qty, entry_type, est_price, " + "stop_price, take_price, alpaca_order_id, status, avg_fill_price, submitted_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + (trade_date, agent, ticker, side, qty, entry_type, est_price, stop_price, take_price, + alpaca_order_id, status, avg_fill_price, _now()), + ) + + +def record_snapshot(con, *, account, equity, cash, buying_power, positions: list[dict]) -> None: + con.execute( + "INSERT INTO snapshots (ts, account, equity, cash, buying_power, positions_json) " + "VALUES (?,?,?,?,?,?)", + (_now(), account, equity, cash, buying_power, json.dumps(positions)), + ) + + +def upsert_position(con, *, account, symbol, qty, avg_entry, market_value, unrealized_pl) -> None: + con.execute( + "INSERT INTO positions (symbol, account, qty, avg_entry, market_value, unrealized_pl, " + "as_of) VALUES (?,?,?,?,?,?,?) ON CONFLICT(symbol, account) DO UPDATE SET " + "qty=excluded.qty, avg_entry=excluded.avg_entry, market_value=excluded.market_value, " + "unrealized_pl=excluded.unrealized_pl, as_of=excluded.as_of", + (symbol, account, qty, avg_entry, market_value, unrealized_pl, _now()), + ) + + +def latest_snapshot(con, account: str | None = None) -> sqlite3.Row | None: + if account: + return con.execute( + "SELECT * FROM snapshots WHERE account=? ORDER BY ts DESC LIMIT 1", (account,) + ).fetchone() + return con.execute("SELECT * FROM snapshots ORDER BY ts DESC LIMIT 1").fetchone() diff --git a/tests/test_broker.py b/tests/test_broker.py new file mode 100644 index 0000000..fab8819 --- /dev/null +++ b/tests/test_broker.py @@ -0,0 +1,28 @@ +"""Pure tests for bracket level math (long + short). No network.""" + +from __future__ import annotations + +import pytest + +from execution.broker import bracket_levels + + +def test_long_levels_ordered(): + side, entry, stop, take = bracket_levels(100.0, 0.07, 0.15, "long") + assert side == "buy" + assert stop < entry < take + assert (stop, take) == (93.0, 115.0) + + +def test_short_levels_inverted_and_ordered(): + side, entry, stop, take = bracket_levels(100.0, 0.07, 0.15, "short") + assert side == "sell" + # short: profit below (take), loss above (stop) + assert take < entry < stop + assert (take, stop) == (85.0, 107.0) + + +def test_tiny_take_does_not_invert_long(): + # adaptive buffer keeps entry inside the band even when take_pct is small + side, entry, stop, take = bracket_levels(100.0, 0.10, 0.03, "long") + assert stop < entry < take diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py new file mode 100644 index 0000000..57eae92 --- /dev/null +++ b/tests/test_guardrails.py @@ -0,0 +1,150 @@ +"""Pure-function tests for the risk guardrails — no network, deterministic.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from agents.schemas import AgentOutput, Pick +from config.risk import RiskConfig +from execution.guardrails import plan_orders + +EQUITY = 100_000.0 +CAPS = {"nova": 0.10, "warren_lite": 0.08, "atlas": 0.09} + + +def _out(persona_id: str, picks: list[Pick]) -> AgentOutput: + return AgentOutput( + agent_name=persona_id, + persona_id=persona_id, + started_at=datetime.now(timezone.utc), + picks=picks, + ) + + +def _pick(ticker, conviction=0.7, weight=0.08, direction="long") -> Pick: + return Pick( + ticker=ticker, conviction=conviction, thesis="t", + requested_weight_pct=weight, direction=direction, + ) + + +def _plan(agent_outputs, prices, *, risk=None, held=None, buying_power=EQUITY): + return plan_orders( + agent_outputs, + equity=EQUITY, + buying_power=buying_power, + prices=prices, + held_symbols=held or set(), + risk=risk or RiskConfig(), + persona_caps=CAPS, + ) + + +def test_shorts_dropped_when_disabled(): + orders, notes = _plan( + [_out("nova", [_pick("NVDA"), _pick("TSLA", direction="short")])], + {"NVDA": 200.0, "TSLA": 250.0}, + risk=RiskConfig(allow_shorts=False), + ) + tickers = {o.ticker for o in orders} + assert "NVDA" in tickers and "TSLA" not in tickers + assert any("shorts disabled" in n for n in notes) + + +def test_shorts_kept_when_enabled(): + orders, _ = _plan( + [_out("nova", [_pick("NVDA"), _pick("TSLA", direction="short")])], + {"NVDA": 200.0, "TSLA": 250.0}, + risk=RiskConfig(allow_shorts=True), + ) + by_ticker = {o.ticker: o for o in orders} + assert "TSLA" in by_ticker and by_ticker["TSLA"].direction == "short" + assert by_ticker["NVDA"].direction == "long" + + +def test_dedup_highest_conviction_wins(): + orders, _ = _plan( + [ + _out("nova", [_pick("MSFT", conviction=0.6)]), + _out("atlas", [_pick("MSFT", conviction=0.9)]), + ], + {"MSFT": 400.0}, + ) + msft = [o for o in orders if o.ticker == "MSFT"] + assert len(msft) == 1 + assert msft[0].persona_id == "atlas" and msft[0].conviction == 0.9 + + +def test_per_agent_trade_cap(): + picks = [_pick(t, conviction=c) for t, c in [("A", 0.9), ("B", 0.8), ("C", 0.7), ("D", 0.6)]] + orders, notes = _plan( + [_out("nova", picks)], + {"A": 10.0, "B": 10.0, "C": 10.0, "D": 10.0}, + risk=RiskConfig(max_trades_per_agent=2), + ) + assert len(orders) == 2 + assert {o.ticker for o in orders} == {"A", "B"} # top conviction kept + + +def test_min_price_filter(): + orders, notes = _plan([_out("nova", [_pick("PENNY")])], {"PENNY": 1.5}) + assert orders == [] + assert any("min" in n for n in notes) + + +def test_skip_already_held(): + orders, notes = _plan( + [_out("nova", [_pick("NVDA"), _pick("AMD")])], + {"NVDA": 200.0, "AMD": 150.0}, + held={"NVDA"}, + ) + assert {o.ticker for o in orders} == {"AMD"} + assert any("already held" in n for n in notes) + + +def test_sizing_and_weight_clamp(): + # requested 50% clamps to nova cap 10% -> qty = floor(0.10 * 100000 / 200) = 50 + orders, _ = _plan([_out("nova", [_pick("NVDA", weight=0.50)])], {"NVDA": 200.0}) + assert len(orders) == 1 + o = orders[0] + assert abs(o.weight_pct - 0.10) < 1e-9 + assert o.qty == 50 + + +def test_day_cap_truncates_by_conviction(): + picks = [_pick(f"T{i}", conviction=0.5 + i / 100) for i in range(6)] + orders, notes = _plan( + [_out("nova", picks[:3]), _out("atlas", picks[3:])], + {f"T{i}": 100.0 for i in range(6)}, + risk=RiskConfig(max_trades_per_day=3, max_trades_per_agent=5), + ) + assert len(orders) == 3 + assert any("day cap" in n for n in notes) + + +def test_option_sizing_by_premium(): + opt = Pick( + ticker="AAPL", conviction=0.8, thesis="bullish", requested_weight_pct=0.10, + instrument="option", option_right="call", + contract_symbol="AAPL260724C00295000", strike=295.0, expiry="2026-07-24", + ) + orders, _ = _plan([_out("nova", [opt])], {"AAPL260724C00295000": 7.39}) + assert len(orders) == 1 + o = orders[0] + assert o.instrument == "option" and o.multiplier == 100 + assert o.contract_symbol == "AAPL260724C00295000" + # qty = floor(0.10 * 100000 / (7.39 * 100)) = floor(10000 / 739) = 13 + assert o.qty == 13 + + +def test_buying_power_trim(): + # 2 orders needing 50k each but only 60k buying power -> keep the higher conviction one + orders, notes = _plan( + [_out("nova", [_pick("HI", conviction=0.9, weight=0.10), + _pick("LO", conviction=0.5, weight=0.10)])], + {"HI": 200.0, "LO": 200.0}, + buying_power=12_000.0, # only one 50-share*200=10k order fits + ) + assert len(orders) == 1 + assert orders[0].ticker == "HI" + assert any("buying power" in n for n in notes)