From 4d2f0bdb21f7df16f0f9a00423ad1c97fa9f6f20 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 22 Jul 2026 18:54:14 -0700 Subject: [PATCH] fix(executors): clear the phantom spot hold left by a fully-unwound LP position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An LP slot's base is bought by an entry order_executor (stopped with keep_position=True, which records a PositionHold) and then deposited into the pool. Neither the add-liquidity deposit nor the mostly-quote withdrawal is a swap fill, so that hold's net_amount_base never comes back down: it lingers as a phantom long after the position is closed on-chain. positions/summary then reports a closed LP position as open indefinitely, which drives false "stranded position" shutdown alarms and an over-stated restart guard. Regular buy/sell executors (spot/perp/grid/dca) are unaffected — their every disposal is a recorded sell fill, so the ledger nets correctly. The gap is specific to LP liquidity add/remove being invisible to a fill-based ledger. Fix: when an lp_executor completes a clean keep_position=False close (the base is gone, returned as quote), clear its position hold by (account, connector, pair, controller) key in _handle_executor_completion. Restrict to a clean close: a FAILED open never deposited into the pool and leaves the swapped base in the wallet as a real spot position (must NOT be cleared); POSITION_HOLD (keep_position=True) still aggregates as before. Verified live: three LP slots opened and shut down; all three holds went to CLEARED, positions/summary returned empty, and the shutdown reconciler reported verify=flat instead of the prior false "left 3 positions OPEN" alarm. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T9Vygff6wPFpmZb5gahuW4 --- services/executor_service.py | 45 +++++++++++++ test/test_position_hold_lp_clear.py | 98 +++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 test/test_position_hold_lp_clear.py diff --git a/services/executor_service.py b/services/executor_service.py index 21c235c1..9d9b3249 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -663,6 +663,21 @@ async def _handle_executor_completion(self, executor_id: str): # Check if this is a POSITION_HOLD close type (keep_position=True) if executor.close_type == CloseType.POSITION_HOLD: await self._aggregate_position_hold(executor_id, executor, metadata) + elif metadata.get("executor_type") == "lp_executor": + # An LP position's base is not held as spot: the entry swap's base is + # deposited into the pool (add-liquidity is neither a buy nor a sell, + # so a position-hold created by that entry order_executor is never + # drawn down by the swap ledger) and returned mostly as quote on + # close. A keep_position=False unwind therefore ends holding no base, + # so its lingering hold is phantom -- clear it so positions/summary, + # the shutdown reconciler, and the restart guard see the true (flat) + # state instead of a position that was closed long ago. + # + # Restrict to a *clean* close: a FAILED open never deposited into the + # pool, leaving the swapped base in the wallet as a real spot + # position (must NOT be cleared); POSITION_HOLD is handled above. + if executor.close_type not in (CloseType.POSITION_HOLD, CloseType.FAILED): + await self._clear_lp_position_hold(executor_id, metadata) # Persist final state to database await self._persist_executor_completed(executor_id, executor) @@ -1072,6 +1087,36 @@ def _get_position_key( """Generate a unique key for position tracking.""" return f"{account_name}|{connector_name}|{trading_pair}|{controller_id}" + async def _clear_lp_position_hold( + self, + executor_id: str, + metadata: Dict[str, Any] + ): + """Clear the phantom spot hold left behind by a fully-unwound LP position. + + The base an LP slot uses is bought by an entry order_executor (stopped + keep_position=True, which records a hold) and then deposited into the + pool. Neither the deposit nor the mostly-quote withdrawal is a swap fill, + so that hold's ``net_amount_base`` never comes back down. When the + lp_executor closes with keep_position=False the base is gone (returned as + quote), so the hold is stale -- clear it by its (account, connector, + pair, controller) key. See the caller in ``_handle_executor_completion``. + """ + account_name = metadata.get("account_name", self.default_account) + connector_name = metadata.get("connector_name", "") + trading_pair = metadata.get("trading_pair", "") + controller_id = metadata.get("controller_id", "main") + if not connector_name or not trading_pair: + return + cleared = await self.clear_position_held( + account_name, connector_name, trading_pair, controller_id + ) + if cleared: + logger.info( + f"Cleared phantom LP position hold for {executor_id} " + f"({connector_name}/{trading_pair}, controller={controller_id})" + ) + async def _aggregate_position_hold( self, executor_id: str, diff --git a/test/test_position_hold_lp_clear.py b/test/test_position_hold_lp_clear.py new file mode 100644 index 00000000..f571b93a --- /dev/null +++ b/test/test_position_hold_lp_clear.py @@ -0,0 +1,98 @@ +"""Tests for clearing the phantom LP position hold on a full unwind. + +An LP slot's base is bought by an entry order_executor (stopped +keep_position=True, which records a PositionHold) and then deposited into the +pool. Neither the add-liquidity deposit nor the mostly-quote withdrawal is a +swap fill, so that hold's net_amount_base never comes back down and it lingers +as a phantom long after the position is closed on-chain. When the lp_executor +closes with keep_position=False the base is gone (returned as quote), so +``_handle_executor_completion`` clears the hold. + +Run with: pytest test/test_position_hold_lp_clear.py -v +""" +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +pytest.importorskip("hummingbot") + +from hummingbot.strategy_v2.models.executors import CloseType # noqa: E402 + +from models.executors import PositionHold # noqa: E402 +from services.executor_service import ExecutorService # noqa: E402 + +KEY_ARGS = ("master", "meteora/clmm", "BOP-SOL", "agent.lp_slot_operator_13") + + +def _svc_with_hold(): + """Minimal ExecutorService carrying one seeded hold and no DB.""" + svc = object.__new__(ExecutorService) + svc.default_account = "master" + svc.db_manager = None + svc._positions_held = {} + svc._log_capture = MagicMock() + # Seed the phantom hold the entry swap would have created. + key = svc._get_position_key(*KEY_ARGS) + svc._positions_held[key] = PositionHold( + trading_pair="BOP-SOL", + connector_name="meteora/clmm", + account_name="master", + controller_id="agent.lp_slot_operator_13", + buy_amount_base=1163, + ) + # Stub the heavy completion collaborators. + svc._persist_executor_completed = AsyncMock() + svc._aggregate_position_hold = AsyncMock() + return svc, key + + +def _completion(svc, executor_id, close_type, executor_type): + svc._active_executors = { + executor_id: SimpleNamespace(close_type=close_type, is_closed=True) + } + svc._executor_metadata = { + executor_id: { + "executor_type": executor_type, + "account_name": "master", + "connector_name": "meteora/clmm", + "trading_pair": "BOP-SOL", + "controller_id": "agent.lp_slot_operator_13", + } + } + return svc._handle_executor_completion(executor_id) + + +@pytest.mark.asyncio +async def test_lp_clean_unwind_clears_phantom_hold(): + """A keep_position=False LP close (EARLY_STOP) clears the stale hold.""" + svc, key = _svc_with_hold() + await _completion(svc, "e_lp", CloseType.EARLY_STOP, "lp_executor") + assert key not in svc._positions_held + svc._aggregate_position_hold.assert_not_called() + + +@pytest.mark.asyncio +async def test_lp_failed_open_keeps_hold(): + """A FAILED LP open leaves the swapped base in the wallet as a real spot + position -- the hold must NOT be cleared.""" + svc, key = _svc_with_hold() + await _completion(svc, "e_lp", CloseType.FAILED, "lp_executor") + assert key in svc._positions_held + + +@pytest.mark.asyncio +async def test_lp_position_hold_close_aggregates_not_clears(): + """keep_position=True (POSITION_HOLD) still aggregates, never the clear path.""" + svc, key = _svc_with_hold() + await _completion(svc, "e_lp", CloseType.POSITION_HOLD, "lp_executor") + svc._aggregate_position_hold.assert_called_once() + assert key in svc._positions_held # aggregate stubbed; clear not invoked + + +@pytest.mark.asyncio +async def test_non_lp_executor_does_not_clear_hold(): + """The clear path is LP-specific: an order_executor close leaves holds alone.""" + svc, key = _svc_with_hold() + await _completion(svc, "e_ord", CloseType.EARLY_STOP, "order_executor") + assert key in svc._positions_held