Proposal: SwapExecutor and LPAmmExecutor executor types
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_idand the ones a swap needs are absent. The class already knows it does not fit — order_executor.py:439:
# 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-flightAn 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_namemust be a network. Anything absent from_conn_settingsis constructed asGateway(connector_name=...)and parsed aschain-network, sodflow/routeris not a valid value there.- The router comes from
self._swap_provider, whichgateway_base.py:185documents as "fetched from network config". Forsolana-mainnet-betathat isswapProvider: jupiter/router. - The only override is a
dex_namekwarg read atgateway.py:368, andorder_executornever 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
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 = 10Three deliberate choices:
allow_approximate_buydefaults toFalse, 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_pctbounds it even when opted in — checkable at quote time, since the quote already carries the estimate.sidemaps 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 separateLimitSwapExecutorthat 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. Withuse_firm_quote, hold thequote_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
{
"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:
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 timemax_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
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] = NoneValidation 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 → COMPLETEACTIVE 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
{
"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}.pyTwo registration points, both one line each:
# 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.py—is_slippage_failure,next_slippage_pct,parse_provider. None is LP-specific;next_slippage_pctis pure arithmetic andis_slippage_failurekeys on Gateway'sSLIPPAGE_EXCEEDEDcode.connector/gateway/gateway.py— already acceptsslippage_pctandquote_idthrough kwargs on_place_order, and already returns realizedamountIn/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 — — FIXED since filing. All eight token and pool methods (gateway_http_client is unmigrated for token and pool routes.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
# 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 silent — services/executor_service.py:699:
# 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_executorunconditionally, which becomes wrong for AMM pools - swap routing should prefer
SwapExecutoroverorder_executorfor 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
FixDone — see above.gateway_http_client'schainNetworkmigration.LPAmmExecutorfirst — new capability, no migration, closes a real monitoring gap. Start withmeteora/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.SwapExecutorsecond, alongsideorder_executorrather than replacing it. Route Gateway venues to it, leave CEX onorder_executor, deprecate the Gateway branch only once the new path has run.- Do not delete
order_executor's Gateway support in the same change — itsvalidate_sufficient_balanceearly-return is load-bearing for anything already pointed at it.
Open questions
- Should
SwapExecutorsubsume the close-out swaplp_executoralready performs? Todaylp_executordoes its own swap inSWAPPING. Delegating to a childSwapExecutorwould unify the tolerance ramp and fill reporting, at the cost of executor-spawning-executor, which nothing instrategy_v2currently does. - Does
max_approximation_shortfall_pctbelong 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
LPAmmExecutorreport asfilled_amount_basefor 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_pctcomputable 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.
Source: hummingbot/hummingbot