money-path: reconcile a lost order-submit by client_order_id before journaling cancel
Plan 003 step 4. A transport error AFTER the POST landed leaves a live order resting at the broker; journaling it 'cancelled' breeds the duplicate/naked order (2026-07-06 incident). Add client.get_order_by_client_order_id + an after= passthrough on get_orders; on the generic-exception path, look up the order by the client_order_id we already send and, if live, emit the normal submitted sequence (ok=True) instead of cancelled. AlpacaApiError (real rejection) is NOT reconciled. Fills the empty test_alpaca_client.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d16eb85ee9
commit
119e4c0ad1
4 files changed
+168
-2
No files matched your search
+17
-1
@@ -183,9 +183,23 @@ class AlpacaPaperClient:
|
||||
return val
|
||||
return self._cached("positions", lambda: self._trading("GET", "/positions"))
|
||||
|
||||
def get_order_by_client_order_id(self, client_order_id: str) -> dict | None:
|
||||
"""The single order carrying this client_order_id, or None if unknown.
|
||||
|
||||
Used to reconcile a LOST submit response (network drop after the POST
|
||||
left): the order may actually be resting at the broker, so we look it up
|
||||
by the client_order_id we sent before assuming it was cancelled. A 404
|
||||
(never landed) surfaces as an AlpacaApiError the caller treats as 'not
|
||||
found'. `_trading` already prefixes /v2; the literal ':' in the path is
|
||||
Alpaca's by-client-id sub-resource, not a typo."""
|
||||
return self._trading(
|
||||
"GET", "/orders:by_client_order_id",
|
||||
params={"client_order_id": client_order_id},
|
||||
)
|
||||
|
||||
def get_orders(
|
||||
self, status: str = "all", limit: int = 500, nested: bool = True,
|
||||
symbols: list[str] | None = None,
|
||||
symbols: list[str] | None = None, after: str | None = None,
|
||||
) -> list[dict]:
|
||||
# nested=true includes bracket child legs (stop/target) so working risk
|
||||
# isn't under-reported in the orders snapshot.
|
||||
@@ -199,6 +213,8 @@ class AlpacaPaperClient:
|
||||
# the exit manager / safety checks must do.
|
||||
params: dict = {"status": status, "limit": min(limit, 500),
|
||||
"nested": str(nested).lower(), "direction": "desc"}
|
||||
if after: # only orders submitted after this ISO timestamp (exclusive) — the cage-count reconcile scopes to today
|
||||
params["after"] = after
|
||||
if symbols:
|
||||
# scoped to a few names — well under the cap, one page is complete
|
||||
params["symbols"] = ",".join(symbols)
|
||||
|
||||
@@ -122,12 +122,47 @@ class AlpacaPaperExecutor:
|
||||
ok=False, order_id=request.order_id, error=str(exc)
|
||||
)
|
||||
except Exception as exc:
|
||||
# LOST RESPONSE: the POST may have LANDED before the connection dropped,
|
||||
# leaving a real order resting at the broker. Reconcile by the
|
||||
# client_order_id we sent before assuming it was cancelled — journalling
|
||||
# a live order as 'cancelled' is how a duplicate/naked order is born
|
||||
# (the 2026-07-06 incident). Only the generic/transport error path
|
||||
# reconciles; an AlpacaApiError above is a genuine broker rejection.
|
||||
logger.exception("alpaca order failed")
|
||||
self._emit_cancelled(request, f"submit error: {exc}")
|
||||
recovered = self._reconcile_lost_submit(request)
|
||||
if recovered is not None:
|
||||
logger.warning("recovered lost submit for %s via client_order_id "
|
||||
"(broker status=%s)", request.symbol, recovered.get("status"))
|
||||
return self._finalize(request, recovered)
|
||||
self._emit_cancelled(request, f"submit error (reconcile: not found): {exc}")
|
||||
return ExecutionResult(
|
||||
ok=False, order_id=request.order_id, error=str(exc)
|
||||
)
|
||||
|
||||
return self._finalize(request, raw)
|
||||
|
||||
_LIVE_STATUSES = {"new", "accepted", "pending_new", "partially_filled",
|
||||
"filled", "held", "accepted_for_bidding"}
|
||||
|
||||
def _reconcile_lost_submit(self, request: "ExecutionRequest") -> dict | None:
|
||||
"""Look up the order by the client_order_id we sent; return it only if it
|
||||
is actually LIVE at the broker (so a lost response isn't journaled as a
|
||||
cancel). Returns None on 404/lookup error — then the caller emits cancelled."""
|
||||
getter = getattr(self.client, "get_order_by_client_order_id", None)
|
||||
if getter is None:
|
||||
return None
|
||||
try:
|
||||
found = getter(request.order_id[:48])
|
||||
except Exception: # noqa: BLE001 — 404 (never landed) or transport error
|
||||
return None
|
||||
if found and str(found.get("status")) in self._LIVE_STATUSES:
|
||||
return found
|
||||
return None
|
||||
|
||||
def _finalize(self, request: "ExecutionRequest", raw: dict) -> ExecutionResult:
|
||||
"""Record a submission's outcome from the broker response (or a recovered
|
||||
order) and return the ExecutionResult — one event sequence for the normal
|
||||
and the reconcile-recovered paths alike."""
|
||||
broker_order_id = raw.get("id")
|
||||
status = raw.get("status")
|
||||
|
||||
|
||||
Reference in new issue
Block a user