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:
momentum devandClaude Fable 5 committed 2026-07-13 22:24:14 -04:00
1 parent d16eb85ee9
commit 119e4c0ad1
4 files changed
+168 -2

No files matched your search

+17 -1
View File
@@ -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)
+36 -1
View File
@@ -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")