Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions services/executor_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions test/test_position_hold_lp_clear.py
Original file line number Diff line number Diff line change
@@ -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