From 390d6660ea82a56ca3e7ade50ef0a174637cd7ed Mon Sep 17 00:00:00 2001 From: momentum dev Date: Wed, 5 Aug 2026 19:01:18 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20halt-aware=20flatten=20=E2=80=94=20let?= =?UTF-8?q?=20a=20working=20market=20sell=20rest=20instead=20of=20churning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A market sell that sits unfilled means the symbol is halted (or session closed); cancelling and resubmitting it every exit-manager pass fills no faster and spams the broker — YXT 2026-08-05: 87 market sells across a 10-minute LULD halt, 86 cancelled, plus one exit_naked_stop journal event every ~4s. On a halt-down the resting sell is also the position's only protection. cancel_protective_and_close now returns False (nothing submitted) when a market sell for the symbol is already working, and only cancels non-market protective sells before closing. Exit-manager paths (catastrophe / naked-stop / first-red / trail) journal the exit only when a close was actually submitted. Co-Authored-By: Claude Fable 5 --- runtime/exit_manager.py | 46 +++++++++-------- runtime/flatten.py | 28 +++++++++-- tests/unit/runtime/test_flatten_close.py | 64 ++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 tests/unit/runtime/test_flatten_close.py diff --git a/runtime/exit_manager.py b/runtime/exit_manager.py index 77513c1..cac9db2 100644 --- a/runtime/exit_manager.py +++ b/runtime/exit_manager.py @@ -101,13 +101,15 @@ class LiveExitManager: return (c.get("id"), float(sp) if sp else None) return (None, None) - def _flatten(self, symbol: str, orders) -> None: + def _flatten(self, symbol: str, orders) -> bool: """Market-close a position, cancelling its resting protective legs first. Delegates to the shared helper so the held-qty 403 fix lives in exactly one place (see runtime/flatten.py). ``orders`` is the pass's pre-fetched - nested snapshot, reused to avoid a redundant fetch.""" - cancel_protective_and_close(self.client, symbol, orders=orders) + nested snapshot, reused to avoid a redundant fetch. Returns False when a + market sell is already resting (halted symbol) — nothing new submitted, + so callers should not re-journal the exit.""" + return cancel_protective_and_close(self.client, symbol, orders=orders) def _market_open(self) -> bool: """Broker clock — is the regular session open? A market FLATTEN can't fill @@ -235,11 +237,11 @@ class LiveExitManager: # re-fetches rather than acting on the sentinel; a true # catastrophe still exits even when stop status is unknowable # (a deep-drawdown position is worse than a redundant cancel). - self._flatten(sym, orders_for_flatten) - self._emit(sym, f"CATASTROPHE exit {sym} @~{cur:.4f} " - f"({loss_pct:.1f}% below entry)", "exit_catastrophe", - {"price": cur, "entry": entry, "stop": cur_stop}) - actions.append(f"{sym} CATASTROPHE-exit @{cur:.2f} (-{loss_pct:.0f}%)") + if self._flatten(sym, orders_for_flatten): + self._emit(sym, f"CATASTROPHE exit {sym} @~{cur:.4f} " + f"({loss_pct:.1f}% below entry)", "exit_catastrophe", + {"price": cur, "entry": entry, "stop": cur_stop}) + actions.append(f"{sym} CATASTROPHE-exit @{cur:.2f} (-{loss_pct:.0f}%)") elif cur_stop is None: # closed + naked: a market flatten can't fill — rest a protective # stop below market so it fires at the open instead of churning. @@ -271,11 +273,13 @@ class LiveExitManager: try: if mkt_open: # session open: a market flatten fills — the safe exit. - self._flatten(sym, orders) - self._emit(sym, f"naked-stop enforced exit {sym} " - f"(no live stop, {n} passes)", "exit_naked_stop", - {"passes": n, "price": cur, "entry": entry}) - actions.append(f"{sym} naked-stop exit (no live stop)") + # False = a sell is already resting (halted symbol); + # don't re-journal every pass (87-order YXT churn). + if self._flatten(sym, orders): + self._emit(sym, f"naked-stop enforced exit {sym} " + f"(no live stop, {n} passes)", "exit_naked_stop", + {"passes": n, "price": cur, "entry": entry}) + actions.append(f"{sym} naked-stop exit (no live stop)") else: # closed: a market sell can't fill (it just churns every # pass) — REST a protective stop so the position survives @@ -361,10 +365,10 @@ class LiveExitManager: # (1) first-red: exit the whole position now if d.exit_now: try: - self._flatten(sym, orders) - self._emit(sym, f"first-red exit {sym} @~mkt", "exit_first_red", - {"reason": d.reason}) - actions.append(f"{sym} first-red exit") + if self._flatten(sym, orders): + self._emit(sym, f"first-red exit {sym} @~mkt", "exit_first_red", + {"reason": d.reason}) + actions.append(f"{sym} first-red exit") self._tracked.pop(sym, None) continue except Exception as exc: # noqa: BLE001 @@ -393,10 +397,10 @@ class LiveExitManager: # prior swing low) — a sell-stop above market would be # rejected, so exit at market now (this is the trail firing). try: - self._flatten(sym, orders) - self._emit(sym, f"trail breached, exit {sym} @~{last_px:.2f}", - "exit_trail", {"trail": d.desired_stop, "price": last_px}) - actions.append(f"{sym} trail-exit @{last_px:.2f}") + if self._flatten(sym, orders): + self._emit(sym, f"trail breached, exit {sym} @~{last_px:.2f}", + "exit_trail", {"trail": d.desired_stop, "price": last_px}) + actions.append(f"{sym} trail-exit @{last_px:.2f}") self._tracked.pop(sym, None) except Exception as exc: # noqa: BLE001 logger.warning("trail exit failed %s: %s", sym, exc) diff --git a/runtime/flatten.py b/runtime/flatten.py index 7d00720..fae0304 100644 --- a/runtime/flatten.py +++ b/runtime/flatten.py @@ -95,9 +95,12 @@ RESERVING_STATES = frozenset({ def cancel_protective_and_close(client, symbol: str, orders=None, - retries: int = 4) -> None: + retries: int = 4) -> bool: """Cancel ``symbol``'s resting protective sell orders, then market-close it. + Returns True when a close was submitted this call, False when a market sell + was already working (liquidation in flight — e.g. resting through a halt). + Args: client: broker client exposing ``cancel_order``, ``close_position`` and (optionally) ``get_orders(status, nested, symbols)``. @@ -112,16 +115,35 @@ def cancel_protective_and_close(client, symbol: str, orders=None, the last close exception if every attempt fails (callers collect, not crash — one bad symbol must not block flattening the rest of the book). """ - # 1) free the reserved shares: cancel the symbol's working sell legs if orders is None and hasattr(client, "get_orders"): try: orders = client.get_orders(status="open", nested=True, symbols=[symbol]) except Exception: # noqa: BLE001 orders = None + + # 0) a working MARKET sell means liquidation is already in flight — let it + # rest. A market sell only sits unfilled while the symbol is HALTED (or the + # session closed); cancelling and resubmitting it every pass fills no faster + # and churns the broker (YXT 2026-08-05: 87 market sells over a 10-minute + # LULD halt, 86 cancelled). On a halt-down, the resting sell is also the + # position's only protection — the one order that fills at the reopen. for parent in (orders or []): for o in [parent, *(parent.get("legs") or [])]: sym = o.get("symbol") or parent.get("symbol") if (sym == symbol and o.get("side") == "sell" + and o.get("type") == "market" + and o.get("status") in RESERVING_STATES): + logger.info("flatten %s: market sell %s already working — " + "resting through the halt", symbol, o.get("id")) + return False + + # 1) free the reserved shares: cancel the symbol's working PROTECTIVE sell + # legs (stop/limit — never a market sell, that's the liquidation itself) + for parent in (orders or []): + for o in [parent, *(parent.get("legs") or [])]: + sym = o.get("symbol") or parent.get("symbol") + if (sym == symbol and o.get("side") == "sell" + and o.get("type") != "market" and o.get("status") in RESERVING_STATES and o.get("id")): try: client.cancel_order(o["id"]) @@ -133,7 +155,7 @@ def cancel_protective_and_close(client, symbol: str, orders=None, for attempt in range(retries): try: client.close_position(symbol) - return + return True except Exception as exc: # noqa: BLE001 last_exc = exc time.sleep(0.3 * (attempt + 1)) diff --git a/tests/unit/runtime/test_flatten_close.py b/tests/unit/runtime/test_flatten_close.py new file mode 100644 index 0000000..65dbc28 --- /dev/null +++ b/tests/unit/runtime/test_flatten_close.py @@ -0,0 +1,64 @@ +"""cancel_protective_and_close: halt-aware liquidation (the YXT 2026-08-05 +churn — 87 market sells through a 10-minute LULD halt, 86 cancelled).""" + +from runtime.flatten import cancel_protective_and_close + + +class FakeClient: + def __init__(self): + self.cancelled: list[str] = [] + self.closed: list[str] = [] + + def cancel_order(self, order_id): + self.cancelled.append(order_id) + + def close_position(self, symbol, qty=None): + self.closed.append(symbol) + + +def _order(**kw): + base = {"symbol": "YXT", "side": "sell", "type": "market", + "status": "accepted", "id": "o1", "legs": []} + base.update(kw) + return base + + +def test_resting_market_sell_is_left_alone(): + client = FakeClient() + submitted = cancel_protective_and_close(client, "YXT", orders=[_order()]) + assert submitted is False + assert client.cancelled == [] + assert client.closed == [] + + +def test_protective_stop_is_cancelled_then_closed(): + client = FakeClient() + submitted = cancel_protective_and_close( + client, "YXT", orders=[_order(type="stop", id="stop1")]) + assert submitted is True + assert client.cancelled == ["stop1"] + assert client.closed == ["YXT"] + + +def test_no_orders_just_closes(): + client = FakeClient() + assert cancel_protective_and_close(client, "YXT", orders=[]) is True + assert client.closed == ["YXT"] + + +def test_dead_market_sell_does_not_block_close(): + # a filled/cancelled market sell no longer reserves shares — must not be + # mistaken for an in-flight liquidation + client = FakeClient() + submitted = cancel_protective_and_close( + client, "YXT", orders=[_order(status="canceled")]) + assert submitted is True + assert client.closed == ["YXT"] + + +def test_other_symbols_market_sell_ignored(): + client = FakeClient() + submitted = cancel_protective_and_close( + client, "YXT", orders=[_order(symbol="SWIM")]) + assert submitted is True + assert client.closed == ["YXT"]