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:
1 parent
aa1ae0c95e
commit
709870b1d5
8 files changed
+813
No files matched your search
Whitespace-only changes.
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user