fix: three Tradier sandbox incident fixes (2026-07-28 reject loop)

Incident: entry-timeout cancels raced lazy sandbox fills -> untracked
positions; naked-stop guard then force-sold but every sell rejected
('more shares than your current long position') for hours.

1. translate_order: Tradier OTOCO/OTO parents carry no top-level
   symbol/side/qty (legs only) — inherit from the entry leg so
   symbol-scoped lookups can see brackets; get_orders symbols filter
   also matches by leg symbols. Without this the stop-leg search read
   a fully-protected position as naked.
2. close_position: cancel the symbol's resting sell orders/legs first —
   Tradier reserves held shares against open sells, so selling without
   releasing them always rejects.
3. cancel_entry (broker-agnostic): after cancelling, re-check the
   broker's final word by client_order_id; journal a fill for any
   executed shares instead of orphaning a real position (2026-07-06
   class, same race exists on Alpaca).

49 tests pass incl. new coverage for all three.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-28 09:11:00 -05:00
1 parent bd7183c861
commit a2de50479a
3 files changed
+146 -4

No files matched your search

+43 -2
View File
@@ -251,16 +251,57 @@ class AlpacaPaperExecutor:
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."""
"""Cancel a resting (unfilled) entry order at the broker and record it.
CANCEL-vs-FILL RACE: the entry can fill at the broker before (or as)
the cancel lands — the cancel then no-ops but journaling only a cancel
orphans a REAL position (untracked shares, the 2026-07-06 class;
happened live on Tradier sandbox 2026-07-28: lazy fills + entry
timeouts). After cancelling, re-check the broker's final word and emit
the fill for any executed shares so the position is tracked."""
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
# continue to the fill-race check below
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)
filled_qty, fill_price, side = 0, None, "buy"
getter = getattr(self.client, "get_order_by_client_order_id", None)
if getter is not None:
try:
found = getter(order_id[:48]) or {}
filled_qty = int(float(found.get("filled_qty") or 0))
fill_price = float(found.get("filled_avg_price") or 0) or None
side = found.get("side") or "buy"
except Exception: # noqa: BLE001 — lookup failure: keep cancel-only path
pass
if filled_qty > 0:
logger.warning("cancel of %s raced a fill: %d %s executed @ %s — "
"journaling the fill", order_id, filled_qty, symbol, fill_price)
self.store.emit(
OrderFilledEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Filled {symbol} {filled_qty} @ {fill_price} "
f"(cancel raced fill)",
symbol=symbol,
order_id=order_id,
fill_price=fill_price or 0.0,
fill_quantity=filled_qty,
side=side,
payload={"symbol": symbol, "order_id": order_id,
"fill_price": fill_price, "fill_quantity": filled_qty,
"side": side, "cancel_raced_fill": True},
)
)
return ExecutionResult(ok=True, order_id=order_id, status="filled")
self._emit_cancelled(
ExecutionRequest(symbol=symbol, side="buy", quantity=0, order_id=order_id),
reason,