feat(kevin): reconcile Alpaca bracket auto-closes + order status
Bracket stop-loss/take-profit legs fill at Alpaca without passing through the executor, so those closes (and their P&L) were invisible locally. - broker: add get_order(nested) + list_orders to BaseBroker/AlpacaBroker (+ SimulatedBroker); BrokerOrder carries child legs - Trade gains broker_order_id (migration f6a7b8c9d0e1); executor stamps the entry order id - new api_gateway trade-reconcile loop: books a closing SELL + realized P&L when a bracket leg fills (idempotent on the leg order id), syncs PENDING->terminal status, logs drift; runs alongside portfolio_sync [ci skip] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
52b3c76482
commit
82dc622544
13 changed files with 1049 additions and 8 deletions
|
|
@ -11,6 +11,7 @@ import asyncio
|
|||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import cast
|
||||
|
||||
from alpaca.common.exceptions import APIError
|
||||
from alpaca.trading.client import TradingClient
|
||||
|
|
@ -21,7 +22,10 @@ from alpaca.trading.enums import TimeInForce
|
|||
from alpaca.trading.models import Order as AlpacaOrder
|
||||
from alpaca.trading.models import Position as AlpacaPosition
|
||||
from alpaca.trading.models import TradeAccount
|
||||
from alpaca.trading.enums import QueryOrderStatus
|
||||
from alpaca.trading.requests import (
|
||||
GetOrderByIdRequest,
|
||||
GetOrdersRequest,
|
||||
LimitOrderRequest,
|
||||
MarketOrderRequest,
|
||||
StopLossRequest,
|
||||
|
|
@ -32,6 +36,7 @@ from alpaca.trading.requests import (
|
|||
from shared.broker.base import BaseBroker
|
||||
from shared.schemas.trading import (
|
||||
AccountInfo,
|
||||
BrokerOrder,
|
||||
OrderRequest,
|
||||
OrderResult,
|
||||
OrderSide,
|
||||
|
|
@ -180,6 +185,14 @@ class AlpacaBroker(BaseBroker):
|
|||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _order_to_broker_order(cls, alpaca_order: AlpacaOrder) -> BrokerOrder:
|
||||
"""Convert an Alpaca ``Order`` (with optional nested legs) to a
|
||||
``BrokerOrder`` exposing each child leg as an ``OrderResult``."""
|
||||
base = cls._order_to_result(alpaca_order)
|
||||
legs = [cls._order_to_result(leg) for leg in (alpaca_order.legs or [])]
|
||||
return BrokerOrder(**base.model_dump(), legs=legs)
|
||||
|
||||
@staticmethod
|
||||
def _position_to_info(pos: AlpacaPosition) -> PositionInfo:
|
||||
"""Convert an Alpaca ``Position`` to our ``PositionInfo``."""
|
||||
|
|
@ -260,6 +273,44 @@ class AlpacaBroker(BaseBroker):
|
|||
)
|
||||
return self._order_to_result(alpaca_order)
|
||||
|
||||
async def get_order(
|
||||
self, order_id: str, *, nested: bool = True
|
||||
) -> BrokerOrder | None:
|
||||
"""Fetch an order with its bracket child legs from Alpaca.
|
||||
|
||||
Returns ``None`` if Alpaca does not know the order (404 -> APIError)
|
||||
so reconciliation can log the drift instead of crashing.
|
||||
"""
|
||||
try:
|
||||
# raw_data defaults to False, so the client returns an Order, not
|
||||
# the dict the SDK's union signature also allows.
|
||||
alpaca_order = cast(
|
||||
AlpacaOrder,
|
||||
await asyncio.to_thread(
|
||||
self._client.get_order_by_id,
|
||||
order_id,
|
||||
GetOrderByIdRequest(nested=nested),
|
||||
),
|
||||
)
|
||||
except APIError as exc:
|
||||
logger.warning("Order %s not found at Alpaca: %s", order_id, exc)
|
||||
return None
|
||||
return self._order_to_broker_order(alpaca_order)
|
||||
|
||||
async def list_orders(
|
||||
self, *, status: str = "all", limit: int = 100
|
||||
) -> list[OrderResult]:
|
||||
"""List orders from Alpaca, mapped to ``OrderResult``."""
|
||||
request = GetOrdersRequest(
|
||||
status=QueryOrderStatus(status),
|
||||
limit=limit,
|
||||
)
|
||||
orders = cast(
|
||||
"list[AlpacaOrder]",
|
||||
await asyncio.to_thread(self._client.get_orders, request),
|
||||
)
|
||||
return [self._order_to_result(o) for o in orders]
|
||||
|
||||
async def is_asset_tradable(self, symbol: str) -> bool:
|
||||
"""Return True iff Alpaca lists *symbol* as tradable.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ changing strategy or execution logic.
|
|||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from shared.schemas.trading import AccountInfo, OrderRequest, OrderResult, PositionInfo
|
||||
from shared.schemas.trading import (
|
||||
AccountInfo,
|
||||
BrokerOrder,
|
||||
OrderRequest,
|
||||
OrderResult,
|
||||
PositionInfo,
|
||||
)
|
||||
|
||||
|
||||
class BaseBroker(ABC):
|
||||
|
|
@ -85,3 +91,45 @@ class BaseBroker(ABC):
|
|||
Current state of the order including fill price if applicable.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_order(
|
||||
self, order_id: str, *, nested: bool = True
|
||||
) -> BrokerOrder | None:
|
||||
"""Fetch an order including its bracket child legs.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
order_id:
|
||||
The brokerage-assigned order identifier.
|
||||
nested:
|
||||
When ``True`` (the default), child legs (stop-loss / take-profit)
|
||||
are populated on the returned :class:`BrokerOrder` so callers can
|
||||
tell which leg filled and at what price.
|
||||
|
||||
Returns
|
||||
-------
|
||||
BrokerOrder | None
|
||||
The order with its legs, or ``None`` if the order does not exist.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def list_orders(
|
||||
self, *, status: str = "all", limit: int = 100
|
||||
) -> list[OrderResult]:
|
||||
"""List orders, optionally filtered by status.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
status:
|
||||
One of ``"open"``, ``"closed"``, or ``"all"`` (the default).
|
||||
limit:
|
||||
Maximum number of orders to return.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[OrderResult]
|
||||
One entry per matching order.
|
||||
"""
|
||||
...
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ class Trade(TimestampMixin, Base):
|
|||
)
|
||||
status: Mapped[TradeStatus] = mapped_column(nullable=False, default=TradeStatus.PENDING)
|
||||
pnl: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
broker_order_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
strategy: Mapped[Strategy | None] = relationship(back_populates="trades")
|
||||
|
|
|
|||
|
|
@ -80,6 +80,18 @@ class OrderResult(BaseModel):
|
|||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class BrokerOrder(OrderResult):
|
||||
"""An order plus its bracket child legs.
|
||||
|
||||
Returned by ``BaseBroker.get_order`` so reconciliation can inspect a
|
||||
bracket's stop-loss / take-profit legs (each an ``OrderResult``) to learn
|
||||
which leg filled and at what price. Simple orders carry an empty
|
||||
``legs`` list.
|
||||
"""
|
||||
|
||||
legs: list[OrderResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PositionInfo(BaseModel):
|
||||
"""Current position state — used in API responses and portfolio views."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue