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))