#8429·hummingbot

Proposal: SwapExecutor and LPAmmExecutor executor types

Author: fengtalityCreated Aug 20, 2026Updated Aug 21, 2026

Summary

Two new strategy_v2 executor types:

today proposed
Gateway swaps order_executor, a CEX abstraction with a Gateway branch bolted on SwapExecutor
CLMM liquidity lp_executor unchanged
AMM liquidity nothing — no executor exists LPAmmExecutor

These are two separate arguments and worth judging separately. SwapExecutor replaces a surface that works but is the wrong shape, and carries migration risk. LPAmmExecutor is new capability with nothing to migrate — today an AMM position can only be opened by calling the AMM routes by hand, and nothing monitors it afterwards.

If only one is built, build LPAmmExecutor — that gap is absolute rather than qualitative.

Findings below marked (measured) come from a live mainnet test session on Solana against the unified /trading routes, 2026-08-20.


Part 1 — SwapExecutor

Why order_executor is the wrong shape

OrderExecutorConfig has eleven fields. On a Gateway swap, five are meaningless:

type, trading_pair, connector_name, side, amount,
position_action,      # perpetual HEDGE-mode concept
price,                # LIMIT/LIMIT_MAKER only; a DEX swap has no resting order
chaser_config,        # LIMIT_CHASER only; there is no book to chase
execution_strategy,   # of four values, only MARKET is reachable
leverage,             # spot DEX
level_id

and the ones a swap needs are absent. The class already knows it does not fit — order_executor.py:439:

python
# Gateway swap connectors have no order book and are not registered in
# AllConnectorSettings, so they carry no CEX fee schema. The BudgetChecker /
# OrderCandidate path raises trying to load that schema, so it cannot be used here.
if isinstance(connector, GatewayBase):
    return          # validate_sufficient_balance: skip the whole pre-flight

An executor whose balance validation is return for an entire class of venue is being asked to model something it was not built for.

What it fixes

Four problems share one root cause — the swap path has no executor of its own, so it inherited a CEX one and the DEX-specific concerns had nowhere to live.

1. No slippage tolerance, and ten identical retries. — FIXED since filing. OrderExecutorConfig now carries the same three-field ramp as LPExecutorConfig (slippage_pct 0.05, slippage_multiplier 5, max_slippage_pct 5), verified in a deployed wheel. This removed the sharpest argument for a separate executor, and it is worth being explicit that it did: a swap can now express a tolerance without SwapExecutor. What remains is that the tolerance sits on a config whose other ten fields are mostly inapplicable, which is a shape argument rather than a capability one.

Scope: LIMIT/LIMIT_MAKER carry an explicit price and LIMIT_CHASER has distance/refresh_threshold. It is MARKET on a Gateway DEX that has no tolerance and no ramp.

2. An approximated BUY is indistinguishable from an exact one. A BUY is ExactOut. Many thin tokens have no ExactOut route, so Gateway falls back to a sell-leg quote and returns an ExactIn quote whose output is near the request. Gateway flags this with approximation: true, but nothing surfaces it to the executor.

(measured) Across eleven pump.fun-era pools at two sizes each, liquidity spanning $17–$1,963, the shortfall was pinned at ~2.5% regardless of depth and uncorrelated with reported price impact — consistent with paying the pool fee twice, once per leg, rather than with depth. One executed BUY asked for 1000 tokens and received 951.68.

3. The fill is not reported. Realized amounts now reach the connector — gateway.py documents amountIn/amountOut as "Gateway's REALIZED amounts, derived from the wallet's on-chain pre/post token balance deltas" — but the order executor's guide still tells callers executed_amount_base is the amount requested and to apply a × 0.995 haircut. A haircut calibrated at "−0.06% to −0.44%" is nowhere near the 4.83% an approximated BUY actually lost.

4. /trading/router/execute-quote has no caller. The two-step flow — quote, decide, execute that quote id — is the one that matters for firm-quote routers (dflow, titan, 0x), and no executor can reach it.

5. The router cannot be chosen per order. (measured 2026-08-20) An order executor on a Gateway venue has no way to name which router executes the swap:

  • connector_name must be a network. Anything absent from _conn_settings is constructed as Gateway(connector_name=...) and parsed as chain-network, so dflow/router is not a valid value there.
  • The router comes from self._swap_provider, which gateway_base.py:185 documents as "fetched from network config". For solana-mainnet-beta that is swapProvider: jupiter/router.
  • The only override is a dex_name kwarg read at gateway.py:368, and order_executor never sets it.

So selecting dflow today means editing conf/chains/solana/mainnet-beta.yml and restarting Gateway — a global default for every Solana swap, including the close-out swaps lp_executor performs, rather than a per-order choice.

The asymmetry makes the point on its own: LPExecutorConfig already has a swap_provider field and OrderExecutorConfig has none, so the LP executor can pick its router and the order executor cannot. This is the strongest remaining argument for a separate executor now that the slippage ramp has landed, and it is a capability gap rather than a shape complaint. SwapExecutorConfig above carries swap_provider for exactly this reason.

It also blocks item 4 in practice: firm-quote routers are the ones worth using execute-quote with, and they are precisely the ones that cannot be selected.

Proposed config

python
class SwapExecutorConfig(ExecutorConfigBase):
    type: Literal["swap_executor"] = "swap_executor"

    # Venue
    connector_name: str                      # network, e.g. "solana-mainnet-beta"
    swap_provider: Optional[str] = None      # "jupiter/router"; None -> network default
    trading_pair: str
    pool_address: Optional[str] = None       # pool-scoped connectors only

    # Order
    side: TradeType                          # BUY = ExactOut, SELL = ExactIn
    amount: Decimal

    # Tolerance — the same ramp as LPExecutorConfig, same defaults
    slippage_pct: Decimal = Decimal("0.05")
    slippage_multiplier: Decimal = Decimal("5")
    max_slippage_pct: Decimal = Decimal("5")

    # ExactOut policy
    allow_approximate_buy: bool = False
    max_approximation_shortfall_pct: Decimal = Decimal("1")

    # Two-step firm quotes
    use_firm_quote: bool = False
    quote_max_age_seconds: int = 10

Three deliberate choices:

  • allow_approximate_buy defaults to False, inverting today's behaviour where approximation is on and invisible. A strategy asking for 1000 units usually wants 1000 or an error, not a silent 951.68. max_approximation_shortfall_pct bounds it even when opted in — checkable at quote time, since the quote already carries the estimate.
  • side maps to swap mode, not to a book side. BUY = ExactOut, SELL = ExactIn is what the DEX actually does.
  • No execution_strategy. A swap is a swap. Limit-like behaviour belongs in a separate LimitSwapExecutor that polls a quote and fires — a different lifecycle, not a fourth enum value.

States

NOT_ACTIVE → QUOTING → SWAPPING → COMPLETE
                ↓          ↓
              FAILED    FAILED
  • QUOTING — quote; enforce max_approximation_shortfall_pct. With use_firm_quote, hold the quote_id.
  • SWAPPING — submit. On SLIPPAGE_EXCEEDED, widen per the ramp and re-quote; any other failure retries at the same tolerance. A swap is an entry, so at the ceiling it stops rather than continuing — matching the LP executor's rule for opens (nothing is stranded by abandoning a swap; a position must still come out).
  • COMPLETE — record realized amount_in/amount_out, not the request.

custom_info

python
{
  "state", "side", "swap_provider", "pool_address",
  "requested_amount", "filled_amount_base", "filled_amount_quote", "effective_price",
  "approximation": bool, "approximation_shortfall_pct",
  "price_impact_pct", "tx_fee", "transaction_hash",
  "current_retries", "max_retries", "max_retries_reached",
  "slippage_pct",            # live value; above config = attempts have failed
}

Part 2 — LPAmmExecutor

The gap

There is no executor for AMM liquidity. lp_executor is CLMM-only: its config requires lower_price/upper_price, its state machine is built on IN_RANGE/OUT_OF_RANGE, and its lp_provider values are all */clmm.

So an AMM position is opened by hand and then nothing watches it. (measured) A Meteora DAMM v2 position sat open ~13 hours across a container rebuild with no monitoring; three separate accounting defects on it were found only by reading the chain afterwards, because no component was tracking what it should have held.

Why it is not lp_executor with the range removed

1. No range means different exit triggers. A CLMM position has an intrinsic "done" signal — price leaves the range and it stops earning. An AMM position is full-range and never stops earning, so its triggers must be economic:

python
upper_limit_price / lower_limit_price   # price-based, as CLMM
max_impermanent_loss_pct                # exit when IL exceeds a bound
min_fee_apr_pct                         # exit when the pool stops paying
max_duration_seconds                    # exit on time

max_impermanent_loss_pct is what earns this executor its place: IL is the whole risk of an AMM position, it is computable from entry and current price, and nothing computes it continuously today.

2. Two position models, one with no identity. Meteora DAMM v2 positions are NFTs with a position_address. Raydium, Uniswap and PancakeSwap AMM positions are fungible LP tokens — no position, just a balance indistinguishable from LP tokens acquired any other way.

The consequence: the fungible case cannot support a reliable orphan check and must not pretend to. The LP executor's recovery story rests on a position address that either exists on-chain or does not. For fungible LP the executor should record the LP token mint and the delta it believes it caused, and report both as believed rather than owned.

3. Rent is a set, not a number. (measured) Closing a DAMM v2 position closed four rent-bearing accounts — position, NFT mint, NFT token account, and the wrapped-SOL ATA. Code that backed out only the position account's rent recorded withdrawn liquidity at 4.1× the truth. Rent tracking must cover the set, on both open and close.

Proposed config

python
class LPAmmExecutorConfig(ExecutorConfigBase):
    type: Literal["lp_amm_executor"] = "lp_amm_executor"

    connector_name: str                    # network
    lp_provider: str                       # "meteora/amm", "raydium/amm", "uniswap/amm"
    swap_provider: Optional[str] = None    # close-out swap when keep_position=False
    pool_address: str
    trading_pair: str

    base_amount: Decimal = Decimal("0")
    quote_amount: Decimal = Decimal("0")

    # Exit triggers — an AMM position has no intrinsic "done"
    upper_limit_price: Optional[Decimal] = None
    lower_limit_price: Optional[Decimal] = None
    max_impermanent_loss_pct: Optional[Decimal] = None
    min_fee_apr_pct: Optional[Decimal] = None
    max_duration_seconds: Optional[int] = None

    # Same ramp, same defaults, same reset-at-phase-boundary rule
    slippage_pct: Decimal = Decimal("0.05")
    slippage_multiplier: Decimal = Decimal("5")
    max_slippage_pct: Decimal = Decimal("5")

    keep_position: bool = False
    extra_params: Optional[Dict] = None

Validation should reject a config with no exit trigger. A full-range position with no trigger never closes itself — a legitimate choice, but one the operator should make explicitly.

States

NOT_ACTIVE → OPENING → ACTIVE → CLOSING → COMPLETE
                                    ↘ SWAPPING → COMPLETE

ACTIVE replaces the IN_RANGE/OUT_OF_RANGE pair — there is no out of range. The executor polls price, fees and IL each tick and evaluates the triggers.

Close is remove_liquidity at 100%, which is the close — a full removal closes the position account and returns its rent in the same transaction. There should be no separate close call.

custom_info

python
{
  "state", "lp_provider", "pool_address",
  "position_address",        # None for fungible-LP connectors
  "lp_token_amount",         # the fungible case's only handle
  "base_amount", "quote_amount", "initial_base_amount", "initial_quote_amount",
  "entry_price", "current_price",
  "base_fee", "quote_fee", "fees_earned_quote",
  "impermanent_loss_quote", "impermanent_loss_pct",   # the point of the executor
  "fee_apr_estimate",
  "position_rent", "position_rent_refunded",          # the set, not one account
  "tx_fee",
  "current_retries", "max_retries", "max_retries_reached", "slippage_pct",
}

Impact

hummingbot

New packages mirroring the existing layout:

hummingbot/strategy_v2/executors/swap_executor/{__init__,data_types,swap_executor}.py
hummingbot/strategy_v2/executors/lp_amm_executor/{__init__,data_types,lp_amm_executor}.py

Two registration points, both one line each:

python
# strategy_v2/models/executors_info.py:18 — discriminated on `type`
AnyExecutorConfig = Union[..., SwapExecutorConfig, LPAmmExecutorConfig]

# strategy_v2/executors/executor_orchestrator.py:213
"swap_executor": SwapExecutor,
"lp_amm_executor": LPAmmExecutor,

Reused as-is, no changes needed:

  • strategy_v2/executors/gateway_utils.pyis_slippage_failure, next_slippage_pct, parse_provider. None is LP-specific; next_slippage_pct is pure arithmetic and is_slippage_failure keys on Gateway's SLIPPAGE_EXCEEDED code.
  • connector/gateway/gateway.py — already accepts slippage_pct and quote_id through kwargs on _place_order, and already returns realized amountIn/amountOut. The plumbing exists; nothing supplies it.

Sizing, from the existing packages: lp_executor is 1655 + 266 lines, order_executor 513 + 63. SwapExecutor should land nearer the latter; LPAmmExecutor nearer the former minus range machinery, plus IL.

Prerequisite — gateway_http_client is unmigrated for token and pool routes. — FIXED since filing. All eight token and pool methods (get_tokens, get_token, add_token, remove_token, get_pool, add_pool, remove_pool, list_pools) now send a single chainNetwork where they previously sent the pre-unification chain + network pair. This had blocked every Gateway executor, because GatewayBase.load_token_data() and all_trading_pairs() sit on the startup path of every Gateway connector. Verified fixed in a deployed wheel: an lp_executor against orca/clmm now creates, opens and reports normally.

hummingbot-api

python
# services/executor_service.py:122
"swap_executor":   (SwapExecutor, SwapExecutorConfig),
"lp_amm_executor": (LPAmmExecutor, LPAmmExecutorConfig),

The orphan filter is the one place a mistake would be silentservices/executor_service.py:699:

python
# lp_executor is the only executor type that owns an on-chain position
# account; filtering in SQL keeps the limit meaningful
records = await repo.get_executors_by_close_types(
    ["FAILED", "SYSTEM_CLEANUP", "POSITION_HOLD"], executor_type="lp_executor")

That comment stops being true the moment LPAmmExecutor exists. The filter must widen to both types — and only NFT-backed AMM connectors can be checked on-chain, so fungible ones need exclusion or an explicitly weaker answer. Left alone, the orphan endpoint reports all-clear while AMM positions sit stranded: the exact failure the comment was written to prevent.

Also: the type list in routers/executors.py:54 and the example at :293. No DB migration — the executors table stores config and final_state as JSON. Optionally link gateway_amm_positions.executor_id to give the AMM side the position↔executor join the CLMM side has; not required for a first cut.

MCP / agent client layer

For clients that drive executors over the API (in our case a Telegram + MCP front-end):

  • the executor type list surfaced by progressive disclosure
  • two new guides alongside the existing per-executor ones — this is the surface an agent actually reads, so the ExactOut/ExactIn distinction and the IL triggers belong there in full
  • executor preference sections
  • routing hints that currently send LP users to lp_executor unconditionally, which becomes wrong for AMM pools
  • swap routing should prefer SwapExecutor over order_executor for Gateway venues
  • response formatting for the new custom_info — worth checking against a real response rather than the model; we hit a case where the AMM branch of a formatter printed no amounts at all while the API was returning them correctly

Rollout

  1. Fix gateway_http_client's chainNetwork migration. Done — see above.
  2. LPAmmExecutor first — new capability, no migration, closes a real monitoring gap. Start with meteora/amm, where NFT positions give it a position address and therefore a working orphan story; add fungible-LP connectors second with the weaker ownership semantics stated explicitly.
  3. SwapExecutor second, alongside order_executor rather than replacing it. Route Gateway venues to it, leave CEX on order_executor, deprecate the Gateway branch only once the new path has run.
  4. Do not delete order_executor's Gateway support in the same change — its validate_sufficient_balance early-return is load-bearing for anything already pointed at it.

Open questions

  • Should SwapExecutor subsume the close-out swap lp_executor already performs? Today lp_executor does its own swap in SWAPPING. Delegating to a child SwapExecutor would unify the tolerance ramp and fill reporting, at the cost of executor-spawning-executor, which nothing in strategy_v2 currently does.
  • Does max_approximation_shortfall_pct belong in the executor or in Gateway? Gateway has the estimate first and could refuse. The argument for the executor is that acceptable shortfall is a strategy decision, not a venue one.
  • What does LPAmmExecutor report as filled_amount_base for a fungible-LP position? There is no position to read — only the wallet delta at open, which drifts as the pool rebalances. Possibly report the LP token balance and let the caller convert.
  • Is min_fee_apr_pct computable well enough to act on? A short observation window on a thin pool produced a 1448% APR estimate in our data. A trigger on a noisy estimate closes positions for no reason.