Files
momentum-system/alpaca_paper/execution.py
momentum devandClaude Opus 4.8 660cd494d5 Harden order/risk path (execution+broker audit): 3 HIGH + 2 MED + 1 LOW
HIGH:
- Circuit breaker fails CLOSED: a repeated unreadable equity (API 401/429/5xx or
  missing field) now halts new entries after 3 consecutive failures instead of
  silently reporting 'no breach' and continuing to trade through a drawdown.
- execute(): map order statuses. Terminal-fail (rejected/expired/...) -> ok=False
  (never armed as a resting entry); record ANY fill (partial too), not only an
  exact 'filled'; require a real broker order id. approve_order only arms
  genuinely OPEN statuses.
- sync: account/positions/orders failures now emit a BrokerHealthChangedEvent
  (degraded) and step_sync surfaces 'DEGRADED', instead of a success-shaped None
  that left risk gates reading stale snapshots as current.
MEDIUM:
- _flatten_all records a position closed only on a CONFIRMED close; the breaker
  event reports 'halted_flatten_incomplete' + the unclosed symbols when a close
  fails, instead of claiming the book is flat.
- submit_exit_order prefers close_position (handles short sign + fractional qty;
  a plain sell of abs(qty) would double a short).
LOW:
- get_orders(nested=true) so bracket child legs aren't dropped from the snapshot.

+ tests/unit/test_execution_safety.py (4 regressions). 75/75 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:13:28 -04:00

247 lines
8.8 KiB
Python

"""Alpaca paper execution bridge.
Turns an approved execution request into a real Alpaca paper order and
emits order lifecycle events into the event store.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime
from alpaca_paper.client import AlpacaApiError, AlpacaPaperClient
from storage.event_schema import (
EventMode,
OrderCancelledEvent,
OrderFilledEvent,
OrderSubmittedEvent,
)
from storage.event_store import EventStore
logger = logging.getLogger(__name__)
@dataclass
class ExecutionRequest:
"""Broker-agnostic order request derived from a signal."""
symbol: str
side: str
quantity: int
entry_price: float | None = None
stop_loss_price: float | None = None
take_profit_price: float | None = None
order_type: str = "market"
time_in_force: str = "day"
order_id: str = ""
def __post_init__(self):
if not self.order_id:
self.order_id = str(uuid.uuid4())
def to_payload(self) -> dict:
return {
"symbol": self.symbol,
"side": self.side,
"quantity": self.quantity,
"entry_price": self.entry_price,
"stop_loss_price": self.stop_loss_price,
"take_profit_price": self.take_profit_price,
"order_type": self.order_type,
"time_in_force": self.time_in_force,
"order_id": self.order_id,
}
@classmethod
def from_payload(cls, payload: dict) -> "ExecutionRequest":
return cls(
symbol=payload["symbol"],
side=payload.get("side", "buy"),
quantity=int(payload.get("quantity", 0)),
entry_price=payload.get("entry_price"),
stop_loss_price=payload.get("stop_loss_price"),
take_profit_price=payload.get("take_profit_price"),
order_type=payload.get("order_type", "market"),
time_in_force=payload.get("time_in_force", "day"),
order_id=str(payload.get("order_id") or ""),
)
@dataclass
class ExecutionResult:
ok: bool
order_id: str
broker_order_id: str | None = None
status: str | None = None
error: str | None = None
raw: dict | None = None
class AlpacaPaperExecutor:
"""Submits execution requests to the Alpaca paper account."""
broker_name = "alpaca_paper"
def __init__(
self,
store: EventStore,
client: AlpacaPaperClient | None = None,
session_id: str | None = None,
mode: EventMode = EventMode.PAPER,
):
self.store = store
self.client = client or AlpacaPaperClient()
self.session_id = session_id
self.mode = mode
def execute(self, request: ExecutionRequest) -> ExecutionResult:
if request.quantity <= 0:
return ExecutionResult(
ok=False, order_id=request.order_id, error="quantity must be > 0"
)
try:
raw = self.client.submit_order(
symbol=request.symbol,
qty=request.quantity,
side=request.side,
order_type=request.order_type,
time_in_force=request.time_in_force,
limit_price=request.entry_price
if request.order_type == "limit"
else None,
stop_loss_price=request.stop_loss_price,
take_profit_price=request.take_profit_price,
client_order_id=request.order_id[:48],
)
except AlpacaApiError as exc:
logger.error("alpaca order failed: %s", exc)
self._emit_cancelled(request, f"broker rejected: {exc}")
return ExecutionResult(
ok=False, order_id=request.order_id, error=str(exc)
)
except Exception as exc:
logger.exception("alpaca order failed")
self._emit_cancelled(request, f"submit error: {exc}")
return ExecutionResult(
ok=False, order_id=request.order_id, error=str(exc)
)
broker_order_id = raw.get("id")
status = raw.get("status")
# An empty/odd 200 body with no order id is not a real submission — don't
# return ok=True and let it be armed as an untrackable resting order.
if not broker_order_id:
self._emit_cancelled(request, f"broker returned no order id (status={status})")
return ExecutionResult(
ok=False, order_id=request.order_id, status="error",
error="no broker order id", raw=raw,
)
self.store.emit(
OrderSubmittedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=(
f"Submitted {request.side} {request.quantity} {request.symbol} "
f"to alpaca_paper ({status})"
),
symbol=request.symbol,
order_id=request.order_id,
side=request.side,
quantity=request.quantity,
price=request.entry_price or 0.0,
payload={
"symbol": request.symbol,
"order_id": request.order_id,
"side": request.side,
"quantity": request.quantity,
"price": request.entry_price,
"broker_order_id": broker_order_id,
"broker_status": status,
},
)
)
# Record ANY fill (partial or full), not only an exact 'filled' status,
# so positions / P&L never under-report real shares.
filled_qty = int(float(raw.get("filled_qty") or 0))
if filled_qty > 0:
fill_price = float(raw.get("filled_avg_price") or 0) or None
self.store.emit(
OrderFilledEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Filled {request.symbol} {filled_qty} @ {fill_price}",
symbol=request.symbol,
order_id=request.order_id,
fill_price=fill_price or 0.0,
fill_quantity=filled_qty,
payload={
"symbol": request.symbol,
"order_id": request.order_id,
"fill_price": fill_price,
"fill_quantity": filled_qty,
},
)
)
# Terminal-failure statuses are NOT live orders: report ok=False so the
# caller never arms a rejected/expired order as a resting entry.
if status in {"rejected", "canceled", "cancelled", "expired",
"done_for_day", "suspended", "stopped"}:
self._emit_cancelled(request, f"broker {status}")
return ExecutionResult(
ok=False, order_id=request.order_id,
broker_order_id=broker_order_id, status=status, raw=raw,
)
return ExecutionResult(
ok=True,
order_id=request.order_id,
broker_order_id=broker_order_id,
status=status,
raw=raw,
)
def cancel_entry(
self, order_id: str, broker_order_id: str | None, symbol: str, reason: str
) -> ExecutionResult:
"""Cancel a resting (unfilled) entry order at the broker and record it."""
try:
if broker_order_id:
self.client.cancel_order(broker_order_id)
except AlpacaApiError as exc:
# already filled/cancelled at the broker, or transient — log and
# still emit the cancel so our state stops tracking it
logger.warning("cancel of %s returned: %s", order_id, exc)
except Exception as exc: # noqa: BLE001
logger.warning("cancel of %s errored: %s", order_id, exc)
self._emit_cancelled(
ExecutionRequest(symbol=symbol, side="buy", quantity=0, order_id=order_id),
reason,
)
return ExecutionResult(ok=True, order_id=order_id, status="cancelled")
def _emit_cancelled(self, request: ExecutionRequest, reason: str) -> None:
self.store.emit(
OrderCancelledEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Order for {request.symbol} cancelled: {reason}",
symbol=request.symbol,
order_id=request.order_id,
cancel_reason=reason,
payload={
"symbol": request.symbol,
"order_id": request.order_id,
"cancel_reason": reason,
},
)
)