Skip to content

feat(lp-agent): add Solana DEX LP Expert — autonomous CLMM liquidity provisioning - #162

Merged
fengtality merged 9 commits into
mainfrom
fix/stop-guard-orphaned-positions
Jul 30, 2026
Merged

feat(lp-agent): add Solana DEX LP Expert — autonomous CLMM liquidity provisioning#162
fengtality merged 9 commits into
mainfrom
fix/stop-guard-orphaned-positions

Conversation

@fengtality

@fengtality fengtality commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Adds the Solana DEX LP Expert, an autonomous agent that provides concentrated liquidity to memecoin pools on Solana — plus a Solana-specific note in the order_executor guide that came out of running it live against a real wallet.

The /stop orphaned-positions guard (condor/agents/shutdown.py, condor/web/routes/agents.py) that used to be in this PR is unrelated to the LP agent and now lives in #184.


The agent

Each tick it monitors open LP slots, exits any that hit take-profit / stop-loss or idle out of range, and fills one free slot with the best-yielding pool it doesn't already hold.

  • Scanlp_scanner routine pulls GeckoTerminal pools across Meteora / Orca / Raydium and ranks by fee yield (fees ÷ TVL) over a configurable window. The quote asset is matched by mint on either pool side, so a SOL-quoted scan also finds pools GeckoTerminal lists as SOL/USDC (SOL as base) — orientation is normalized before ranking. Telegram-form inputs are tolerated: list fields accept comma/space-separated text, and blank coerces to empty.
  • Size — capital split into slots, each opened at a base_pct base/quote ratio (default 20/80).
  • Place — LP Executors (side=3 RANGE, keep_position=false), range placed asymmetrically so an even Spot distribution lands the target ratio.
  • Manage — per-slot TP/SL, plus an out-of-range idle exit (out_of_range_max_sec) that recycles capital sitting out of range earning nothing.
  • Wind downshutdown.md declares on_kill_switch: flatten_all: positions are risk to close, never spot to keep.

Files: AGENT.md, shutdown.md, strategies/lp_slot_operator/strategy.md, 4 skills (pool_ranking, lp_range_config, slot_exit, lp_bot_report), and the lp_scanner routine. Runtime session data (journals, snapshots) is deliberately untracked, matching the existing market_making_expert layout.

Two correctness rules that took live runs to find

Range bounds flip by venue. Meteora quotes SOL-per-memecoin (memecoin above P); Orca and Raydium invert it (memecoin below P). Apply one convention to the other and the bounds don't bracket the live price — the open fails on-chain with no funds moved and the slot silently stays empty.

Width clamps must be in granularity units, not percent. Percent width isn't comparable across pools:

Meteora:       bins     = ln(Pu/Pl) / ln(1 + bin_step/10000)      < 69
Orca/Raydium:  spacings = ln(Pu/Pl) / (ln(1.0001) × tick_spacing) ≤ 120

Two Orca opens at an identical 20.8% width landed on opposite sides purely on tick_spacing: 16 → 118 spacings opened fine, 8 → 237 failed. Successes observed at 75 / 76 / 98 / 118 spacings; the only failure was 237. Any percent-based rule of thumb is wrong in both directions — it rejects good ranges and admits failing ones.

The winddown's swap-back leg is not guaranteed

keep_position=false should remove liquidity and swap the base tokens back to quote. Observed twice that only the first leg ran: liquidity came out, the swap silently failed, and the full base position sat as spot while every lp_executor reported a clean EARLY_STOP.

A clean executor status is not evidence the tokens were converted. shutdown.md says so and requires re-reading the on-chain balance and retrying until it's ~0.

order_executor guide: Solana sizing note

executed_amount_base is the amount REQUESTED, not received. The agent swaps quote→base, then immediately opens the LP with what it just bought. But order_executor reports the requested amount (62), while slippage and fees mean the wallet actually receives less (61.962753, −0.06%; another case −0.44%). Passing the reported fill into base_amount asks the pool for tokens that aren't there — the open fails, position_address=null, no funds move.

Fix, now documented in the guide: base_amount = executed_amount_base × 0.995, or read the true post-swap balance. This was the cause of every "SIMULATION_FAILED" open. After the fix: 7 opens across two sessions, 0 failures (previously 3 and 2 failures per session).


Testing

Run live against a real Solana wallet across sessions 8–11: start/stop/shutdown, 3 concurrent LP slots, TP/SL and out-of-range idle exits, and full winddown. All positions verified closed on-chain — position accounts and NFTs checked directly via RPC, not just the API, after the API's total_value_quote was found to misreport a slot's value by ~2.7×. Final wallet reconciled to chain.

No secrets included — the staged diff was scanned for API keys, RPC credentials, wallet addresses, and tokens before commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_0154EUNSY4ocqsWPKZWgwBJn

fengtality and others added 2 commits July 21, 2026 11:57
Stopping a strategy that still holds open positions silently stranded
them. `engine.stop()` cleans up executor tracking (executors land in
SYSTEM_CLEANUP) while the on-chain position stays live, so the normal
`stop_executor(keep_position=False)` close path can no longer reach it.
Recovery means going around the executor layer entirely.

This is only wrong for agents whose shutdown policy declares
`on_kill_switch: flatten_all` -- those are saying their positions are
risk to close, not spot to keep. For them a plain stop is almost always
a mistake; `shutdown` is the operation that winds down per shutdown.md.

Guard `/stop` for exactly that case: 409 with the open executors and
positions named, pointing at `shutdown`, with `force=true` as the
explicit override. Other policies (keep_all, keep_spot_close_perp) are
untouched, as is stopping an agent that holds nothing.

`open_risk()` reuses the winddown's own executor/position lookups so the
guard and the shutdown path cannot disagree about what "open" means.

In the all-instances branch every engine is guarded before any is
stopped, so a 409 can't leave half the instances down.

Verified against the failure it fixes. Before, stopping the LP agent
with two live positions succeeded and left both as SYSTEM_CLEANUP with
~$25 stranded on-chain and no alert. After, the same stop returns 409
naming the open position, and `shutdown` attempts the flatten and raises
its stranded-position alert when a swap fails -- no silent orphan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EUNSY4ocqsWPKZWgwBJn
…-limit guidance

Adds the LP Slot Operator agent (scans GeckoTerminal for memecoin CLMM pools,
ranks by fee yield, runs LP Executors across Meteora/Orca/Raydium with per-slot
TP/SL) and documents three failure modes found while running it live.

shutdown.md -- the swap-back leg is not guaranteed:
`keep_position=false` is meant to remove liquidity AND swap the base tokens back
to quote. Observed twice that only the first leg ran: liquidity came out, the
swap silently failed, and the full base position sat as spot (BONK 1.74M, PUMP
2588, ANSEM 24.6, JIMOTHY 253) while every lp_executor reported a clean
EARLY_STOP. A clean executor status is not evidence the tokens were converted.
The winddown now says so, and requires re-reading the on-chain balance and
retrying until it is ~0.

order_executor.md -- Jupiter rate limits masquerade as NO_ROUTE_FOUND:
A throttled swap surfaces as `NO_ROUTE_FOUND` carrying the text
`Unexpected token 'R', "Rate limit"... is not valid JSON`. The route is fine and
the token is tradable; treating it as unsellable is the wrong read. Documents
the tell and the pacing that follows from it -- ~2s between swaps with a Jupiter
key (api.jup.ag, ~60 req/min), >=15-20s without one (lite-api.jup.ag). The same
three sells failed after 10+ retries each on the keyless tier and went through
with 0-1 retries once a key was configured.

order_executor.md -- executed_amount_base reports the amount REQUESTED:
Slippage and fees mean the wallet receives less (observed -0.06% to -0.44%).
Feeding it straight into a downstream call that must spend those tokens asks for
more than you hold and fails on-chain. Apply a 0.995 haircut or read the true
post-swap balance. strategy.md carries the same rule at the swap->LP-open seam,
where it caused every "SIMULATION_FAILED" open until fixed.

strategy.md also gains the venue width clamp in granularity units rather than
percent: Meteora bins = ln(Pu/Pl)/ln(1+bin_step/10000) < 69, Orca/Raydium
spacings = ln(Pu/Pl)/(ln(1.0001)*tick_spacing) <= 120. Percent width is not
comparable across pools -- two Orca opens at an identical 20.8% split on
tick_spacing alone (16 -> 118 spacings opened; 8 -> 237 failed).

Runtime session data (journals, snapshots) is deliberately not tracked, matching
the existing market_making_expert layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EUNSY4ocqsWPKZWgwBJn
@fengtality fengtality changed the title fix(agents): refuse a plain stop that would orphan on-chain positions feat(lp-agent): Solana DEX LP Expert + refuse a plain stop that would orphan positions Jul 22, 2026
@fengtality fengtality changed the title feat(lp-agent): Solana DEX LP Expert + refuse a plain stop that would orphan positions feat(lp-agent): add Solana DEX LP Expert — autonomous CLMM liquidity provisioning Jul 22, 2026
@rapcmia rapcmia moved this to Backlog in Pull Request Board Jul 22, 2026
@rapcmia rapcmia self-assigned this Jul 22, 2026
@rapcmia rapcmia removed their assignment Jul 24, 2026
@fengtality
fengtality force-pushed the fix/stop-guard-orphaned-positions branch from 1b9d01b to 414f427 Compare July 24, 2026 13:08
… guidance

The gateway (feat/robinhood-chain) now detects Jupiter throttling, retries with
backoff, and returns a clean 429 instead of masquerading as NO_ROUTE_FOUND — so the
"don't blacklist / pace swaps ≥15-20s" guidance is superseded. Keep only the
separate executed_amount_base haircut note (unrelated to rate limits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9Vygff6wPFpmZb5gahuW4
@fengtality
fengtality force-pushed the fix/stop-guard-orphaned-positions branch from 7117d0c to bdc364c Compare July 24, 2026 13:22
fengtality and others added 3 commits July 24, 2026 06:24
Cut ~30% of the per-tick system prompt — removed verbose prose, repeated examples,
and the exact range-bound formulas (they live in the lp_range_config skill). Kept
every load-bearing fact: network-vs-venue mapping, the asymmetric venue-flip range
placement, the bracket-price HARD GUARDRAIL, the ~69-bin Meteora cap, and the
fees/TVL ranking. Defer detail to the skills.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9Vygff6wPFpmZb5gahuW4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9Vygff6wPFpmZb5gahuW4
@fengtality
fengtality force-pushed the fix/stop-guard-orphaned-positions branch from e1b1d4c to 3da5fda Compare July 24, 2026 23:36
@rapcmia rapcmia moved this from Backlog to Under Review in Pull Request Board Jul 28, 2026
@rapcmia

rapcmia commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Commit 36fc10d

  • Build local .whl from staging and deploy with HAPI
  • Setup Condor + HAPI successfully
  • Deployed gateway via HAPI using staging branch docker image
  • Observed the behavior between keep_position false and true from previous tests ✅
  • For most of the test, I consult/delegate solana_dex_lp_expert and switched agents from claude to codex (vice versa) ✅
  • Open a small LP per venue and verify bounds bracket price, respect bin/tick caps, and create a position. ✅

Test base_pct 0, 20, and 100 to catch the conflicting range instructions. ✅

## `base_pct` → sizing (key lever; full presets in the `lp_range_config` skill)
- **`0`** → quote-only, `side=1`, range **below** P, no swap.
- **`100`** → base-only: swap quote→base first, `side=2`, range **above** P.
- **`0<base_pct<100`** → double-sided, `side=3`: `quote_amount = capital×(1−base_pct/100)`, acquire base worth `capital×base_pct/100` (swap the shortfall).
  • While checking the solana_dex_lp_exert, i found this base_pct to test behavior
    • base_pct=0 opened a USDC-only BUY LP with no SOL. It was correctly out of range above its buy range.
    • base_pct=40 opened a mixed SOL/USDC RANGE LP. The live price was inside its range.
    • base_pct=100 opened a SOL-only SELL LP with no USDC. It was correctly out of range below its sell range.

Verify unavailable executor/position data blocks stop rather than allowing it. ✅

#### confirm the Solana LP expert blocks all actions for the terminated executor
curl -sS --max-time 180 -H "Authorization: Bearer XXX" -H "Content-Type: application/json" -X POST http://localhost:8088/api/v1/agents/solana_dex_lp_expert/consult -d '
{
  "task": "Planning-only negative QA check; block all LP actions for a terminated, failed executor with no position.",
  "context": "Reference executor FCLc3WAqbdpkByJfDZTQMHQYNqXcnv2veizGRgd32JAG is terminated, FAILED, and has position_address null.",
  "server_name": "local"
}' | jq
{
  "agent": "solana_dex_lp_expert",
  "answer": {
    "lookup_status": "terminated_failed_no_position",
    "recommended_action": "block_all_lp_actions",
    "blocked": true,
    "reason": "Reference executor is terminated with FAILED state and position_address=null"
  }
}
  • Condor found an old LP executor that had already stopped and had no position attached to it.
  • The Solana LP expert stopped the process and did not make any LP change, swap, or transaction.
  • This confirms the safety check works when an executor has stopped or its position data is missing.

Confirm scanner results for SOL/USDC pools regardless of GeckoTerminal token orientation across Meteora, Orca, and Raydium. ❌

  • I ran the default config and it only displayed pairs ending with XXX-SOL
    image

  • After i updated the Base token mints to exclude (already held) does not let use the routine anymore ❌
    image

    condor.routine_store - ERROR - Routine lp_scanner[0b61e696] failed: ValidationError: 1 validation error for Config
    exclude_mints
      Input should be a valid list [type=list_type, input_value='', input_type=str]
      For further information visit https://errors.pydantic.dev/2.12/v/list_type
    Traceback (most recent call last):
      File "/home/eddga/hummingbot/condor/162/condor/routine_store.py", line 333, in _execute_and_record
        cfg = routine.config_class(**config)
      File "/home/eddga/hummingbot/condor/162/.venv/lib/python3.13/site-packages/pydantic/main.py", line 250, in __init__
        validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
    pydantic_core._pydantic_core.ValidationError: 1 validation error for Config
    exclude_mints
      Input should be a valid list [type=list_type, input_value='', input_type=str]
      For further information visit https://errors.pydantic.dev/2.12/v/list_type
    
    • Leaving Base token mints to exclude (already held) blank prevents the LP scanner from starting, so the SOL/USDC orientation check cannot run.
    • The routine receives exclude_mints as "", but its configuration requires a list; it stops with a Pydantic validation error before generating a report.
    • Entering 0, a space, {}, or [] gives the same error, which confirms the form is submitting text values instead of omitting the field or sending a list.

…t on either pool side

QA found two blockers in lp_scanner:

- Leaving "exclude_mints" blank in the Telegram config form submits "" (a
  string), which failed Pydantic list validation before the routine could
  run. venues/exclude_pools/exclude_mints now coerce comma/space-separated
  text ("" -> []) via a before-validator.

- The quote filter compared GeckoTerminal's quote_token_symbol only, so a
  SOL-quoted scan dropped every pool Gecko happens to list as SOL/USDC
  (SOL as base) - only XXX-SOL pairs ever appeared. The quote side is now
  matched by mint on either side of the pool and flipped into base-quote
  orientation when Gecko lists it inverted (price taken from
  quote_token_price_usd in that case). Verified live: 13 SOL/USDC pools
  previously dropped now rank.

Unsupported quote assets now error out early instead of silently matching
nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@fengtality

Copy link
Copy Markdown
Contributor Author

Both ❌ items are addressed in f1160c3:

Blank exclude_mints no longer blocks the routine. The Telegram form submits list fields as text, so venues / exclude_pools / exclude_mints now accept comma- or space-separated text via a mode="before" validator — blank ("" or whitespace) coerces to [], and mintA, mintB parses to a list. The Pydantic list_type error is gone.

SOL/USDC pools now appear regardless of GeckoTerminal orientation. The scanner previously kept a pool only when Gecko's quote_token_symbol matched the configured quote, so a SOL-quoted scan dropped every pool Gecko lists as SOL/USDC (SOL as base) — hence only XXX-SOL rows. The quote asset is now matched by mint on either side of the pool, and inverted listings are flipped into base-quote orientation (price sourced from quote_token_price_usd when flipped). Verified against live GeckoTerminal data: 13 SOL/USDC pools across Meteora/Orca/Raydium that were previously dropped now enter the ranking as USDC-SOL.

Also, an unsupported quote_asset now returns a clear error up front instead of silently matching nothing.

🤖 Generated with Claude Code

@fengtality
fengtality merged commit baa703c into main Jul 30, 2026
@rapcmia rapcmia moved this from Under Review to Condor in Pull Request Board Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Condor

Development

Successfully merging this pull request may close these issues.

2 participants