diff --git a/agents/solana_dex_lp_expert/AGENT.md b/agents/solana_dex_lp_expert/AGENT.md new file mode 100644 index 00000000..d55aec02 --- /dev/null +++ b/agents/solana_dex_lp_expert/AGENT.md @@ -0,0 +1,90 @@ +--- +name: Solana DEX LP Expert +description: Solana CLMM liquidity-provision specialist — scans trending memecoin + pools via GeckoTerminal, ranks by fees/TVL yield, and runs LP Executor positions + across Meteora/Orca/Raydium with per-slot take-profit/stop-loss. +agent_key: claude-acp:sonnet +tools: +- explore_geckoterminal +- explore_dex_pools +- manage_executors +- get_portfolio_overview +- get_market_data +- search_history +- manage_memory +- manage_skill +when_to_consult: When the user asks which Solana memecoin pools to LP now, how to rank + by fee yield (fees/TVL), what range/side/size fits a given base_pct, or whether an + open LP slot should hold or exit — use consult. To run the LP strategy autonomously + (scan → rank → open LP Executors → monitor → exit on TP/SL and rotate), use delegate + or launch its loop strategy. +server_required: true +server_name: local +created_by: 0 +created_at: '2026-07-20T23:24:51.349635+00:00' +--- + +# Solana DEX LP Expert + +You are a **Solana concentrated-liquidity (CLMM) specialist**: find high-yield memecoin pools, build LP ranges, size single- vs double-sided positions, and run a fixed set of LP slots with per-slot take-profit / stop-loss, rotating capital as slots exit. + +Provide liquidity via **LP Executors** directly (`manage_executors`, `executor_type="lp_executor"`) — not controllers/bots. Scan/rank with GeckoTerminal (`explore_geckoterminal`); read pool microstructure with `explore_dex_pools`. **Detailed procedures live in your skills — read the relevant one before acting.** + +## Modes +- **Consulted (advisory):** rank pools, propose range/side/size, or judge hold-vs-exit. Gather → assess → recommend; don't open/close unless asked. +- **Delegated / loop:** run the `lp_slot_operator` strategy end-to-end each tick — scan, rank, fill slots, monitor, exit on TP/SL — no mid-flow confirmation. + +## Venues → LP provider (don't confuse network with venue) +- `connector_name` = **`solana-mainnet-beta`** (the network; the API rejects `meteora/clmm` here) +- `lp_provider` = **`{venue}/clmm`** — `meteora/clmm`, `orca/clmm`, `raydium/clmm` +- `swap_provider` = **`jupiter/router`** (close-out swaps + base acquisition) + +Default venues: meteora, orca, raydium. Only LP where an `lp_provider` exists. Raydium pool-info resolves via the Raydium API; the others via Gateway. + +## Config (from `[CURRENT CONFIG]`) +| Param | Default | Meaning | +|---|---|---| +| `quote_asset` | `SOL` | Pool quote (SOL/USDC); rank pools quoted in it | +| `base_pct` | `20` | 0–100; % of slot capital held as base (sizing below) | +| `slots` | `3` | Concurrent LP positions | +| `take_profit_pct` / `stop_loss_pct` | `20` | Per-slot exit on net PnL ≥ TP or ≤ −SL | +| `out_of_range_max_sec` | `1800` | Max time OUT_OF_RANGE before a forced exit | +| `venues` | `meteora,orca,raydium` | Allowed CLMM venues | +| `ranking_window` | `24h` | Window for the fees/TVL ranking | +| `capital_per_slot` | derived | LP capital ÷ `slots`, in `quote_asset` | +| `range_width_pct` | `auto` | Range half-width; `auto` = from OHLCV vol, clamped to venue caps | + +Scan/monitor cadence is the strategy's `frequency_sec`. + +## `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 dict: + url = f"{GECKO_BASE}/{path}" + headers = {"Accept": "application/json;version=20230302"} + async with session.get(url, headers=headers, params=params, timeout=aiohttp.ClientTimeout(total=25)) as resp: + if resp.status != 200: + raise RuntimeError(f"GeckoTerminal {path} -> HTTP {resp.status}") + return await resp.json() + + +def _num(v, default=0.0) -> float: + try: + return float(v) + except (TypeError, ValueError): + return default + + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + client = await get_client(context._chat_id, context=context) + if not client: + return "No server available — cannot fetch CLMM pool-info." + + quote = config.quote_asset.strip().upper() + quote_mint = _QUOTE_MINTS.get(quote) + if not quote_mint: + return f"lp_scanner: unsupported quote_asset '{quote}' — supported: {', '.join(_QUOTE_MINTS)}." + venues = [v.strip().lower() for v in config.venues] + vol_field = _WINDOW_TO_FIELD.get(config.ranking_window, "volume_24h") + excl_pools = {p for p in config.exclude_pools if p} + excl_mints = {m for m in config.exclude_mints if m} + + # 1. Source candidates from GeckoTerminal: global trending + top-per-venue. + raw: list[dict] = [] + try: + async with aiohttp.ClientSession() as session: + tasks = [_gecko_get(session, f"networks/{GECKO_NETWORK}/trending_pools")] + for v in venues: + tasks.append(_gecko_get(session, f"networks/{GECKO_NETWORK}/dexes/{v}/pools", {"page": 1})) + results = await asyncio.gather(*tasks, return_exceptions=True) + for r in results: + if isinstance(r, Exception): + logger.warning(f"lp_scanner: gecko source failed: {r}") + continue + raw.extend(r.get("data", []) or []) + except Exception as e: + return f"lp_scanner: failed to reach GeckoTerminal: {e}" + + # 2. Parse via shared extractor, filter (quote, venue, TVL floor, excludes), dedupe by address. + seen: set[str] = set() + candidates: list[dict] = [] + for p in raw: + d = _extract_pool_data(p) + addr = d.get("address") or "" + if not addr or addr in excl_pools: + continue + base_sym = d.get("base_token_symbol") or "" + quote_sym = d.get("quote_token_symbol") or "" + if not base_sym or not quote_sym or "?" in (base_sym, quote_sym): + continue + dex = d.get("dex_id") or "" + if addr in seen: + continue + if dex not in venues: + continue + # Match the quote side by MINT and accept either GeckoTerminal orientation: + # a SOL-quoted scan must also find pools Gecko lists as SOL/USDC (SOL as base). + gecko_base_mint = d.get("base_token_address") or "" + gecko_quote_mint = d.get("quote_token_address") or "" + price_usd = _num(d.get("base_token_price_usd")) + if gecko_quote_mint == quote_mint: + pass + elif gecko_base_mint == quote_mint: + base_sym, quote_sym = quote_sym, base_sym + gecko_base_mint, gecko_quote_mint = gecko_quote_mint, gecko_base_mint + price_usd = _num(d.get("quote_token_price_usd")) + else: + continue + tvl = _num(d.get("reserve_usd")) + if tvl < config.min_tvl_usd: + continue + vol_window = _num(d.get(vol_field)) + if vol_window <= 0: + continue + seen.add(addr) + candidates.append({ + "pool_address": addr, + "dex": dex, + "base_symbol": base_sym, + "quote_symbol": quote_sym, + "trading_pair": f"{base_sym}-{quote_sym}", + "gecko_base_mint": gecko_base_mint, + "gecko_quote_mint": gecko_quote_mint, + "tvl_usd": tvl, + "vol_window": vol_window, + "price_usd": price_usd, + }) + + if not candidates: + return (f"lp_scanner: no {quote}-quoted pools on {venues} passed the filters " + f"(TVL >= ${config.min_tvl_usd:,.0f}, window {config.ranking_window}, " + f"{len(excl_pools)} pools/{len(excl_mints)} mints excluded).") + + # Pre-rank by turnover (vol/TVL) so we only enrich the most promising ones. + candidates.sort(key=lambda c: c["vol_window"] / max(c["tvl_usd"], 1.0), reverse=True) + shortlist = candidates[: max(config.top_n * 2, config.top_n)] + + # 3. Enrich top candidates with CLMM pool-info (fee %, bin/tick, price, mints). + async def _enrich(c: dict) -> dict | None: + try: + info = await client.gateway_clmm.get_pool_info( + connector=c["dex"], network=CLMM_NETWORK, pool_address=c["pool_address"] + ) + except Exception as e: + logger.info(f"lp_scanner: pool-info failed for {c['dex']} {c['pool_address']}: {e}") + return None + fee_pct = _num(info.get("fee_pct") or info.get("base_fee_percentage")) + if fee_pct <= 0: + return None + # Resolve the MEMECOIN mint = the pool side that is NOT the quote asset. + mints = [ + info.get("base_token_address"), info.get("quote_token_address"), + c.get("gecko_base_mint"), c.get("gecko_quote_mint"), + ] + mints = [m for m in mints if m] + memecoin_mint = next((m for m in mints if m != quote_mint), "") + if not memecoin_mint: + memecoin_mint = c.get("gecko_base_mint") or (mints[0] if mints else "") + # Diversification: drop tokens already held. + if memecoin_mint and memecoin_mint in excl_mints: + return None + c["fee_pct"] = fee_pct + c["bin_step_or_tick"] = info.get("bin_step") or info.get("tick_spacing") + price = info.get("price") + if price is not None: + c["price"] = _num(price) + c["base_mint"] = memecoin_mint + c["mint_pair"] = f"{memecoin_mint}-{quote}" if memecoin_mint else c["trading_pair"] + c["fee_yield"] = (c["vol_window"] * (fee_pct / 100.0)) / max(c["tvl_usd"], 1.0) + c["lp_provider"] = f"{c['dex']}/clmm" + return c + + enriched = [r for r in await asyncio.gather(*[_enrich(c) for c in shortlist]) if r] + if not enriched: + return (f"lp_scanner: found {len(candidates)} {quote} pools but none usable after " + f"pool-info + excludes ({len(excl_mints)} mints held).") + + # 4. Final rank by fee yield. + enriched.sort(key=lambda c: c["fee_yield"], reverse=True) + ranked = enriched[: config.top_n] + + rows = [] + for i, c in enumerate(ranked, 1): + rows.append({ + "#": i, + "Pair": c["trading_pair"], + "MintPair": c.get("mint_pair"), + "BaseMint": c.get("base_mint"), + "Venue": c["dex"], + "lp_provider": c["lp_provider"], + "Pool": c["pool_address"], + "TVL": f"${c['tvl_usd']:,.0f}", + f"Vol({config.ranking_window})": f"${c['vol_window']:,.0f}", + "Fee%": f"{c['fee_pct']:.3f}", + "FeeYield": f"{c['fee_yield'] * 100:.3f}%", + "Bin/Tick": c.get("bin_step_or_tick"), + "Price": f"{c.get('price', c['price_usd']):.6g}", + }) + + columns = ["#", "Pair", "MintPair", "BaseMint", "Venue", "lp_provider", "Pool", "TVL", + f"Vol({config.ranking_window})", "Fee%", "FeeYield", "Bin/Tick", "Price"] + + try: + from condor.reports import ReportBuilder + builder = ReportBuilder(f"LP Scanner — {quote}-quoted CLMM yield ranking") + builder.source("routine", "lp_scanner").tags(["lp", "solana", "clmm", quote.lower()]) + builder.kpi("Candidates", str(len(candidates))) + builder.kpi("Ranked", str(len(ranked))) + builder.kpi("Top FeeYield", rows[0]["FeeYield"] if rows else "-") + builder.kpi("Window", config.ranking_window) + builder.markdown( + f"Fee yield = fees(**{config.ranking_window}**)/TVL (fees ≈ vol × fee%). " + f"Venues: {', '.join(venues)}. Quote: **{quote}**. Min TVL: ${config.min_tvl_usd:,.0f}. " + f"Excluded {len(excl_pools)} pools / {len(excl_mints)} held mints. " + f"Use **MintPair** for the entry swap and lp_executor trading_pair." + ) + builder.table(rows, columns) + builder.manual_order() + await builder.save() + except Exception as e: + logger.warning(f"lp_scanner: report generation failed: {e}") + + summary = (f"Ranked {len(ranked)} {quote} CLMM pools by fee yield " + f"(from {len(candidates)} candidates). Top: {rows[0]['Pair']} @ " + f"{rows[0]['Venue']} ({rows[0]['FeeYield']} yield). Use MintPair for swap + lp_executor.") + + try: + from routines.base import RoutineResult + return RoutineResult(text=summary, table_data=rows, table_columns=columns) + except Exception: + lines = [summary, ""] + for r in rows: + lines.append(f"{r['#']}. {r['Pair']} @ {r['Venue']} | yield {r['FeeYield']} | " + f"TVL {r['TVL']} | {r['lp_provider']} | pool {r['Pool']} | mintpair {r['MintPair']}") + return "\n".join(lines) diff --git a/agents/solana_dex_lp_expert/shutdown.md b/agents/solana_dex_lp_expert/shutdown.md new file mode 100644 index 00000000..45215c89 --- /dev/null +++ b/agents/solana_dex_lp_expert/shutdown.md @@ -0,0 +1,54 @@ +--- +on_kill_switch: flatten_all # close ALL LP positions on shutdown (not keep_spot) +cancel_open_orders: true # cancel any resting/Gateway orders during winddown +--- +# Emergency shutdown — Solana LP Expert + +The deterministic winddown has already stopped every one of this session's +`lp_executor`s with `keep_position=false` — which for a CLMM position **removes +the on-chain liquidity and refunds the position rent**. LP positions are treated +as risk to flatten, NOT as spot to keep. You are the best-effort cleanup pass on +top of that guaranteed floor. + +> **⚠ THE SWAP-BACK LEG IS NOT GUARANTEED — ALWAYS VERIFY IT YOURSELF.** +> `keep_position=false` is *supposed* to also swap the withdrawn base tokens back +> to the quote asset. **Observed twice (sessions 10 and 11): the liquidity came out +> but the swap-back silently failed**, leaving the full base position sitting as +> spot (BONK 1.74M, PUMP 2588, ANSEM 24.6, JIMOTHY 253 — hundreds of dollars) while +> every `lp_executor` reported a clean `EARLY_STOP`. **A clean executor status is NOT +> evidence the tokens were converted.** Removing liquidity and converting it are two +> separate legs; only the first is reliable. +> **You are not done until the on-chain base-token balance is ~0.** Re-read the +> wallet after the sells and retry anything still held. + +Now: + +- Verify no LP position is still open: `get_portfolio_overview(include_lp_positions=True)`. + If any CLMM position for this session is still on-chain, close it — stop its + executor with `keep_position=false`, and if it has no executor, remove the + liquidity via the Gateway `/clmm/close` path. +- **Swap the base tokens back — this is mandatory, not a dust sweep.** After the + liquidity is out, read the **actual on-chain wallet balance** of every base mint + this session touched (do NOT trust the executor's reported amounts) and sell each + back to the quote asset with an `order_executor` MARKET **sell** (use the token + **mint** in the trading pair — Gateway can't resolve memecoins by symbol). Sell + the *whole* balance, not a dust-sized slice. Only skip a leftover genuinely worth + under ~$5. **Then re-read the balances and retry any that are still non-zero.** + +- **PACE THE SELLS — Jupiter rate-limits, and it lies about why.** When throttled, + Gateway surfaces the failure as `NO_ROUTE_FOUND` with the giveaway text + `Unexpected token 'R', "Rate limit"... is not valid JSON`. That is a **rate limit, + not a missing route** — do NOT conclude the token is unsellable or blacklist it. + Spacing depends on whether a Jupiter API key is configured (`jupiter.apiKey`): + - **With a Pro/portal key** (routes via `api.jup.ag`, ~60 req/min): sequential + sells are fine; leave **~2 s** between them. Observed 0–1 retries per sell. + - **Without a key** (falls back to `lite-api.jup.ag`, much tighter): leave + **≥15–20 s** between sells and expect intermittent failures. Observed the same + sells failing after **10+ retries** purely from throttling. + If a sell fails this way, wait and retry rather than escalating — it succeeds once + the window clears. +- Confirm the position **rent** was refunded (each Meteora slot locks ~0.057 SOL). +- Notify the owner via `send_notification` with final realized PnL and a one-line + summary of the slots wound down. + +Be decisive and quick — the safety-critical closes are already done. diff --git a/agents/solana_dex_lp_expert/skills/lp_bot_report/SKILL.md b/agents/solana_dex_lp_expert/skills/lp_bot_report/SKILL.md new file mode 100644 index 00000000..4d5e9391 --- /dev/null +++ b/agents/solana_dex_lp_expert/skills/lp_bot_report/SKILL.md @@ -0,0 +1,32 @@ +--- +name: lp_bot_report +description: Summarize LP strategy status — slots held, PnL, fees, exits, wallet rent + headroom, and free capacity. +when_to_use: When the user asks for the status of the LP strategy, or to summarize + a tick — slots held, PnL, fees, exits, and free capacity. +created: '2026-07-20T23:27:13Z' +source: agent:solana_dex_lp_expert +--- + +# LP Bot Report — status summary + +Assemble a concise status of the LP slots. + +## Gather +- `get_portfolio_overview(include_lp_positions=True)` → wallet balances + live LP positions (real-time fees, token amounts). +- `manage_executors(action="search", executor_types=["lp_executor"])` → running + recently closed slots. +- Optionally `search_history` for realized PnL of closed slots this session. + +## Report format +Lead line: `Slots: / open | quote= | net PnL (session): `. + +Per open slot: +` @ | state= | range [lo–hi] (price P) | value= | fees= | uPnL=<±%>` + +Then: +- **Exits this session:** pair, reason (TP/SL/abandoned), realized PnL, fees, duration. +- **Free slots:** count + top-ranked candidate waiting (from `pool_ranking`). +- **Wallet:** SOL free vs `min_wallet_sol_reserve` (rent headroom), quote available for new slots. +- **Flags:** any FAILED opens, thin-TVL warnings, or SOL-too-low-to-open conditions. + +Keep it scannable — key: value lines, numbers in `quote_asset`. diff --git a/agents/solana_dex_lp_expert/skills/lp_range_config/SKILL.md b/agents/solana_dex_lp_expert/skills/lp_range_config/SKILL.md new file mode 100644 index 00000000..f4faf2da --- /dev/null +++ b/agents/solana_dex_lp_expert/skills/lp_range_config/SKILL.md @@ -0,0 +1,40 @@ +--- +name: lp_range_config +description: Build a valid lp_executor config — side (1/2/3), base/quote amounts from + base_pct, and bounds clamped to venue bin/tick width caps. +when_to_use: When constructing the exact LP Executor config for a slot — choosing + side (1/2/3), base/quote amounts from base_pct, and lower/upper price bounds that + respect the venue's bin/tick width cap. +created: '2026-07-20T23:26:59Z' +source: agent:solana_dex_lp_expert +--- + +# LP Range Config — side, amounts, bounds + +Turn a chosen pool + `capital_per_slot` (in `quote_asset`) + `base_pct` into a valid `lp_executor` config. Current pool price = `P`. + +## Side + amounts from base_pct +| base_pct | side | base_amount | quote_amount | range vs P | swap first? | +|---|---|---|---|---|---| +| `0` | `1` BUY | 0 | `capital` | **below** P | no | +| `100` | `2` SELL | acquired base | 0 | **above** P | yes: quote→base for full slot | +| `0<β<100` | `3` RANGE | base worth `capital·β/100` | `capital·(1−β/100)` | **centered** on P | swap the base shortfall only | + +- Swaps use `swap_provider="jupiter/router"` (or an order_executor market buy of base). +- Always `keep_position=false` → exit swaps back to `quote_asset` so PnL/TP/SL are in quote terms. + +## Bounds — width then CLAMP to venue cap +1. Half-width `w`: if `range_width_pct` set, use it; if `auto`, derive from OHLCV — e.g. `w ≈ k · ATR%` over `ranking_window` (k≈1–2). Tighter = denser fees but exits range sooner. +2. Provisional bounds: + - RANGE (β middle): `lower=P·(1−w)`, `upper=P·(1+w)`. + - BUY (β=0): `upper=P·(1−ε)`, `lower=P·(1−ε−2w)` (range below P). + - SELL (β=100): `lower=P·(1+ε)`, `upper=P·(1+ε+2w)` (range above P). +3. **Clamp to the venue cap** (this prevents `SIMULATION_FAILED` / reallocate errors): + - **Meteora:** bins `≈ ln(upper/lower)/ln(1+bin_step/10000)` must be **< 69**. If over, shrink bounds until < ~60 (leave headroom). `bin_step=4` ⇒ total width ≲ 2.7%. + - **Orca / Raydium:** width bounded by `tick_spacing`; smaller spacing ⇒ tighter cap. Pull `tick_spacing` from pool-info and keep the tick count within the connector's per-position limit. +4. Meteora only: `extra_params={"strategyType":0}` (0=Spot uniform, 1=Curve concentrated, 2=Bid-Ask). Default Spot. + +## Validate before create +- `capital_per_slot` ≥ venue minimum position size (else skip pool). +- Enough SOL for rent (~0.057 SOL Meteora) + fees beyond `min_wallet_sol_reserve`. +- If open FAILS with reallocate/simulation error → range too wide → shrink bounds and retry once. diff --git a/agents/solana_dex_lp_expert/skills/pool_ranking/SKILL.md b/agents/solana_dex_lp_expert/skills/pool_ranking/SKILL.md new file mode 100644 index 00000000..50752415 --- /dev/null +++ b/agents/solana_dex_lp_expert/skills/pool_ranking/SKILL.md @@ -0,0 +1,41 @@ +--- +name: pool_ranking +description: Scan and rank Solana CLMM memecoin pools by fee yield (fees/TVL) to choose + which to LP into. +when_to_use: When you need to find and rank Solana memecoin CLMM pools by fee yield + (fees/TVL) to decide which pools to LP into. Use at the start of filling any free + slot. +created: '2026-07-20T23:26:46Z' +source: agent:solana_dex_lp_expert +--- + +# Pool Ranking — fees/TVL yield scan + +Goal: produce a ranked shortlist of Solana CLMM pools to LP into, quoted in `quote_asset`, on an allowed `venue`. + +## 1. Source candidates (GeckoTerminal) +- `explore_geckoterminal(action="trending_pools", network="solana")` — momentum memecoins. +- `explore_geckoterminal(action="top_pools", network="solana", dex_id="")` — top by volume per venue (loop `venues`). +- Optionally `action="new_pools"` for fresh launches (higher risk/higher fee). + +## 2. Filter +- Keep only pools whose **quote == `quote_asset`** (SOL or USDC). +- Keep only pools on an allowed **venue** (meteora / orca / raydium). +- Drop pools already held by an open slot. +- Drop ultra-thin TVL (`reserve_usd`) — thin pools gap out of range immediately and IL dominates. Rule of thumb: skip TVL < ~$25k unless volume is exceptional. + +## 3. Score — fee yield +For each survivor: **fee_yield = fees(`ranking_window`) / reserve_usd**. +- GeckoTerminal pool fields give volume + reserve; when a direct fee figure isn't present, estimate `fees ≈ volume(window) × pool_fee_pct`. +- Higher fee_yield = more fee income per dollar of liquidity = better. This is the primary sort key. + +## 4. Sanity-check the top few +- `explore_dex_pools(action="get_pool_info", connector=, network="solana-mainnet-beta", pool_address=...)` → live price, `bin_step`/`tick_spacing`, liquidity distribution. +- `explore_geckoterminal(action="ohlcv", network="solana", pool_address=..., timeframe="1h")` → volatility (for range width) and trend. **Reject** pools in a steep one-directional dump (fees won't cover IL / you'll be single-sided into a falling knife). + +## 5. Output +Ranked list: `pool | venue | pair | TVL | vol(window) | fee_pct | fee_yield | bin_step/tick | price | trend`. Lead with the single best for the next free slot. + +## Notes +- Raydium pool-info comes from the Raydium API (not Gateway); Meteora/Orca via Gateway. +- Re-rank every tick that has free slots — trending sets rotate fast. diff --git a/agents/solana_dex_lp_expert/skills/slot_exit/SKILL.md b/agents/solana_dex_lp_expert/skills/slot_exit/SKILL.md new file mode 100644 index 00000000..beb008f3 --- /dev/null +++ b/agents/solana_dex_lp_expert/skills/slot_exit/SKILL.md @@ -0,0 +1,29 @@ +--- +name: slot_exit +description: Decide hold vs exit for an open LP slot (TP/SL/range-abandoned), close + it swapping back to quote, and rotate the freed capital. +when_to_use: When deciding whether an open LP slot should be held or exited, and how + to close it and rotate the freed capital. +created: '2026-07-20T23:27:07Z' +source: agent:solana_dex_lp_expert +--- + +# Slot Exit — TP/SL + rotation + +## Exit triggers (evaluate every tick, per open slot) +Exit when ANY is true: +- **Take profit:** slot `net_pnl_pct ≥ take_profit_pct` (default 20). +- **Stop loss:** slot `net_pnl_pct ≤ −stop_loss_pct` (default 20). +- **Range abandoned:** OUT_OF_RANGE for a sustained period AND OHLCV trend continues away from the range (price won't come back → no fees, only IL/exposure). + +Measure PnL in `quote_asset` terms (net of fees earned, IL, rent, tx). Use executor `net_pnl_pct` / `custom_info` when available; otherwise compute from current vs initial value. + +## How to exit +`manage_executors(action="stop", executor_id=, keep_position=false)` +- `keep_position=false` removes on-chain liquidity, runs the close-out swap back to `quote_asset`, and refunds position rent. +- Confirm the executor reaches COMPLETE and rent is refunded (`position_rent_refunded > 0`). + +## Rotate +- A freed slot is re-filled next tick from a fresh `pool_ranking`. +- Do NOT immediately re-enter the pool you just exited (esp. a stop-loss) unless it clearly re-ranks on top — avoid churn/fee bleed. +- Journal each exit: pool, reason (TP/SL/abandoned), realized PnL, fees earned, duration. diff --git a/agents/solana_dex_lp_expert/strategies/lp_slot_operator/strategy.md b/agents/solana_dex_lp_expert/strategies/lp_slot_operator/strategy.md new file mode 100644 index 00000000..47a2bf03 --- /dev/null +++ b/agents/solana_dex_lp_expert/strategies/lp_slot_operator/strategy.md @@ -0,0 +1,108 @@ +--- +name: LP Slot Operator +description: '' +agent_key: null +skills: [] +default_config: + frequency_sec: 300 + execution_mode: loop + total_amount_quote: 1 + quote_asset: SOL + base_pct: 20 + slots: 3 + take_profit_pct: 20 + stop_loss_pct: 20 + out_of_range_max_sec: 1800 + venues: meteora,orca,raydium + ranking_window: 24h + range_width_pct: auto + capital_per_slot: null + risk_limits: + min_wallet_sol_reserve: 0.3 + max_open_slots: 3 +default_trading_context: '' +created_by: 0 +created_at: '2026-07-20T23:25:33.346667+00:00' +--- + +# LP Slot Operator + +You are the Solana DEX LP Expert's execution strategy. Each tick you **monitor open LP slots**, **exit** any that hit take-profit / stop-loss, and **fill ONE free slot** with the best-yielding memecoin pool you don't already hold. Positions are **LP Executors** (`manage_executors`, `executor_type="lp_executor"`), never controllers. + +## HARD TICK BUDGET +~5-minute tick limit. **Aim for ≤ 8 tool calls per tick.** Do **NOT** hand-scan GeckoTerminal — use the **`lp_scanner` routine** (one call). Fill **at most ONE slot per tick**. + +## Configuration at launch +Read from `[CURRENT CONFIG]`: `quote_asset` (SOL), `base_pct` (20), `slots` (3), `take_profit_pct` (20), `stop_loss_pct` (20), `venues` (meteora,orca,raydium), `ranking_window` (24h), `range_width_pct` (auto), `capital_per_slot`. If `capital_per_slot` is null, derive from usable `quote_asset` balance ÷ `slots`, keeping `min_wallet_sol_reserve` SOL for rent+fees. + +## Constants +- `connector_name` = `solana-mainnet-beta` · `lp_provider` = `{venue}/clmm` · `swap_provider` = `jupiter/router` · `keep_position` = `false` + +## CRITICAL: use the MINT, not the symbol +Gateway can't resolve memecoins by symbol ("Token not found"). Use the base token **mint** in the `trading_pair` for BOTH the entry swap and the `lp_executor`. `lp_scanner` returns it as **`MintPair`** (e.g. `"9cRC…pump-SOL"`) and **`BaseMint`**. + +## DIVERSIFICATION (one distinct token per slot) +NEVER open a 2nd slot on a pool **or token** you already hold. Concentrating multiple slots in one memecoin defeats the point of slots. Always pass your held pools/tokens to `lp_scanner` as excludes (step 3). + +## Each Tick — Step by Step + +### 1. Load state — ADOPT every live slot (critical after a restart) +`[CORE DATA]` pre-loads executors **this session** opened — but a fresh session (e.g. after a restart) shows **none even when positions are live on-chain**, which would make you re-open a full duplicate set and over-expose the wallet. So **on the FIRST tick of a session (open_slots from `[CORE DATA]` is empty), verify against reality**: call `manage_executors(action="search", executor_types=["lp_executor"], status="RUNNING")` and **treat ALL returned RUNNING lp_executors as your open slots** (they share `controller_id="main"`, so you can monitor and exit them). `open_slots` = that RUNNING set; `free_slots = slots − open_slots`. From each, note its `pool_address` and **base mint** = the part before `-SOL`/`-USDC` in its `trading_pair`. NEVER open a slot for a token/pool already in that RUNNING set. Only call `get_portfolio_overview` if you need the live wallet SOL balance. + +### 2. Monitor + exit your open slots +For each RUNNING lp_executor you own, read `net_pnl_pct`, `state`, and `out_of_range_seconds`. **Exit** (`manage_executors(action="stop", executor_id=..., keep_position=false)`) if: +- `net_pnl_pct ≥ take_profit_pct` (take-profit), or `≤ −stop_loss_pct` (stop-loss); OR +- **idle out-of-range**: `state == OUT_OF_RANGE` and `out_of_range_seconds ≥ out_of_range_max_sec` (default 1800s). An out-of-range position earns **zero LP fees** — the whole point of a slot — so cut it and re-scan even if PnL hasn't hit ±`stop_loss_pct`. (Exception: skip if OHLCV shows price is decisively trending back INTO the range — one OHLCV check max, only for a slot near a threshold.) + +**Learnings — mandatory on any exit or notable state.** Whenever a slot goes OUT_OF_RANGE, is force-exited for idling, or hits TP/SL, write a `trading_agent_journal_write(entry_type="learning", ...)` line capturing: token, venue, what happened (e.g. "OUT_OF_RANGE 31m, 0 fees, exited + re-scanned"), and the takeaway (e.g. "range too tight for this pool's vol / price gapped below lower bound"). These learnings tune future range widths and pool picks. + +### 3. Rank — ONE routine call (only if free_slots > 0) +Pass the pools and base mints you already hold so the ranking excludes them: +``` +manage_routines(action="run", name="lp_scanner", strategy_id="solana_dex_lp_expert.lp_slot_operator", + config={"quote_asset": , "venues": , "ranking_window": , "top_n": 5, + "exclude_pools": [], "exclude_mints": []}) +``` +Returns per pool: `Pool`, `lp_provider`, `MintPair`, `BaseMint`, `Bin/Tick`, `Price`, `FeeYield` — already free of what you hold. Pick the top result. + +### 4. Fill ONE free slot +Size by `base_pct` with `capital_per_slot` at price `P`: +- `base_pct=0` → `side=1`, `quote_amount=capital`, `base_amount=0`, range **below** `P`. No swap. +- `base_pct=100` → swap quote→base full slot, `side=2`, `base_amount=acquired`, `quote_amount=0`, range **above** `P`. +- `0,"side":1,"amount":,"execution_strategy":"MARKET"})`. Wait for it to TERMINATE with `executed_amount_base` ≈ target. + +> **⚠ NEVER pass the swap's reported fill straight into `base_amount` — it is NOT what landed in the wallet.** +> `order_executor` reports `executed_amount_base` equal to the amount you *requested* (e.g. `62`), but Jupiter takes its cut in slippage/fees, so the wallet actually receives slightly less (e.g. `61.962753`, −0.06%; observed up to −0.44%). Opening the LP with the reported figure asks the pool for tokens you don't have, and the open **fails on-chain with no funds moved and no position address** — the slot silently stays empty and you've paid for the swap round-trip. +> **Always haircut: `base_amount = executed_amount_base × 0.995`** (or read the true post-swap wallet balance and use that). The leftover dust is worth cents; a failed open costs a full swap round-trip. +> This is **venue-independent** — it is not a Meteora/Orca/Raydium quirk. Do **not** blacklist a pool for it: the same shortfall recurs on the next pool. Only blacklist after the open fails with the correct, haircut amount. + +**Open LP** (MINT pair): `manage_executors(action="create", executor_type="lp_executor", executor_config={"connector_name":"solana-mainnet-beta","lp_provider":"/clmm","swap_provider":"jupiter/router","trading_pair":,"pool_address":,"lower_price":,"upper_price":,"side":<1|2|3>,"base_amount":,"quote_amount":,"keep_position":false,"extra_params":{"strategyType":0}})`. + +**Range bounds:** pick total width `W` from `range_width_pct`/OHLCV vol, then place `P` **asymmetrically by `base_pct`** so even (Spot) liquidity gives the target split — the **memecoin side gets `base_pct%` of `W`**, the SOL side `(100−base_pct)%`. **The memecoin side FLIPS by venue price convention:** +- **Meteora** (price = SOL-per-memecoin, small e.g. `0.00105`): memecoin is ABOVE `P` → `upper=P×(1+W·base_pct/100)`, `lower=P×(1−W·(100−base_pct)/100)`. +- **Orca / Raydium** (price = memecoin-per-SOL, large e.g. `1654`, `24.5M` — inverted): memecoin is BELOW `P` → `lower=P×(1−W·base_pct/100)`, `upper=P×(1+W·(100−base_pct)/100)`. + +**GUARDRAIL: always verify `lower_price < current_price < upper_price`.** If the bounds don't bracket `P` (both below it = you applied the Meteora formula to an inverted Orca/Raydium price), the open FAILS simulation — recompute with the right orientation. Match the magnitude of `current_price` from `get_pool_info`. + +**MANDATORY WIDTH CLAMP — compute this before every open, both venues. Width in *percent* is meaningless on its own; only the granularity count matters.** +- **Meteora:** `bins = ln(Pu/Pl) / ln(1 + bin_step/10000)` → must be **< 69**. +- **Orca / Raydium:** `spacings = ln(Pu/Pl) / (ln(1.0001) × tick_spacing)` → must be **≤ 120**. + +Pull `bin_step` / `tick_spacing` from `get_pool_info` **per pool** — never assume, it varies pool to pool and it is what decides the cap. If the count exceeds the limit, **shrink `W` until it fits** (keep `P` bracketed and the `base_pct` split intact), then open. + +> Why this matters — two Orca opens at the **identical 20.8% width** landed on opposite sides purely because of `tick_spacing`: +> `tick_spacing=16` → 118 spacings → **opened fine**; `tick_spacing=8` → 237 spacings → **SIMULATION_FAILED** (`/connectors/orca/clmm/open-position`, no funds moved, slot left empty). +> Observed: 75, 76, 98, 118 spacings all succeeded; 237 failed. Same pool family, same strategy — only the count differed. + +If an open still FAILS with reallocate/SIMULATION after the clamp → narrow once and retry; only then consider the pool suspect. + +### 5. Journal +One `trading_agent_journal_write(entry_type="action", ...)` line: slots held + PnL, exit (reason), fill (pool, side, range, size), free slots left. Add a `learning` only if genuinely new. + +## Guardrails +- Keep `min_wallet_sol_reserve` SOL free for rent (~0.057 SOL/Meteora slot) + fees; if short, don't open — journal and hold. +- **One slot fill per tick; one distinct token per slot** (always pass exclude_pools + exclude_mints). +- Don't re-enter a pool/token you stopped out of this session unless it clearly re-ranks on top. +- On any tool failure, journal it and hold — never leave a half-opened position unmonitored. diff --git a/mcp_servers/hummingbot_api/guides/order_executor.md b/mcp_servers/hummingbot_api/guides/order_executor.md index 9ce573a9..cfd6136e 100644 --- a/mcp_servers/hummingbot_api/guides/order_executor.md +++ b/mcp_servers/hummingbot_api/guides/order_executor.md @@ -34,3 +34,9 @@ Closest executor to a plain BUY/SELL order but with strategy options. - `leverage`: Leverage multiplier (default: 1) - `position_action`: 'OPEN' or 'CLOSE' (default: 'OPEN', useful for perpetuals in HEDGE mode) - `level_id`: Optional identifier tag + +**Solana / Jupiter: `executed_amount_base` is the amount REQUESTED, not received.** +Slippage and fees mean the wallet gets slightly less (observed −0.06% to −0.44%). +Never feed it straight into a downstream call that must spend those tokens (e.g. an +LP open's `base_amount`) — apply a haircut (`× 0.995`) or read the true post-swap +wallet balance.