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
|
|
@ -42,6 +42,8 @@ def _make_alpaca_order(
|
|||
filled_avg_price: str | None = None,
|
||||
status: AlpacaOrderStatus = AlpacaOrderStatus.NEW,
|
||||
submitted_at: datetime | None = None,
|
||||
order_class: OrderClass = OrderClass.SIMPLE,
|
||||
legs: list[AlpacaOrder] | None = None,
|
||||
) -> AlpacaOrder:
|
||||
"""Build a minimal Alpaca ``Order`` model for testing."""
|
||||
oid = uuid.UUID(order_id) if order_id else uuid.uuid4()
|
||||
|
|
@ -66,7 +68,7 @@ def _make_alpaca_order(
|
|||
qty=qty,
|
||||
filled_qty="0",
|
||||
filled_avg_price=filled_avg_price,
|
||||
order_class=OrderClass.SIMPLE,
|
||||
order_class=order_class,
|
||||
order_type=AlpacaOrderType.MARKET,
|
||||
type=AlpacaOrderType.MARKET,
|
||||
side=side,
|
||||
|
|
@ -75,7 +77,7 @@ def _make_alpaca_order(
|
|||
stop_price=None,
|
||||
status=status,
|
||||
extended_hours=False,
|
||||
legs=None,
|
||||
legs=legs,
|
||||
trail_percent=None,
|
||||
trail_price=None,
|
||||
hwm=None,
|
||||
|
|
@ -537,3 +539,157 @@ class TestBaseBrokerInterface:
|
|||
from shared.broker.base import BaseBroker
|
||||
|
||||
assert BB is BaseBroker
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_order — nested bracket parent + legs (Phase 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetOrderNested:
|
||||
"""``get_order`` returns a ``BrokerOrder`` exposing the parent plus its
|
||||
bracket child legs (stop-loss / take-profit) with status + fill price, so
|
||||
reconciliation can tell which leg filled and at what price."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_order_returns_broker_order(
|
||||
self, broker: AlpacaBroker, mock_client: MagicMock
|
||||
) -> None:
|
||||
from shared.schemas.trading import BrokerOrder
|
||||
|
||||
parent_id = str(uuid.uuid4())
|
||||
parent = _make_alpaca_order(
|
||||
order_id=parent_id,
|
||||
symbol="NVDA",
|
||||
side=AlpacaOrderSide.BUY,
|
||||
qty="10",
|
||||
status=AlpacaOrderStatus.FILLED,
|
||||
filled_avg_price="100.00",
|
||||
order_class=OrderClass.BRACKET,
|
||||
legs=[],
|
||||
)
|
||||
mock_client.get_order_by_id.return_value = parent
|
||||
|
||||
result = await broker.get_order(parent_id)
|
||||
|
||||
assert isinstance(result, BrokerOrder)
|
||||
assert result.order_id == parent_id
|
||||
assert result.ticker == "NVDA"
|
||||
assert result.status == OrderStatus.FILLED
|
||||
assert result.filled_price == 100.00
|
||||
assert result.legs == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_order_maps_take_profit_leg(
|
||||
self, broker: AlpacaBroker, mock_client: MagicMock
|
||||
) -> None:
|
||||
"""A bracket parent with a FILLED take-profit leg exposes that leg with
|
||||
its fill price; the stop-loss leg is reported still pending."""
|
||||
parent_id = str(uuid.uuid4())
|
||||
tp_id = str(uuid.uuid4())
|
||||
sl_id = str(uuid.uuid4())
|
||||
tp_leg = _make_alpaca_order(
|
||||
order_id=tp_id,
|
||||
symbol="NVDA",
|
||||
side=AlpacaOrderSide.SELL,
|
||||
qty="10",
|
||||
status=AlpacaOrderStatus.FILLED,
|
||||
filled_avg_price="120.00",
|
||||
)
|
||||
sl_leg = _make_alpaca_order(
|
||||
order_id=sl_id,
|
||||
symbol="NVDA",
|
||||
side=AlpacaOrderSide.SELL,
|
||||
qty="10",
|
||||
status=AlpacaOrderStatus.HELD,
|
||||
)
|
||||
parent = _make_alpaca_order(
|
||||
order_id=parent_id,
|
||||
symbol="NVDA",
|
||||
side=AlpacaOrderSide.BUY,
|
||||
qty="10",
|
||||
status=AlpacaOrderStatus.FILLED,
|
||||
filled_avg_price="100.00",
|
||||
order_class=OrderClass.BRACKET,
|
||||
legs=[tp_leg, sl_leg],
|
||||
)
|
||||
mock_client.get_order_by_id.return_value = parent
|
||||
|
||||
result = await broker.get_order(parent_id)
|
||||
|
||||
assert len(result.legs) == 2
|
||||
tp = next(leg for leg in result.legs if leg.order_id == tp_id)
|
||||
sl = next(leg for leg in result.legs if leg.order_id == sl_id)
|
||||
assert tp.status == OrderStatus.FILLED
|
||||
assert tp.filled_price == 120.00
|
||||
assert tp.side == OrderSide.SELL
|
||||
assert sl.status == OrderStatus.PENDING
|
||||
assert sl.filled_price is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_order_passes_nested_flag(
|
||||
self, broker: AlpacaBroker, mock_client: MagicMock
|
||||
) -> None:
|
||||
"""``nested=True`` must reach Alpaca as a GetOrderByIdRequest so the
|
||||
legs come back populated."""
|
||||
from alpaca.trading.requests import GetOrderByIdRequest
|
||||
|
||||
parent_id = str(uuid.uuid4())
|
||||
mock_client.get_order_by_id.return_value = _make_alpaca_order(
|
||||
order_id=parent_id, order_class=OrderClass.BRACKET, legs=[]
|
||||
)
|
||||
|
||||
await broker.get_order(parent_id, nested=True)
|
||||
|
||||
call = mock_client.get_order_by_id.call_args
|
||||
assert call.args[0] == parent_id
|
||||
req = call.args[1] if len(call.args) > 1 else call.kwargs.get("filter")
|
||||
assert isinstance(req, GetOrderByIdRequest)
|
||||
assert req.nested is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_order_returns_none_on_missing(
|
||||
self, broker: AlpacaBroker, mock_client: MagicMock
|
||||
) -> None:
|
||||
"""A missing order (Alpaca 404 -> APIError) yields ``None`` rather than
|
||||
raising, so reconciliation can log drift and move on."""
|
||||
mock_client.get_order_by_id.side_effect = APIError("order not found")
|
||||
|
||||
result = await broker.get_order(str(uuid.uuid4()))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_orders (Phase 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListOrders:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orders_maps_results(
|
||||
self, broker: AlpacaBroker, mock_client: MagicMock
|
||||
) -> None:
|
||||
o1 = _make_alpaca_order(symbol="AAPL", status=AlpacaOrderStatus.FILLED, filled_avg_price="1")
|
||||
o2 = _make_alpaca_order(symbol="MSFT", status=AlpacaOrderStatus.NEW)
|
||||
mock_client.get_orders.return_value = [o1, o2]
|
||||
|
||||
results = await broker.list_orders()
|
||||
|
||||
assert len(results) == 2
|
||||
assert {r.ticker for r in results} == {"AAPL", "MSFT"}
|
||||
assert all(isinstance(r, OrderResult) for r in results)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orders_builds_request(
|
||||
self, broker: AlpacaBroker, mock_client: MagicMock
|
||||
) -> None:
|
||||
from alpaca.trading.requests import GetOrdersRequest
|
||||
|
||||
mock_client.get_orders.return_value = []
|
||||
|
||||
await broker.list_orders(status="all", limit=25)
|
||||
|
||||
req = mock_client.get_orders.call_args.args[0]
|
||||
assert isinstance(req, GetOrdersRequest)
|
||||
assert req.limit == 25
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue