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:
1 parent
bd7183c861
commit
a2de50479a
3 files changed
+146
-4
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user