Skip to content

fix(executors): clear phantom spot hold left by a fully-unwound LP position - #197

Closed
fengtality wants to merge 1 commit into
mainfrom
fix/lp-position-hold-clear
Closed

fix(executors): clear phantom spot hold left by a fully-unwound LP position#197
fengtality wants to merge 1 commit into
mainfrom
fix/lp-position-hold-clear

Conversation

@fengtality

Copy link
Copy Markdown
Contributor

Problem

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.

GET /executors/positions/summary then reports a closed LP position as open indefinitely. Observed downstream effects:

  • False "emergency shutdown left N positions OPEN" alarms (the reconciler reads the ledger and, under flatten_all, treats every phantom as stranded). In one run a position closed an hour earlier still showed up.
  • An over-stated orphan-risk guard on plain /stop.

Regular buy/sell executors (spot/perp/grid/dca) are unaffected: 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.

Guards:

  • FAILED open never deposited into the pool and leaves the swapped base in the wallet as a real spot position → not cleared.
  • POSITION_HOLD (keep_position=True) still aggregates as before.

Tests

test/test_position_hold_lp_clear.py (4 cases): clean unwind clears, FAILED keeps, POSITION_HOLD aggregates (not clears), non-LP executor untouched.

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. The same scenario on the unpatched build produced the false alarm.

🤖 Generated with Claude Code

…P position

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9Vygff6wPFpmZb5gahuW4
@fengtality
fengtality requested a review from rapcmia July 23, 2026 02:03
@rapcmia rapcmia self-assigned this Jul 23, 2026
@rapcmia rapcmia moved this to Under Review in Pull Request Board Jul 23, 2026
@rapcmia

rapcmia commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

PR update:

  • LP position closes cleanly ✅
    • Opened a live SOL-USDC range position with about $3 of SOL and $3 of USDC.
    • After 30 seconds, the bot closed the position with keep_position=false.
    • The close was confirmed and the deposit rent was returned.
    • No LP position remained, and the position summary showed the account was flat (total_positions: 0).
  • Failed LP open keeps the SOL hold ✅
    • Bought 0.01 SOL first, so the account had a visible SOL-USDC hold.
    • Started an LP with a non-pool address. It failed before adding any liquidity.
    • The SOL hold was still listed afterward: net_amount_base: 0.01 and total_positions: 1.
  • Keep-position LP stop keeps the hold ✅
    • Opened a live SOL-USDC range LP, then stopped it with keep_position=true.
    • The executor finished as POSITION_HOLD.
    • The summary still listed one SOL-USDC hold (total_positions: 1), so the hold was not cleared.
  • Closing an LP clears a separate spot hold ❌
    # Before closing the LP: checks the real SOL spot hold under the shared controller.
    # `executor_count: 2` means two separate spot executors contributed to this hold.
    curl -sS -u "$USERNAME:$PASSWORD" 'http://localhost:8000/executors/positions/summary?controller_id=lp-shared-hold-test'
    {"total_positions":1,"positions":[{"trading_pair":"SOL-USDC","net_amount_base":0.02,"position_side":"LONG","executor_count":2}]}
    
    # Stops the LP and asks it to fully unwind instead of keeping its resulting position.
    curl -sS -X POST -u "$USERNAME:$PASSWORD" -H 'Content-Type: application/json' -d '{"keep_position":false}' http://localhost:8000/executors/7QWZX9dUFQfxEhVdD9cp7Cz5FTC9R9wy2VZkPZUns4KK/stop
    {"executor_id":"7QWZX9dUFQfxEhVdD9cp7Cz5FTC9R9wy2VZkPZUns4KK","status":"stopping","keep_position":false}
    
    # After the LP has closed: checks whether the separate spot hold is still tracked.
    # `total_positions: 0` is the failed result because the real spot hold disappeared.
    curl -sS -u "$USERNAME:$PASSWORD" 'http://localhost:8000/executors/positions/summary?controller_id=lp-shared-hold-test'
    {"total_positions":0,"positions":[]}
    
    • A real SOL spot hold was created by separate order executors under the same account, connector, trading pair, and controller as an LP executor.
    • After the LP was fully closed with keep_position=false, /executors/positions/summary returned no positions.

@fengtality

Copy link
Copy Markdown
Contributor Author

Closing — this was fixing the wrong layer.

@Reviewer's finding is correct and the cause is more fundamental than the scoping bug: PositionHold is an aggregate keyed account|connector|pair|controller (_get_position_key), shared by every executor that stops with keep_position=True. clear_position_held() deletes that whole bucket, so one LP executor's completion wipes holds contributed by unrelated order executors — exactly the executor_count: 2 repro above.

But scoping the clear per-executor would still have been wrong, because the phantom it was clearing shouldn't exist in the first place.

Root cause, in hummingbot, not here. hummingbot commit 357943e13 changed the keep_position=False close-out swap from net-based (withdrawn − deposited) to selling the entire withdrawn base balance. That turns the LP executor into a real base→quote disposal — but the ledger write is gated on if self.config.keep_position or self.close_type == CloseType.POSITION_HOLD (lp_executor.py), and keep_position=False closes as EARLY_STOP, so _store_lp_event_from_remove never runs. hummingbot-api likewise only aggregates on POSITION_HOLD. The entry order_executor's hold therefore never nets down — the phantom.

The LP round trip is the executor's to undo; the base it was handed is not. Restoring the net-based close-out makes the LP executor position-neutral, keeps the entry executor's hold accurate, and needs no LP-specific hold logic in hummingbot-api at all. Fixed in hummingbot instead (_calculate_net_base_difference restored, with a docstring explaining why it's the net so this doesn't get re-changed).

Thanks for catching it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants