feat(execution): per-account Alpaca GTC brackets, guardrails, shorts, options, reconcile

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DvdRZkyCy9NJpHYDdPqmS
This commit is contained in:
Aryan SinghandClaude Opus 4.8 committed 2026-07-01 16:29:40 -05:00
1 parent aa1ae0c95e
commit 709870b1d5
8 files changed
+813

No files matched your search

View File
Whitespace-only changes.
+153
View File
@@ -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)
+151
View File
@@ -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
+46
View File
@@ -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}
+177
View File
@@ -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))
+108
View File
@@ -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()
+28
View File
@@ -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
+150
View File
@@ -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)