From 223f793b8b88dbfbec6aad6b131cd75526527d3a Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 12 Aug 2026 18:23:18 -0700 Subject: [PATCH 01/65] =?UTF-8?q?feat(executors):=20surface=20orphaned=20L?= =?UTF-8?q?P=20positions=20to=20agents=20=E2=80=94=20tick-prompt=20warning?= =?UTF-8?q?s,=20orphaned/resolve=5Forphan=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Condor-side of the gateway#678 retry-ownership work (canonical design: docs/retry-architecture.md in the companion gateway PR). Agents drive executors through manage_executors, where no controller exists to react to a stranded position — and the tick-prompt summary was RUNNING-only, so a terminal executor holding a live on-chain position was invisible: - ExecutorsProvider surfaces terminal executors that still own a position (involuntary POSITION_HOLD with hold_reason, the injected orphaned_position flag, or legacy FAILED-with-position) as a reason-aware ORPHANED POSITION warning with explicit recovery guidance: close via the gateway tools by position address — a fresh lp_executor CANNOT adopt an existing position and would mint a second one — then mark recovered. Also exposed as orphaned_executors in provider data. - manage_executors gains orphaned (list recovery candidates) and resolve_orphan (mark recovered) actions wired to the new API endpoints. - stop handler surfaces the already_terminated payload (close_type, position_address, orphan warning) instead of treating it as a plain stop; Telegram stop menu shows the same. - guides/lp_executor.md: close-exhaustion now terminates as an involuntary POSITION_HOLD (hold_reason=close_retries_exhausted), FAILED means nothing-left-on-chain, and the orphan recovery flow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HahKfEY9rvKnZijrzUAFSq --- condor/agents/providers/executors.py | 34 +++++++ handlers/executors/menu.py | 20 +++- .../hummingbot_api/guides/lp_executor.md | 17 ++-- mcp_servers/hummingbot_api/schemas.py | 8 ++ mcp_servers/hummingbot_api/server.py | 4 + mcp_servers/hummingbot_api/tools/executors.py | 95 ++++++++++++++++++- 6 files changed, 167 insertions(+), 11 deletions(-) diff --git a/condor/agents/providers/executors.py b/condor/agents/providers/executors.py index dfb781a95..c1c38c04f 100644 --- a/condor/agents/providers/executors.py +++ b/condor/agents/providers/executors.py @@ -53,6 +53,28 @@ async def execute( ) running = [e for e in perf.executors if e["status"] == "RUNNING"] + # A terminal executor that still reports an on-chain position address has + # stranded live exposure with no automated owner: an involuntary hold + # (POSITION_HOLD with hold_reason set — an LP close that exhausted its + # retries) or a legacy FAILED-with-position. It would otherwise vanish + # from this RUNNING-only summary — surface it until it is recovered. + orphaned = [ + e + for e in perf.executors + if e["status"] != "RUNNING" + and not (e.get("custom_info") or {}).get("orphan_resolved") + and ( + (e.get("custom_info") or {}).get("orphaned_position") + or ( + (e.get("custom_info") or {}).get("hold_reason") + and (e.get("custom_info") or {}).get("position_address") + ) + or ( + str(e.get("close_type") or "").upper() == "FAILED" + and (e.get("custom_info") or {}).get("position_address") + ) + ) + ] lines = [ ( f"Active Executors ({len(running)}) [agent: {agent_id}]:" @@ -65,6 +87,17 @@ async def execute( lines.append( f" {r['pair']} {side} ${r['pnl']:+.2f} (V:${r['volume']:,.0f})" ) + for o in orphaned: + pos = (o.get("custom_info") or {}).get("position_address") + reason = (o.get("custom_info") or {}).get("hold_reason") or "close failed" + lines.append( + f" 🚨 ORPHANED POSITION: executor {o['id']} ({o['pair']}) terminated " + f"({reason}) with position {pos} still open on-chain. Close it via the " + "gateway tools (remove liquidity by position address — a new " + "lp_executor CANNOT adopt it and would mint a second position), then " + f"mark it recovered with manage_executors(action=\"resolve_orphan\", " + f"executor_id=\"{o['id']}\"). Do not open new positions on these funds first." + ) if perf.bot_names: lines.append(f" Bots operated: {', '.join(perf.bot_names)}") lines.append( @@ -90,6 +123,7 @@ async def execute( name=self.name, data={ "executors": running, + "orphaned_executors": orphaned, "all_executors": perf.executors, "total_pnl": perf.total_pnl, "realized_pnl": perf.realized_pnl, diff --git a/handlers/executors/menu.py b/handlers/executors/menu.py index 7fc43f432..aa052f3da 100644 --- a/handlers/executors/menu.py +++ b/handlers/executors/menu.py @@ -892,7 +892,7 @@ async def handle_confirm_stop_executor( full_id = ex_id break try: - await stop_executor(client, full_id, keep_position=False) + stop_result = await stop_executor(client, full_id, keep_position=False) finally: # Either way the cached view is stale: a stop that failed to answer # may still have gone through upstream. @@ -908,8 +908,24 @@ async def handle_confirm_stop_executor( keyboard = [ [InlineKeyboardButton("📋 Back to List", callback_data="executors:menu")] ] + if (stop_result or {}).get("status") == "already_terminated": + # No-op stop: the executor was already terminal. A generic "stopped" + # would hide the one payload that matters — an orphaned position. + text = ( + f"ℹ️ *Executor Already Terminated*\n\n" + f"🆔 `{escape_markdown_v2(full_id[:30])}`\n" + f"Close type: `{escape_markdown_v2(str(stop_result.get('close_type')))}`" + ) + if stop_result.get("orphaned_position"): + text += ( + f"\n\n🚨 *Orphaned position still open on\\-chain:*\n" + f"`{escape_markdown_v2(str(stop_result.get('position_address')))}`\n" + "Close it via the gateway tools, then mark it recovered\\." + ) + else: + text = f"✅ *Executor Stopped*\n\n🆔 `{escape_markdown_v2(full_id[:30])}`" await query.message.edit_text( - f"✅ *Executor Stopped*\n\n🆔 `{escape_markdown_v2(full_id[:30])}`", + text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), ) diff --git a/mcp_servers/hummingbot_api/guides/lp_executor.md b/mcp_servers/hummingbot_api/guides/lp_executor.md index ed2064c57..3457ddb28 100644 --- a/mcp_servers/hummingbot_api/guides/lp_executor.md +++ b/mcp_servers/hummingbot_api/guides/lp_executor.md @@ -51,7 +51,7 @@ NOT_ACTIVE → OPENING → IN_RANGE ↔ OUT_OF_RANGE → CLOSING → COMPLETE ↘ SWAPPING → COMPLETE ``` -Any state may transition to `FAILED` if max retries are exhausted (open / close / swap) or if `early_stop` is called while still `OPENING`. +Any state may transition to `FAILED` if max retries are exhausted with nothing left on-chain (open / swap) or if `early_stop` is called while still `OPENING`. An exhausted **close** instead terminates as an involuntary `POSITION_HOLD` (`hold_reason: "close_retries_exhausted"`) because the position is still live on-chain. - **NOT_ACTIVE**: Initial state, no position yet - **OPENING**: `add_liquidity` submitted, waiting for confirmation @@ -60,7 +60,7 @@ Any state may transition to `FAILED` if max retries are exhausted (open / close - **CLOSING**: `remove_liquidity` submitted, waiting for confirmation - **SWAPPING**: Close-out swap in progress (only when `keep_position=False`, to return to the original quote asset) - **COMPLETE**: Position closed permanently -- **FAILED**: Max retries reached or invalid config; manual intervention required +- **FAILED**: Max retries reached with nothing left on-chain, or invalid config. A failed close retries in `CLOSING` with fresh on-chain state (bounded by `max_retries`); if the retries run out with the position still live, the executor terminates as an involuntary `POSITION_HOLD` with `hold_reason: "close_retries_exhausted"` and the record is flagged `orphaned_position` in `custom_info` — recover it (see below) before opening new positions #### Key Parameters @@ -177,7 +177,12 @@ manage_executors( - If position is closed on-chain but executor still shows `RUNNING`, manually update executor status in database to `TERMINATED` - If position is open on-chain but executor still shows `OPENING`, the executor should eventually sync — if stuck, check API logs for errors -**Exception: Executor not found in API (404 error):** -- If API was restarted, executors may no longer exist in memory but positions remain on-chain -- In this case, close the on-chain position directly via the DEX UI or gateway API -- Then manually update the executor status in the database to `TERMINATED` +**Orphaned positions (executor terminated with a live on-chain position):** +- `manage_executors(action="orphaned")` lists terminated executors that may still own an on-chain position: involuntary holds (`POSITION_HOLD` with `hold_reason` set — a close that exhausted its retries), legacy `FAILED` executors whose final state carries a `position_address`, and `SYSTEM_CLEANUP` LP executors from an API restart (position address unknown — reconcile against `get_portfolio_overview(include_lp_positions=True)` or the gateway positions-owned endpoints) +- Recover by closing the position via the gateway tools (remove liquidity by position address). A fresh `lp_executor` CANNOT adopt an existing position — it always mints a new one, which would stack a second funded position on top of the orphan +- After the position is closed on-chain, mark it recovered with `manage_executors(action="resolve_orphan", executor_id="...")` so it stops appearing in orphan listings and warnings +- Do NOT open new positions on the same funds until the orphan is recovered + +**Stopping an already-terminated executor is a no-op, not an error:** +- `manage_executors(action="stop")` on a terminated executor returns `status="already_terminated"` with its final `close_type`, `position_address`, and `orphaned_position` flag instead of a 404 +- A 404 means the executor ID is unknown to the API's database (never existed, or the database was unavailable at that moment) diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index 15b35df9f..681d6d5d0 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -30,6 +30,8 @@ class ManageExecutorsRequest(BaseModel): 9. action="reset_preferences" -> Reset preferences to defaults 10. action="positions_summary" -> Get positions (or specific if connector_name+trading_pair given) 11. action="clear_position" + connector_name + trading_pair -> Clear position + 12. action="orphaned" -> List terminated executors that may still own an on-chain position + 13. action="resolve_orphan" + executor_id -> Mark an orphaned position as recovered """ action: ( @@ -44,6 +46,8 @@ class ManageExecutorsRequest(BaseModel): "positions_summary", "clear_position", "performance_report", + "orphaned", + "resolve_orphan", ] | None ) = Field( @@ -188,6 +192,10 @@ def get_flow_stage(self) -> str: return "clear_position" elif self.action == "performance_report": return "performance_report" + elif self.action == "orphaned": + return "orphaned" + elif self.action == "resolve_orphan" and self.executor_id: + return "resolve_orphan" elif self.executor_type is not None: return "show_schema" else: diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index d55c97de0..851b53d5c 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -702,6 +702,8 @@ async def manage_executors( "positions_summary", "clear_position", "performance_report", + "orphaned", + "resolve_orphan", ] | None ) = None, @@ -747,6 +749,8 @@ async def manage_executors( - positions_summary → View all positions (add connector_name + trading_pair to filter) - clear_position + connector_name + trading_pair → Clear externally-closed position - performance_report → Get executor performance report (optionally filter by controller_id) + - orphaned → List terminated executors that may still own an on-chain LP position (recover before opening new ones) + - resolve_orphan + executor_id → Mark an orphaned position as recovered after closing it via the gateway tools Args: action: Action to perform. Leave empty to see executor types or config schema. diff --git a/mcp_servers/hummingbot_api/tools/executors.py b/mcp_servers/hummingbot_api/tools/executors.py index fc43d1353..67e66ca13 100644 --- a/mcp_servers/hummingbot_api/tools/executors.py +++ b/mcp_servers/hummingbot_api/tools/executors.py @@ -306,9 +306,26 @@ async def manage_executors( keep_position=request.keep_position, ) - formatted = f"Executor stopped successfully!\n\n" - formatted += f"Executor ID: {request.executor_id}\n" - formatted += f"Keep Position: {request.keep_position}\n" + if result.get("status") == "already_terminated": + # No-op: the executor was already terminal. Say so — a generic + # "stopped successfully" would hide the one payload that matters + # (an orphaned on-chain position needing recovery). + formatted = f"Executor was ALREADY terminated (stop was a no-op).\n\n" + formatted += f"Executor ID: {request.executor_id}\n" + formatted += f"Final close_type: {result.get('close_type')}\n" + if result.get("orphaned_position"): + formatted += ( + f"\n🚨 ORPHANED POSITION: {result.get('position_address')} is still " + "open on-chain with no automated owner. Close it via the gateway " + "tools (remove liquidity by position address), then mark it " + f"recovered with action=\"resolve_orphan\", executor_id=\"{request.executor_id}\".\n" + ) + elif result.get("position_address"): + formatted += f"Position address (final state): {result.get('position_address')}\n" + else: + formatted = f"Executor stopped successfully!\n\n" + formatted += f"Executor ID: {request.executor_id}\n" + formatted += f"Keep Position: {request.keep_position}\n" return { "action": "stop", @@ -325,6 +342,78 @@ async def manage_executors( "formatted_output": f"Error stopping executor {request.executor_id}: {e}", } + elif flow_stage == "orphaned": + # List terminated executors that may still own an on-chain position + try: + resp = await client.executors.session.get( + f"{client.executors.base_url}/executors/positions/orphaned", + ) + resp.raise_for_status() + result = await resp.json() + + orphans = result.get("orphans", []) + formatted = f"Orphaned position candidates: {result.get('count', len(orphans))}\n\n" + if not orphans: + formatted += "No orphaned positions. All terminated executors closed cleanly." + else: + for o in orphans: + formatted += ( + f"- {o.get('executor_id')} ({o.get('executor_type')}, " + f"{o.get('trading_pair')} on {o.get('connector_name')}, " + f"close_type={o.get('close_type')}, closed_at={o.get('closed_at')})\n" + ) + if o.get("position_address"): + formatted += f" position: {o['position_address']}\n" + if o.get("needs_onchain_reconciliation"): + formatted += ( + " position address unknown (API restart) - reconcile against " + "on-chain positions (get_portfolio_overview include_lp_positions=True)\n" + ) + formatted += ( + "\nRecover each by closing the position via the gateway tools " + "(remove liquidity by position address), then mark it recovered with " + "action=\"resolve_orphan\", executor_id=\"...\"." + ) + + return { + "action": "orphaned", + "result": result, + "formatted_output": formatted, + } + + except Exception as e: + return { + "action": "orphaned", + "error": str(e), + "formatted_output": f"Error listing orphaned positions: {e}", + } + + elif flow_stage == "resolve_orphan": + # Mark an orphaned position as recovered (after closing it externally) + try: + resp = await client.executors.session.post( + f"{client.executors.base_url}/executors/{request.executor_id}/resolve-orphan", + ) + resp.raise_for_status() + result = await resp.json() + + return { + "action": "resolve_orphan", + "executor_id": request.executor_id, + "result": result, + "formatted_output": ( + f"Orphaned position for executor {request.executor_id} marked recovered. " + "It will no longer appear in orphan listings or warnings." + ), + } + + except Exception as e: + return { + "action": "resolve_orphan", + "error": str(e), + "formatted_output": f"Error resolving orphan for executor {request.executor_id}: {e}", + } + elif flow_stage == "get_logs": # Get executor logs via direct API call (not yet in client library) try: From 06adc2264809a9bf1184adb06b703b9700487eaf Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 13 Aug 2026 07:35:54 -0700 Subject: [PATCH 02/65] fix(mcp): resolve_orphan without executor_id returns a required-input error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1 on #204: get_flow_stage() required executor_id for the resolve_orphan action, so a call missing the id silently fell through to show_schema/list_types and the recovery request was ignored. The action now always routes to resolve_orphan and the tool returns an explicit error pointing at action="orphaned" to find candidates. Also documents in the LP executor guide that resolve_orphan updates the API database only — an lp_rebalancer controller's in-memory orphan halt clears on controller restart, so restart after resolving. Co-Authored-By: Claude Fable 5 --- .../hummingbot_api/guides/lp_executor.md | 1 + mcp_servers/hummingbot_api/schemas.py | 2 +- mcp_servers/hummingbot_api/tools/executors.py | 9 ++++++ tests/test_executor_resolve_orphan.py | 30 +++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/test_executor_resolve_orphan.py diff --git a/mcp_servers/hummingbot_api/guides/lp_executor.md b/mcp_servers/hummingbot_api/guides/lp_executor.md index 3457ddb28..095fa14c6 100644 --- a/mcp_servers/hummingbot_api/guides/lp_executor.md +++ b/mcp_servers/hummingbot_api/guides/lp_executor.md @@ -181,6 +181,7 @@ manage_executors( - `manage_executors(action="orphaned")` lists terminated executors that may still own an on-chain position: involuntary holds (`POSITION_HOLD` with `hold_reason` set — a close that exhausted its retries), legacy `FAILED` executors whose final state carries a `position_address`, and `SYSTEM_CLEANUP` LP executors from an API restart (position address unknown — reconcile against `get_portfolio_overview(include_lp_positions=True)` or the gateway positions-owned endpoints) - Recover by closing the position via the gateway tools (remove liquidity by position address). A fresh `lp_executor` CANNOT adopt an existing position — it always mints a new one, which would stack a second funded position on top of the orphan - After the position is closed on-chain, mark it recovered with `manage_executors(action="resolve_orphan", executor_id="...")` so it stops appearing in orphan listings and warnings +- If an `lp_rebalancer` controller was managing the executor, restart the controller (or its bot) after resolving: the controller's orphan halt is held in memory and only clears on restart. `resolve_orphan` updates the API database, not the running controller - Do NOT open new positions on the same funds until the orphan is recovered **Stopping an already-terminated executor is a no-op, not an error:** diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index 681d6d5d0..b5617153d 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -194,7 +194,7 @@ def get_flow_stage(self) -> str: return "performance_report" elif self.action == "orphaned": return "orphaned" - elif self.action == "resolve_orphan" and self.executor_id: + elif self.action == "resolve_orphan": return "resolve_orphan" elif self.executor_type is not None: return "show_schema" diff --git a/mcp_servers/hummingbot_api/tools/executors.py b/mcp_servers/hummingbot_api/tools/executors.py index 67e66ca13..53f45b923 100644 --- a/mcp_servers/hummingbot_api/tools/executors.py +++ b/mcp_servers/hummingbot_api/tools/executors.py @@ -390,6 +390,15 @@ async def manage_executors( elif flow_stage == "resolve_orphan": # Mark an orphaned position as recovered (after closing it externally) + if not request.executor_id: + return { + "action": "resolve_orphan", + "error": "executor_id is required", + "formatted_output": ( + "resolve_orphan requires executor_id. " + 'Run manage_executors(action="orphaned") to list candidates.' + ), + } try: resp = await client.executors.session.post( f"{client.executors.base_url}/executors/{request.executor_id}/resolve-orphan", diff --git a/tests/test_executor_resolve_orphan.py b/tests/test_executor_resolve_orphan.py new file mode 100644 index 000000000..7c44de4b4 --- /dev/null +++ b/tests/test_executor_resolve_orphan.py @@ -0,0 +1,30 @@ +"""resolve_orphan without executor_id must fail loudly, not fall through. + +``get_flow_stage()`` originally required ``executor_id`` for the resolve_orphan +action, so a call missing the id silently dispatched to show_schema/list_types: +the agent's recovery request was ignored and it received unrelated executor-type +listings instead of a required-input error. The action now always routes to the +resolve_orphan stage, and the tool returns an explicit error when the id is +missing (the API client is never touched on that path). + +The repo has no async test setup, so the coroutine is driven with asyncio.run(). +""" +import asyncio + +from mcp_servers.hummingbot_api.schemas import ManageExecutorsRequest +from mcp_servers.hummingbot_api.tools.executors import manage_executors + + +def test_resolve_orphan_without_id_still_routes_to_resolve_orphan(): + request = ManageExecutorsRequest(action="resolve_orphan") + assert request.get_flow_stage() == "resolve_orphan" + + +def test_resolve_orphan_without_id_returns_required_input_error(): + request = ManageExecutorsRequest(action="resolve_orphan") + + result = asyncio.run(manage_executors(client=None, request=request)) + + assert result["action"] == "resolve_orphan" + assert result["error"] == "executor_id is required" + assert 'action="orphaned"' in result["formatted_output"] From d8fa769ba00d300a3cd7fb777c235a9baa91f817 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 13 Aug 2026 08:47:03 -0700 Subject: [PATCH 03/65] test(mcp): pin the resolve_orphan required-input contract Regression cover for d8012bd: a resolve_orphan call with no executor_id must route to the resolve_orphan stage and return an actionable error, rather than falling through get_flow_stage() to show_schema/list_types and answering a recovery request with executor-type listings. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_executor_orphan_flow.py | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/test_executor_orphan_flow.py diff --git a/tests/test_executor_orphan_flow.py b/tests/test_executor_orphan_flow.py new file mode 100644 index 000000000..f8b5102a7 --- /dev/null +++ b/tests/test_executor_orphan_flow.py @@ -0,0 +1,55 @@ +"""Regression tests for the orphaned-position recovery actions. + +An orphan recovery request that goes unanswered is worse than one that errors: +the agent is told about a stranded on-chain position, asks to resolve it, and +`resolve_orphan` without an executor_id used to fall through get_flow_stage() to +show_schema/list_types — so the caller got a list of executor types back and the +position stayed flagged. These tests pin the routing and the required-input error. + +The repo has no async test setup, so the coroutine is driven with asyncio.run() +instead of a pytest-asyncio marker. +""" + +import asyncio + +import pytest + +from mcp_servers.hummingbot_api.schemas import ManageExecutorsRequest +from mcp_servers.hummingbot_api.tools.executors import manage_executors + + +def test_resolve_orphan_without_id_routes_to_resolve_orphan(): + """A missing executor_id must not silently reroute to executor discovery.""" + request = ManageExecutorsRequest(action="resolve_orphan") + + assert request.get_flow_stage() == "resolve_orphan" + + +def test_resolve_orphan_without_id_reports_the_missing_input(): + """The caller gets an actionable error, not unrelated informational output.""" + request = ManageExecutorsRequest(action="resolve_orphan") + + result = asyncio.run(manage_executors(client=None, request=request)) + + assert result["action"] == "resolve_orphan" + assert "executor_id" in result["error"] + assert "orphaned" in result["formatted_output"] + + +def test_resolve_orphan_without_id_ignores_executor_type(): + """executor_type present must not divert the recovery request to show_schema.""" + request = ManageExecutorsRequest( + action="resolve_orphan", executor_type="lp_executor" + ) + + assert request.get_flow_stage() == "resolve_orphan" + + +def test_resolve_orphan_with_id_still_routes_to_the_api_call(): + request = ManageExecutorsRequest(action="resolve_orphan", executor_id="abc123") + + assert request.get_flow_stage() == "resolve_orphan" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 0772a61ed8147aa2be4608592a6f90f75664d8d1 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 13 Aug 2026 09:57:35 -0700 Subject: [PATCH 04/65] feat(clmm): expose bin_count on get_pool_info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents can now request the per-tick liquidity distribution around the active price via manage_gateway_clmm(action="get_pool_info", bin_count=N), which the companion API forwards to Gateway. Meteora always returns its bins; orca, raydium, uniswap and pancakeswap compute them on request, so the default of 0 keeps pool-info cheap. The client library's get_pool_info has no bin_count parameter, so requests with bins go straight to the endpoint — the same passthrough the executors tools use for newer routes. Co-Authored-By: Claude Fable 5 --- mcp_servers/hummingbot_api/schemas.py | 12 ++++++++++ .../hummingbot_api/tools/gateway_clmm.py | 24 +++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index b5617153d..6629f5c4a 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -539,6 +539,18 @@ class GatewayCLMMRequest(BaseModel): default=None, description="Pool contract address (required for get_pool_info)" ) + bin_count: int = Field( + default=0, + ge=0, + le=401, + description=( + "For get_pool_info: if > 0, include the per-tick liquidity distribution " + "(`bins`) around the active price. Meteora always returns its bins; " + "orca, raydium, uniswap and pancakeswap compute them on request. " + "Default 0 skips the extra on-chain reads." + ), + ) + # Pool listing parameters page: int = Field( default=0, ge=0, description="Page number for list_pools (default: 0)" diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index 189f4fce9..435c1c642 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -178,11 +178,25 @@ async def explore_gateway_clmm_pools( if not request.pool_address: raise ToolError("pool_address is required for get_pool_info action") - result = await client.gateway_clmm.get_pool_info( - connector=request.connector, - network=request.network, - pool_address=request.pool_address, - ) + # The client library's get_pool_info has no bin_count parameter, so ask the + # endpoint directly when bins are wanted (same passthrough the executors + # tools use for newer routes). + if request.bin_count: + result = await client.gateway_clmm._get( + "/gateway/clmm/pool-info", + params={ + "connector": request.connector, + "network": request.network, + "pool_address": request.pool_address, + "bin_count": request.bin_count, + }, + ) + else: + result = await client.gateway_clmm.get_pool_info( + connector=request.connector, + network=request.network, + pool_address=request.pool_address, + ) return { "action": "get_pool_info", From 7e622a3a5fc0e411618c9fd4190428c744bbb877 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 13 Aug 2026 10:36:05 -0700 Subject: [PATCH 05/65] chore: bump hummingbot-api-client to 1.5.8 for bin_count 1.5.8 adds bin_count to gateway_clmm.get_pool_info, so the tool calls the typed client method instead of reaching past it to the endpoint. Co-Authored-By: Claude Fable 5 --- .../hummingbot_api/tools/gateway_clmm.py | 25 +++++-------------- pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index 435c1c642..852c1090c 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -178,25 +178,12 @@ async def explore_gateway_clmm_pools( if not request.pool_address: raise ToolError("pool_address is required for get_pool_info action") - # The client library's get_pool_info has no bin_count parameter, so ask the - # endpoint directly when bins are wanted (same passthrough the executors - # tools use for newer routes). - if request.bin_count: - result = await client.gateway_clmm._get( - "/gateway/clmm/pool-info", - params={ - "connector": request.connector, - "network": request.network, - "pool_address": request.pool_address, - "bin_count": request.bin_count, - }, - ) - else: - result = await client.gateway_clmm.get_pool_info( - connector=request.connector, - network=request.network, - pool_address=request.pool_address, - ) + result = await client.gateway_clmm.get_pool_info( + connector=request.connector, + network=request.network, + pool_address=request.pool_address, + bin_count=request.bin_count, + ) return { "action": "get_pool_info", diff --git a/pyproject.toml b/pyproject.toml index a0d3be286..7cb6c6357 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Telegram bot for Hummingbot trading via Backend API" requires-python = ">=3.12" dependencies = [ "python-telegram-bot[job-queue]", - "hummingbot-api-client==1.5.7", + "hummingbot-api-client==1.5.8", "python-dotenv", "PyYAML", "aiohttp", From 4f1a9ad9b36eca1f896802ba0f97fee11f8c2dd3 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 17 Aug 2026 06:49:38 -0700 Subject: [PATCH 06/65] feat(clmm): add manage_clmm so agents can recover orphaned LP positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orphan warning told the agent to "close the position via the gateway tools (remove liquidity by position address)" — an instruction with no implementing tool. Following it, an agent found that manage_executors(action="stop") is a no-op (the executor has already terminated, which is the correct contract), manage_amm handles AMMs only, and explore_dex_pools is read-only. The position stayed open and the recovery loop dead-ended. Adds manage_clmm, mirroring manage_amm: progressive-disclosure guide, per-action validation, dispatch to client.gateway_clmm.*. Actions: position_info, open, add_liquidity, remove_liquidity, close, collect_fees. Pool discovery stays in explore_dex_pools rather than being duplicated. Two details decide whether a recovery call actually works, and both are covered by tests: - an orphan records its DEX as lp_provider "orca/clmm" while Gateway routes on the bare "orca", so the connector is normalised - an lp_executor position is not in the API database, so close must forward pool_address or the API returns 400 The orphan listing now emits the concrete call rather than prose, built from the record's own fields — including that connector_name holds the network, not the DEX, which is the easiest way to construct a dead call by hand. Records still awaiting reconciliation emit no call, so no close is ever suggested with an unknown position address. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj --- .../hummingbot_api/formatters/__init__.py | 1 + .../hummingbot_api/formatters/gateway.py | 59 +++++ .../hummingbot_api/guides/gateway_clmm.md | 69 ++++++ .../hummingbot_api/guides/lp_executor.md | 4 +- mcp_servers/hummingbot_api/schemas.py | 67 ++++++ mcp_servers/hummingbot_api/server.py | 99 +++++++++ mcp_servers/hummingbot_api/tools/executors.py | 30 ++- .../hummingbot_api/tools/gateway_clmm.py | 145 +++++++++++- tests/test_manage_clmm.py | 210 ++++++++++++++++++ tests/test_orphan_recovery_instructions.py | 127 +++++++++++ 10 files changed, 799 insertions(+), 12 deletions(-) create mode 100644 mcp_servers/hummingbot_api/guides/gateway_clmm.md create mode 100644 tests/test_manage_clmm.py create mode 100644 tests/test_orphan_recovery_instructions.py diff --git a/mcp_servers/hummingbot_api/formatters/__init__.py b/mcp_servers/hummingbot_api/formatters/__init__.py index 1a83936f5..582fd3bb4 100644 --- a/mcp_servers/hummingbot_api/formatters/__init__.py +++ b/mcp_servers/hummingbot_api/formatters/__init__.py @@ -49,6 +49,7 @@ # Gateway formatters from .gateway import ( format_amm_result, + format_clmm_result, format_gateway_clmm_pool_result, format_gateway_config_result, format_gateway_container_result, diff --git a/mcp_servers/hummingbot_api/formatters/gateway.py b/mcp_servers/hummingbot_api/formatters/gateway.py index 230e30507..4095827c8 100644 --- a/mcp_servers/hummingbot_api/formatters/gateway.py +++ b/mcp_servers/hummingbot_api/formatters/gateway.py @@ -212,3 +212,62 @@ def format_amm_result(action: str, result: dict[str, Any]) -> str: return f"{header}\nSignature/Tx: {sig} Status: {payload.get('status')}{extra}" return f"{header}\n{payload}" + + +def format_clmm_result(action: str, result: dict[str, Any]) -> str: + """Format manage_clmm results into a human-readable string.""" + # Progressive disclosure: the guide is returned directly. + if action is None or result.get("action") is None: + return result.get("formatted_output", str(result)) + + connector = result.get("connector", "") + network = result.get("network", "") + payload = result.get("result", {}) + header = f"CLMM {action} [{connector} · {network}]" + + if action == "position_info" and isinstance(payload, list): + if not payload: + return ( + f"{header}\nPool: {result.get('pool_address')}\n" + "No open positions for this wallet in this pool." + ) + lines = [f"{header}", f"Pool: {result.get('pool_address')} — {len(payload)} position(s)"] + for p in payload: + lines.append( + f" • {p.get('address')} — Base: {p.get('baseTokenAmount')} " + f"Quote: {p.get('quoteTokenAmount')} " + f"Range: {p.get('lowerPrice')}–{p.get('upperPrice')} Price: {p.get('price')}" + ) + lines.append( + f" Uncollected fees — base: {p.get('baseFeeAmount')} quote: {p.get('quoteFeeAmount')}" + ) + return "\n".join(lines) + + if action == "open" and isinstance(payload, dict): + return ( + f"{header}\n" + f"Position: {payload.get('position_address')}\n" + f"Tx: {payload.get('transaction_hash')} Status: {payload.get('status')}\n" + f"Range: {payload.get('lower_price')}–{payload.get('upper_price')}\n" + "This position is NOT tracked by any executor — it will not be range-monitored, " + "rebalanced, or auto-closed." + ) + + if action in ("close", "collect_fees") and isinstance(payload, dict): + return ( + f"{header}\n" + f"Position: {payload.get('position_address')}\n" + f"Tx: {payload.get('transaction_hash')} Status: {payload.get('status')}\n" + f"Fees collected — base: {payload.get('base_fee_collected')} " + f"quote: {payload.get('quote_fee_collected')}" + ) + + if action in ("add_liquidity", "remove_liquidity") and isinstance(payload, dict): + tx = payload.get("transaction_hash") or payload.get("signature") + return ( + f"{header}\n" + f"Position: {result.get('position_address')}\n" + f"Tx: {tx} Status: {payload.get('status')}" + ) + + return f"{header}\n{payload}" diff --git a/mcp_servers/hummingbot_api/guides/gateway_clmm.md b/mcp_servers/hummingbot_api/guides/gateway_clmm.md new file mode 100644 index 000000000..8c4b87f2f --- /dev/null +++ b/mcp_servers/hummingbot_api/guides/gateway_clmm.md @@ -0,0 +1,69 @@ +# manage_clmm — direct concentrated-liquidity positions + +Direct, chain- and DEX-agnostic CLMM position control. Stateless: you hold position state in your +journal. + +**This is the unmanaged path.** For normal LP work use `manage_executors` with `lp_executor` — it +owns range monitoring, rebalancing, and bounded close retries. Reach for `manage_clmm` when there is +no executor to do that for you: + +- **Recovering an orphaned position** — the main reason this tool exists. Once an executor has + terminated it cannot be told to close anything; `manage_executors(action="stop")` on it correctly + returns a no-op. The position has to be closed by address. +- Inspecting a position's live amounts and uncollected fees. +- Collecting fees without disturbing the position. + +## Connectors + +| Connector | Chain | Pools | +|---|---|---| +| `meteora` | Solana | DLMM | +| `raydium` | Solana | CLMM | +| `orca` | Solana | Whirlpools | +| `uniswap` | EVM | V3 | +| `pancakeswap` | EVM | V3 | + +A `/clmm` form is accepted too, so an orphan record's `lp_provider` (e.g. `orca/clmm`) can be +passed straight through. + +## Actions + +| Action | Required | Notes | +|---|---|---| +| `position_info` | `pool_address` | Lists your positions in that pool with amounts, range, and uncollected fees | +| `open` | `pool_address`, `lower_price`, `upper_price`, and at least one amount | Creates a position no executor tracks | +| `add_liquidity` | `position_address`, at least one amount | Keeps the existing range | +| `remove_liquidity` | `position_address`, `percentage_to_remove` | Partial withdrawal; the position account survives even at 100 | +| `close` | `position_address` | Withdraws everything, collects fees, closes the account | +| `collect_fees` | `position_address` | Fees only; position untouched | + +`remove_liquidity` at 100% and `close` are not the same thing: the former leaves an empty position +open, the latter closes the account. To recover an orphan, use `close`. + +## pool_address on close and collect_fees + +The API reads a position's pool from its own database. Positions opened by an `lp_executor` are +never in it — the bot opens those straight against Gateway — so for those you must pass +`pool_address` explicitly or the call fails with a 400 naming exactly this. The orphan listing +reports the pool for each orphan, so pass it through. + +Gateway itself needs only `position_address` to close; `pool_address` is used to snapshot pending +fees before the close so they can be reported. + +## Recovering an orphaned position + +1. `manage_executors(action="orphaned")` — each entry carries `lp_provider`, `pool_address`, + `position_address`, and `connector_name` (which for an LP executor holds the *network*, e.g. + `solana-mainnet-beta`, not the DEX). +2. Confirm it is really still on-chain: + `manage_clmm(action="position_info", connector=, network=, pool_address=)` +3. Close it: + `manage_clmm(action="close", connector=, network=, position_address=, pool_address=)` +4. `manage_executors(action="resolve_orphan", executor_id="...")` so it stops being reported. + +If an entry has `needs_onchain_reconciliation: true` its position address was never persisted (an +API restart). Find it with `get_portfolio_overview(include_lp_positions=True)` or `position_info` +before closing anything. + +Do not open a replacement position until the orphan is closed — a fresh `lp_executor` always mints a +new position and cannot adopt an existing one, so you would end up funding two. diff --git a/mcp_servers/hummingbot_api/guides/lp_executor.md b/mcp_servers/hummingbot_api/guides/lp_executor.md index 095fa14c6..d25f2d482 100644 --- a/mcp_servers/hummingbot_api/guides/lp_executor.md +++ b/mcp_servers/hummingbot_api/guides/lp_executor.md @@ -179,7 +179,9 @@ manage_executors( **Orphaned positions (executor terminated with a live on-chain position):** - `manage_executors(action="orphaned")` lists terminated executors that may still own an on-chain position: involuntary holds (`POSITION_HOLD` with `hold_reason` set — a close that exhausted its retries), legacy `FAILED` executors whose final state carries a `position_address`, and `SYSTEM_CLEANUP` LP executors from an API restart (position address unknown — reconcile against `get_portfolio_overview(include_lp_positions=True)` or the gateway positions-owned endpoints) -- Recover by closing the position via the gateway tools (remove liquidity by position address). A fresh `lp_executor` CANNOT adopt an existing position — it always mints a new one, which would stack a second funded position on top of the orphan +- Each entry reports the `lp_provider` (the DEX, e.g. `orca/clmm`) and `pool_address` needed to close it. Note `connector_name` holds the **network** (e.g. `solana-mainnet-beta`), not the DEX +- Recover with `manage_clmm(action="close", connector=, network=, position_address=..., pool_address=...)`. `pool_address` is REQUIRED here: LP-executor positions are opened by the bot straight against Gateway, so the API database has no row to read the pool from and the close returns 400 without it +- Stopping the executor will NOT close the position — it has already terminated, so `manage_executors(action="stop")` is correctly a no-op. A fresh `lp_executor` CANNOT adopt an existing position either; it always mints a new one, which would stack a second funded position on top of the orphan - After the position is closed on-chain, mark it recovered with `manage_executors(action="resolve_orphan", executor_id="...")` so it stops appearing in orphan listings and warnings - If an `lp_rebalancer` controller was managing the executor, restart the controller (or its bot) after resolving: the controller's orphan halt is held in memory and only clears on restart. `resolve_orphan` updates the API database, not the running controller - Do NOT open new positions on the same funds until the orphan is recovered diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index 6629f5c4a..9d14c474b 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -694,3 +694,70 @@ class AMMRequest(BaseModel): max_gas: int | None = Field( default=None, description="Uniswap (EVM) max gas limit (optional, create_pool)" ) + + +class CLMMRequest(BaseModel): + """Request model for the direct, chain- & DEX-agnostic CLMM tool (manage_clmm). + + Drives concentrated-liquidity position operations across Meteora DLMM, Raydium CLMM, Orca + Whirlpools (Solana), and Uniswap V3 / PancakeSwap V3 (EVM). + + Progressive disclosure: action=None returns the CLMM guide. + + This is the direct, unmanaged path. The managed path for normal LP work is + manage_executors(lp_executor), which owns range monitoring, rebalancing and close retries. + Use manage_clmm to inspect positions and to recover orphaned ones that no executor owns + any more — a terminated executor cannot be told to close its position. + """ + + action: ( + Literal[ + "position_info", + "open", + "add_liquidity", + "remove_liquidity", + "close", + "collect_fees", + ] + | None + ) = Field(default=None, description="CLMM action. Leave empty to load the CLMM guide.") + + connector: str | None = Field( + default=None, + description="CLMM connector (required for any action): 'meteora', 'raydium', 'orca' (Solana), " + "'uniswap', 'pancakeswap' (EVM). A '/clmm' form is accepted, so an orphan record's " + "lp_provider (e.g. 'orca/clmm') can be passed through unchanged.", + ) + network: str | None = Field( + default=None, + description="Network ID in 'chain-network' format. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'bsc-mainnet'", + ) + wallet_address: str | None = Field(default=None, description="Wallet address (optional, uses default if not provided)") + pool_address: str | None = Field( + default=None, + description="Pool contract address. Required for open and position_info. Also required to close or " + "collect fees on a position the API never recorded — every lp_executor position, since the " + "bot opens those straight against Gateway.", + ) + position_address: str | None = Field( + default=None, + description="Position NFT address. Required for add_liquidity, remove_liquidity, close, collect_fees.", + ) + + # Range params (open) + lower_price: str | None = Field(default=None, description="Lower price bound of the position range (open)") + upper_price: str | None = Field(default=None, description="Upper price bound of the position range (open)") + + # Liquidity params + base_token_amount: str | None = Field(default=None, description="Base token amount (open / add_liquidity)") + quote_token_amount: str | None = Field(default=None, description="Quote token amount (open / add_liquidity)") + percentage_to_remove: str | None = Field( + default=None, + description="Percentage of liquidity to remove, 0-100 (remove_liquidity). 100 empties the position but " + "leaves the account open — use action='close' to withdraw and close it.", + ) + slippage_pct: str | None = Field(default=None, description="Maximum slippage percentage (as string)") + extra_params: dict[str, Any] | None = Field( + default=None, + description="Connector-specific parameters for open (e.g. {'strategyType': 0} for Meteora DLMM)", + ) diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index 851b53d5c..ed4357d38 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -14,6 +14,7 @@ format_active_bots_as_table, format_amm_result, format_bot_logs_as_table, + format_clmm_result, format_gateway_clmm_pool_result, format_gateway_config_result, format_gateway_container_result, @@ -24,6 +25,7 @@ from mcp_servers.hummingbot_api.middleware import GATEWAY_LOG_HINT, handle_errors from mcp_servers.hummingbot_api.schemas import ( AMMRequest, + CLMMRequest, GatewayCLMMRequest, GatewayConfigRequest, GatewayContainerRequest, @@ -50,6 +52,9 @@ from mcp_servers.hummingbot_api.tools.gateway_clmm import ( explore_gateway_clmm_pools as explore_gateway_clmm_pools_impl, ) +from mcp_servers.hummingbot_api.tools.gateway_clmm import ( + manage_clmm_impl, +) from mcp_servers.hummingbot_api.tools.gateway_swap import ( manage_gateway_swaps as manage_gateway_swaps_impl, ) @@ -970,6 +975,100 @@ async def manage_amm( return format_amm_result(action, result) +@mcp.tool() +@handle_errors("manage CLMM", GATEWAY_LOG_HINT) +async def manage_clmm( + action: ( + Literal[ + "position_info", + "open", + "add_liquidity", + "remove_liquidity", + "close", + "collect_fees", + ] + | None + ) = None, + connector: str | None = None, + network: str | None = None, + wallet_address: str | None = None, + pool_address: str | None = None, + position_address: str | None = None, + lower_price: str | None = None, + upper_price: str | None = None, + base_token_amount: str | None = None, + quote_token_amount: str | None = None, + percentage_to_remove: str | None = None, + slippage_pct: str | None = None, + extra_params: dict | None = None, +) -> str: + """Direct CLMM position operations, chain- & DEX-agnostic (Meteora / Raydium / Orca / Uniswap / PancakeSwap). + + Stateless — you hold position state in your journal. Progressive disclosure: call with NO + `action` to load the CLMM guide. + + THE UNMANAGED PATH. For normal LP work use `manage_executors` with `lp_executor`, which owns + range monitoring, rebalancing and bounded close retries. Use this tool when no executor can do + that for you — above all to RECOVER AN ORPHANED POSITION: a terminated executor cannot be told + to close anything (`manage_executors(action="stop")` on one is correctly a no-op), so the + position must be closed by address here, then marked with + `manage_executors(action="resolve_orphan")`. + + Actions: + - position_info → your positions in a pool with amounts, range, and uncollected fees + - open → create a position NO executor tracks (not range-monitored or auto-closed) + - add_liquidity / remove_liquidity → resize an existing position, keeping its range + - close → withdraw everything, collect fees, close the account + - collect_fees → fees only, position untouched + + remove_liquidity at 100% leaves an EMPTY POSITION OPEN; only `close` closes the account. To + recover an orphan, use `close`. + + Positions opened by an lp_executor are not in the API database (the bot opens them straight + against Gateway), so close/collect_fees on those REQUIRE `pool_address` — the orphan listing + reports it. Without it the call fails with a 400 saying so. + + Pool discovery lives in `explore_dex_pools`. + + Args: + action: CLMM action. Leave empty to load the CLMM guide. + connector: CLMM connector: meteora | raydium | orca (Solana), uniswap | pancakeswap (EVM). + A '/clmm' form is accepted, so an orphan's lp_provider passes through unchanged. + network: Network ID in 'chain-network' format (e.g. 'solana-mainnet-beta', 'ethereum-mainnet'). + For an orphan record this is the `connector_name` field. + wallet_address: Wallet address (optional, uses default if not provided). + pool_address: Pool contract address. Required for open and position_info, and for + close/collect_fees on a position the API never recorded. + position_address: Position NFT address (add_liquidity, remove_liquidity, close, collect_fees). + lower_price: Lower price bound of the range (open). + upper_price: Upper price bound of the range (open). + base_token_amount: Base token amount (open / add_liquidity). + quote_token_amount: Quote token amount (open / add_liquidity). + percentage_to_remove: Percentage of liquidity to remove, 0-100 (remove_liquidity). + slippage_pct: Maximum slippage percentage. + extra_params: Connector-specific params for open (e.g. {"strategyType": 0} for Meteora DLMM). + """ + request = CLMMRequest( + action=action, + connector=connector, + network=network, + wallet_address=wallet_address, + pool_address=pool_address, + position_address=position_address, + lower_price=lower_price, + upper_price=upper_price, + base_token_amount=base_token_amount, + quote_token_amount=quote_token_amount, + percentage_to_remove=percentage_to_remove, + slippage_pct=slippage_pct, + extra_params=extra_params, + ) + + client = await hummingbot_client.get_client() + result = await manage_clmm_impl(client, request) + return format_clmm_result(action, result) + + # GeckoTerminal Tools diff --git a/mcp_servers/hummingbot_api/tools/executors.py b/mcp_servers/hummingbot_api/tools/executors.py index 53f45b923..84211af05 100644 --- a/mcp_servers/hummingbot_api/tools/executors.py +++ b/mcp_servers/hummingbot_api/tools/executors.py @@ -316,9 +316,12 @@ async def manage_executors( if result.get("orphaned_position"): formatted += ( f"\n🚨 ORPHANED POSITION: {result.get('position_address')} is still " - "open on-chain with no automated owner. Close it via the gateway " - "tools (remove liquidity by position address), then mark it " - f"recovered with action=\"resolve_orphan\", executor_id=\"{request.executor_id}\".\n" + "open on-chain with no automated owner. Stopping the executor does not " + "close it — it has already terminated. Close it with " + "manage_clmm(action=\"close\", position_address=..., pool_address=...), " + "then mark it recovered with action=\"resolve_orphan\", " + f"executor_id=\"{request.executor_id}\".\n" + "Run action=\"orphaned\" to get the dex, pool and network for the call.\n" ) elif result.get("position_address"): formatted += f"Position address (final state): {result.get('position_address')}\n" @@ -364,15 +367,30 @@ async def manage_executors( ) if o.get("position_address"): formatted += f" position: {o['position_address']}\n" + if o.get("lp_provider") or o.get("pool_address"): + formatted += ( + f" dex: {o.get('lp_provider')} pool: {o.get('pool_address')}\n" + ) if o.get("needs_onchain_reconciliation"): formatted += ( " position address unknown (API restart) - reconcile against " "on-chain positions (get_portfolio_overview include_lp_positions=True)\n" ) + elif o.get("lp_provider") and o.get("pool_address"): + # Spell the recovery call out: the executor is terminated, so stopping it is + # a no-op and the position can only be closed by address. + formatted += ( + " close with: manage_clmm(action=\"close\", " + f"connector=\"{o.get('lp_provider')}\", " + f"network=\"{o.get('connector_name')}\", " + f"position_address=\"{o.get('position_address')}\", " + f"pool_address=\"{o.get('pool_address')}\")\n" + ) formatted += ( - "\nRecover each by closing the position via the gateway tools " - "(remove liquidity by position address), then mark it recovered with " - "action=\"resolve_orphan\", executor_id=\"...\"." + "\nRecover each by closing the position with manage_clmm(action=\"close\") - " + "pool_address is required because LP-executor positions are not in the API " + "database. Stopping the executor will NOT close it; it has already terminated. " + "Then mark it recovered with action=\"resolve_orphan\", executor_id=\"...\"." ) return { diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index 852c1090c..7e7689301 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -1,22 +1,30 @@ """ Gateway CLMM tools for Hummingbot MCP Server -Handles DEX CLMM read-only operations via Hummingbot Gateway: -- Pool exploration (list pools, get pool info) -- Position queries (get positions) +Two tools live here: +- explore_gateway_clmm_pools: read-only pool discovery (list pools, get pool info) +- manage_clmm: direct position operations (open, add, remove, close, collect fees) -For opening/closing LP positions, use `manage_executors` with `lp_executor` type. +The managed path for normal LP work is `manage_executors` with `lp_executor`, which owns range +monitoring, rebalancing and close retries. manage_clmm is the direct path, and the only way to +recover an orphaned position: once an executor has terminated it cannot be told to close anything, +so the position has to be closed by address. """ import logging +from decimal import Decimal +from pathlib import Path from typing import Any from mcp_servers.hummingbot_api.exceptions import ToolError from mcp_servers.hummingbot_api.formatters.base import format_number, get_field -from mcp_servers.hummingbot_api.schemas import GatewayCLMMRequest +from mcp_servers.hummingbot_api.schemas import CLMMRequest, GatewayCLMMRequest logger = logging.getLogger("hummingbot-mcp") +# Gateway routes CLMM calls on the bare connector name (see trading/clmm close/open routes). +SUPPORTED_CLMM_CONNECTORS = {"meteora", "raydium", "orca", "uniswap", "pancakeswap", "pancakeswap-sol"} + def format_pools_as_table(pools: list[dict[str, Any]]) -> str: """ @@ -195,3 +203,130 @@ async def explore_gateway_clmm_pools( else: raise ToolError(f"Unknown action: {request.action}") + + +def _clmm_guide() -> str: + guide_file = Path(__file__).parent.parent / "guides" / "gateway_clmm.md" + if guide_file.exists(): + return guide_file.read_text().strip() + raise ToolError("CLMM guide not found (guides/gateway_clmm.md)") + + +def _dec(value: str | None, name: str) -> Decimal: + if value is None: + raise ToolError(f"{name} is required for this action") + try: + return Decimal(str(value)) + except Exception as exc: + raise ToolError(f"{name} must be a number, got {value!r}") from exc + + +def _opt_dec(value: str | None) -> Decimal | None: + return None if value is None else Decimal(str(value)) + + +def _require_clmm(request: CLMMRequest, *fields: str) -> None: + for f in fields: + if getattr(request, f) is None: + raise ToolError(f"{f} is required for the '{request.action}' action") + + +def _normalize_connector(connector: str) -> str: + """Accept both 'orca' and the 'orca/clmm' form an lp_executor config records as lp_provider.""" + name = connector.lower().strip() + if name.endswith("/clmm"): + name = name[: -len("/clmm")] + if name not in SUPPORTED_CLMM_CONNECTORS: + raise ToolError( + f"Unsupported CLMM connector '{connector}'. Supported: {', '.join(sorted(SUPPORTED_CLMM_CONNECTORS))}" + ) + return name + + +async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: + """Validate per-action and dispatch to client.gateway_clmm.*.""" + # Progressive disclosure: no action → load the guide. + if request.action is None: + return {"action": None, "formatted_output": _clmm_guide()} + + if not request.connector: + raise ToolError( + "connector is required (meteora | raydium | orca | uniswap | pancakeswap). " + "Call manage_clmm with no action to load the guide." + ) + connector = _normalize_connector(request.connector) + if not request.network: + raise ToolError("network is required (e.g. 'solana-mainnet-beta', 'ethereum-mainnet')") + + net = request.network + action = request.action + # Dereferenced only after per-action validation so guard errors never depend on the client. + gc = None if client is None else client.gateway_clmm + + if action == "position_info": + _require_clmm(request, "pool_address") + result = await gc.get_positions_owned( + connector=connector, network=net, pool_address=request.pool_address, + wallet_address=request.wallet_address, + ) + + elif action == "open": + _require_clmm(request, "pool_address", "lower_price", "upper_price") + if request.base_token_amount is None and request.quote_token_amount is None: + raise ToolError("open requires base_token_amount, quote_token_amount, or both") + result = await gc.open_position( + connector=connector, network=net, pool_address=request.pool_address, + lower_price=_dec(request.lower_price, "lower_price"), + upper_price=_dec(request.upper_price, "upper_price"), + base_token_amount=_opt_dec(request.base_token_amount), + quote_token_amount=_opt_dec(request.quote_token_amount), + slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, + extra_params=request.extra_params, + ) + + elif action == "add_liquidity": + _require_clmm(request, "position_address") + if request.base_token_amount is None and request.quote_token_amount is None: + raise ToolError("add_liquidity requires base_token_amount, quote_token_amount, or both") + result = await gc.add_liquidity( + connector=connector, network=net, position_address=request.position_address, + base_token_amount=_opt_dec(request.base_token_amount), + quote_token_amount=_opt_dec(request.quote_token_amount), + slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, + ) + + elif action == "remove_liquidity": + _require_clmm(request, "position_address", "percentage_to_remove") + result = await gc.remove_liquidity( + connector=connector, network=net, position_address=request.position_address, + percentage=_dec(request.percentage_to_remove, "percentage_to_remove"), + wallet_address=request.wallet_address, + ) + + elif action == "close": + _require_clmm(request, "position_address") + result = await gc.close_position( + connector=connector, network=net, position_address=request.position_address, + pool_address=request.pool_address, wallet_address=request.wallet_address, + ) + + elif action == "collect_fees": + _require_clmm(request, "position_address") + result = await gc.collect_fees( + connector=connector, network=net, position_address=request.position_address, + pool_address=request.pool_address, wallet_address=request.wallet_address, + ) + + else: + raise ToolError(f"Unknown action: {action}") + + return { + "action": action, + "connector": connector, + "network": net, + "pool_address": request.pool_address, + "position_address": request.position_address, + "result": result, + } + + diff --git a/tests/test_manage_clmm.py b/tests/test_manage_clmm.py new file mode 100644 index 000000000..43add298e --- /dev/null +++ b/tests/test_manage_clmm.py @@ -0,0 +1,210 @@ +"""manage_clmm — the tool that makes an orphaned CLMM position recoverable. + +Before this tool existed the orphan warning told the agent to "close the position via the gateway +tools", but nothing exposed a CLMM close: ``manage_executors(action="stop")`` is correctly a no-op +on an already-terminated executor, ``manage_amm`` covers AMMs only, and ``explore_dex_pools`` is +read-only. The recovery instruction had no implementing tool. + +Two details carry the recovery path and are pinned here: +- an orphan record reports ``lp_provider`` as ``"orca/clmm"`` while Gateway routes on the bare + ``"orca"``, so the tool must normalise it +- an lp_executor's position is opened by the bot straight against Gateway, so the API database has + no row to read the pool from and ``pool_address`` must be forwarded on close + +The repo has no async test setup, so coroutines are driven with asyncio.run(). +""" +import asyncio + +import pytest + +from mcp_servers.hummingbot_api.exceptions import ToolError +from mcp_servers.hummingbot_api.schemas import CLMMRequest +from mcp_servers.hummingbot_api.tools.gateway_clmm import manage_clmm_impl + + +class _RecordingCLMM: + """Captures the kwargs each client.gateway_clmm.* call receives.""" + + def __init__(self): + self.calls = [] + + def _record(self, name): + async def call(**kwargs): + self.calls.append((name, kwargs)) + return {"transaction_hash": "sig123", "status": "CONFIRMED"} + + return call + + def __getattr__(self, name): + return self._record(name) + + +class _Client: + def __init__(self): + self.gateway_clmm = _RecordingCLMM() + + +def _run(request, client=None): + return asyncio.run(manage_clmm_impl(client, request)) + + +# --- connector normalisation ------------------------------------------------- + + +def test_lp_provider_form_is_normalised_to_the_bare_connector(): + """An orphan's lp_provider ('orca/clmm') must reach Gateway as 'orca'.""" + client = _Client() + + result = _run( + CLMMRequest( + action="close", + connector="orca/clmm", + network="solana-mainnet-beta", + position_address="POS", + pool_address="POOL", + ), + client, + ) + + assert result["connector"] == "orca" + _, kwargs = client.gateway_clmm.calls[0] + assert kwargs["connector"] == "orca" + + +def test_unsupported_connector_is_rejected_by_name(): + with pytest.raises(ToolError, match="Unsupported CLMM connector"): + _run( + CLMMRequest( + action="close", + connector="sushiswap", + network="ethereum-mainnet", + position_address="POS", + ) + ) + + +# --- the orphan recovery call ------------------------------------------------ + + +def test_close_forwards_pool_address(): + """Without pool_address the API 400s: an lp_executor position is never in its database.""" + client = _Client() + + _run( + CLMMRequest( + action="close", + connector="orca", + network="solana-mainnet-beta", + position_address="H4vD69DsraHjHyKvRwRPHVGe2aJkvAUaNK5tMif2CiNw", + pool_address="Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", + ), + client, + ) + + name, kwargs = client.gateway_clmm.calls[0] + assert name == "close_position" + assert kwargs["pool_address"] == "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE" + assert kwargs["position_address"] == "H4vD69DsraHjHyKvRwRPHVGe2aJkvAUaNK5tMif2CiNw" + + +def test_collect_fees_forwards_pool_address(): + client = _Client() + + _run( + CLMMRequest( + action="collect_fees", + connector="meteora", + network="solana-mainnet-beta", + position_address="POS", + pool_address="POOL", + ), + client, + ) + + name, kwargs = client.gateway_clmm.calls[0] + assert name == "collect_fees" + assert kwargs["pool_address"] == "POOL" + + +# --- validation guards ------------------------------------------------------- + + +def test_no_action_returns_the_guide_without_touching_the_client(): + result = _run(CLMMRequest()) + + assert result["action"] is None + assert "manage_clmm" in result["formatted_output"] + assert "orphan" in result["formatted_output"].lower() + + +def test_connector_is_required(): + with pytest.raises(ToolError, match="connector is required"): + _run(CLMMRequest(action="close", network="solana-mainnet-beta", position_address="POS")) + + +def test_network_is_required(): + with pytest.raises(ToolError, match="network is required"): + _run(CLMMRequest(action="close", connector="orca", position_address="POS")) + + +def test_close_requires_position_address(): + with pytest.raises(ToolError, match="position_address is required"): + _run(CLMMRequest(action="close", connector="orca", network="solana-mainnet-beta")) + + +def test_remove_liquidity_requires_a_percentage(): + with pytest.raises(ToolError, match="percentage_to_remove is required"): + _run( + CLMMRequest( + action="remove_liquidity", + connector="orca", + network="solana-mainnet-beta", + position_address="POS", + ) + ) + + +def test_open_requires_at_least_one_amount(): + """Both amounts optional individually, but an empty position is not a position.""" + with pytest.raises(ToolError, match="requires base_token_amount"): + _run( + CLMMRequest( + action="open", + connector="orca", + network="solana-mainnet-beta", + pool_address="POOL", + lower_price="75", + upper_price="76", + ) + ) + + +def test_guards_run_before_the_client_is_dereferenced(): + """A validation failure must not depend on having a client at all.""" + request = CLMMRequest(action="close", connector="orca", network="solana-mainnet-beta") + + with pytest.raises(ToolError, match="position_address is required"): + _run(request, client=None) + + +# --- amounts are passed through as Decimals --------------------------------- + + +def test_remove_liquidity_passes_percentage_as_decimal(): + from decimal import Decimal + + client = _Client() + + _run( + CLMMRequest( + action="remove_liquidity", + connector="raydium", + network="solana-mainnet-beta", + position_address="POS", + percentage_to_remove="50", + ), + client, + ) + + _, kwargs = client.gateway_clmm.calls[0] + assert kwargs["percentage"] == Decimal("50") diff --git a/tests/test_orphan_recovery_instructions.py b/tests/test_orphan_recovery_instructions.py new file mode 100644 index 000000000..901a6adaa --- /dev/null +++ b/tests/test_orphan_recovery_instructions.py @@ -0,0 +1,127 @@ +"""The orphan listing must hand the agent a call it can actually make. + +The listing used to say "close the position via the gateway tools (remove liquidity by position +address)" — an instruction with no implementing tool. An agent following it found that +``manage_executors(action="stop")`` was a no-op (the executor is already terminated), ``manage_amm`` +does not handle CLMM positions, and ``explore_dex_pools`` is read-only, so the position stayed open. + +The listing now emits a concrete ``manage_clmm(action="close", ...)`` call built from the record's +own fields. Two are easy to get wrong and are pinned here: +- ``connector_name`` is the NETWORK for an lp_executor ("solana-mainnet-beta"), so it must land in + ``network=``, never ``connector=`` +- ``lp_provider`` is the DEX, and ``pool_address`` must be forwarded or the close 400s + +The repo has no async test setup, so coroutines are driven with asyncio.run(). +""" +import asyncio + +from mcp_servers.hummingbot_api.schemas import ManageExecutorsRequest +from mcp_servers.hummingbot_api.tools.executors import manage_executors + +ORPHAN = { + "executor_id": "HJJUFaSZdThCH6rRv5agcVCZ4widziThm1aRdh2LtcTW", + "executor_type": "lp_executor", + "connector_name": "solana-mainnet-beta", + "trading_pair": "SOL-USDC", + "lp_provider": "orca/clmm", + "pool_address": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", + "close_type": "POSITION_HOLD", + "closed_at": "2026-08-13T18:04:11+00:00", + "position_address": "H4vD69DsraHjHyKvRwRPHVGe2aJkvAUaNK5tMif2CiNw", + "hold_reason": "close_retries_exhausted", + "needs_onchain_reconciliation": False, +} + + +class _Response: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + async def json(self): + return self._payload + + +class _Session: + def __init__(self, payload): + self._payload = payload + + async def get(self, _url): + return _Response(self._payload) + + +class _Executors: + def __init__(self, payload): + self.session = _Session(payload) + self.base_url = "http://api" + + +class _Client: + def __init__(self, payload): + self.executors = _Executors(payload) + + +def _list_orphans(orphans): + client = _Client({"count": len(orphans), "orphans": orphans}) + request = ManageExecutorsRequest(action="orphaned") + return asyncio.run(manage_executors(client=client, request=request))["formatted_output"] + + +def test_listing_emits_a_runnable_close_call(): + output = _list_orphans([ORPHAN]) + + assert 'manage_clmm(action="close"' in output + assert 'connector="orca/clmm"' in output + assert 'network="solana-mainnet-beta"' in output + assert f'position_address="{ORPHAN["position_address"]}"' in output + assert f'pool_address="{ORPHAN["pool_address"]}"' in output + + +def test_network_is_not_passed_as_the_connector(): + """connector_name holds the network for an lp_executor — mixing them up produces a dead call.""" + output = _list_orphans([ORPHAN]) + + assert 'connector="solana-mainnet-beta"' not in output + + +def test_listing_reports_the_dex_and_pool(): + output = _list_orphans([ORPHAN]) + + assert "dex: orca/clmm" in output + assert f"pool: {ORPHAN['pool_address']}" in output + + +def test_listing_says_stopping_will_not_close_it(): + """The failure mode was an agent trying to stop an already-terminated executor.""" + output = _list_orphans([ORPHAN]) + + assert "Stopping the executor will NOT close it" in output + assert 'resolve_orphan' in output + + +def test_no_close_call_when_the_position_address_is_unknown(): + """A SYSTEM_CLEANUP orphan needs reconciliation first — never a close call with None in it.""" + unknown = { + **ORPHAN, + "close_type": "SYSTEM_CLEANUP", + "position_address": None, + "hold_reason": None, + "needs_onchain_reconciliation": True, + } + + output = _list_orphans([unknown]) + + # The trailing summary always names the recovery call; what must not appear is a + # per-record "close with:" line, which would carry position_address=None. + assert "close with:" not in output + assert 'position_address="None"' not in output + assert "position address unknown" in output + + +def test_empty_listing_stays_clean(): + output = _list_orphans([]) + + assert "No orphaned positions" in output + assert "close with:" not in output From a091669b16db0ade1cccb71b7c17ed009207a5bb Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 17 Aug 2026 06:51:44 -0700 Subject: [PATCH 07/65] fix(clmm): point every orphan warning at manage_clmm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four places told the agent (or the Telegram user) to close an orphaned position "via the gateway tools" — the tick-prompt warning, the Telegram stop menu, the manage_executors docstring, and the orphan listing. None named a tool that could do it, and the nearest guess, stopping the executor, is a no-op because it has already terminated. All four now name manage_clmm(action="close") and say plainly that stopping will not close the position. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj --- condor/agents/providers/executors.py | 13 ++++++++----- handlers/executors/menu.py | 3 ++- mcp_servers/hummingbot_api/server.py | 6 ++++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/condor/agents/providers/executors.py b/condor/agents/providers/executors.py index c1c38c04f..5de4e6e8b 100644 --- a/condor/agents/providers/executors.py +++ b/condor/agents/providers/executors.py @@ -92,11 +92,14 @@ async def execute( reason = (o.get("custom_info") or {}).get("hold_reason") or "close failed" lines.append( f" 🚨 ORPHANED POSITION: executor {o['id']} ({o['pair']}) terminated " - f"({reason}) with position {pos} still open on-chain. Close it via the " - "gateway tools (remove liquidity by position address — a new " - "lp_executor CANNOT adopt it and would mint a second position), then " - f"mark it recovered with manage_executors(action=\"resolve_orphan\", " - f"executor_id=\"{o['id']}\"). Do not open new positions on these funds first." + f"({reason}) with position {pos} still open on-chain. Stopping the executor " + "will NOT close it — it has already terminated. Close it with " + "manage_clmm(action=\"close\", position_address=..., pool_address=...); " + "manage_executors(action=\"orphaned\") reports the dex, pool and network for " + "the call, and pool_address is required. A new lp_executor CANNOT adopt the " + "position and would mint a second one. Then mark it recovered with " + f"manage_executors(action=\"resolve_orphan\", executor_id=\"{o['id']}\"). " + "Do not open new positions on these funds first." ) if perf.bot_names: lines.append(f" Bots operated: {', '.join(perf.bot_names)}") diff --git a/handlers/executors/menu.py b/handlers/executors/menu.py index aa052f3da..a28fa373b 100644 --- a/handlers/executors/menu.py +++ b/handlers/executors/menu.py @@ -920,7 +920,8 @@ async def handle_confirm_stop_executor( text += ( f"\n\n🚨 *Orphaned position still open on\\-chain:*\n" f"`{escape_markdown_v2(str(stop_result.get('position_address')))}`\n" - "Close it via the gateway tools, then mark it recovered\\." + "Stopping did not close it — the executor had already terminated\\. " + "Ask the agent to close it with `manage_clmm`, then mark it recovered\\." ) else: text = f"✅ *Executor Stopped*\n\n🆔 `{escape_markdown_v2(full_id[:30])}`" diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index ed4357d38..90def1dbf 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -754,8 +754,10 @@ async def manage_executors( - positions_summary → View all positions (add connector_name + trading_pair to filter) - clear_position + connector_name + trading_pair → Clear externally-closed position - performance_report → Get executor performance report (optionally filter by controller_id) - - orphaned → List terminated executors that may still own an on-chain LP position (recover before opening new ones) - - resolve_orphan + executor_id → Mark an orphaned position as recovered after closing it via the gateway tools + - orphaned → List terminated executors that may still own an on-chain LP position (recover before opening new ones). + Reports the dex, pool and network needed to close each one with `manage_clmm(action="close")` + - resolve_orphan + executor_id → Mark an orphaned position as recovered, after closing it with `manage_clmm`. + Stopping a terminated executor does NOT close its position Args: action: Action to perform. Leave empty to see executor types or config schema. From dbc9ffc960361422619223ad7ae024b5f1460ed8 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 17 Aug 2026 08:24:59 -0700 Subject: [PATCH 08/65] chore(deps): lock hummingbot-api-client at 1.5.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pyproject already required ==1.5.8, but uv.lock still resolved 1.5.6, so every `uv run --frozen` silently re-synced the venv back down. That downgrade is not cosmetic: 1.5.6 has no pool_address on close_position and no add/remove liquidity, so manage_clmm's close died with TypeError: close_position() got an unexpected keyword argument 'pool_address' The lock now names 1.5.8. Its sdist/wheel hashes are absent because 1.5.8 is not published yet — they get filled in by a plain `uv lock` once hummingbot-api-client#25 merges and releases. `uv lock --check` accepts the entry as consistent with pyproject in the meantime. Until that release, `uv run --frozen` fails loudly: Distribution `hummingbot-api-client==1.5.8` can't be installed because it doesn't have a source distribution or wheel for the current platform which is the honest state of the dependency, and better than silently running against a client that cannot satisfy the tool. To work locally before the release, install the client from source and skip the re-sync: uv pip install --python .venv/bin/python ../hummingbot-api-client uv run --no-sync python -m pytest tests/ Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj --- uv.lock | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/uv.lock b/uv.lock index a25fe8e03..7615d2117 100644 --- a/uv.lock +++ b/uv.lock @@ -639,7 +639,7 @@ requires-dist = [ { name = "fastapi" }, { name = "faster-whisper" }, { name = "geckoterminal-py" }, - { name = "hummingbot-api-client", specifier = "==1.5.7" }, + { name = "hummingbot-api-client", specifier = "==1.5.8" }, { name = "kaleido" }, { name = "markdown", specifier = ">=3.10.2" }, { name = "matplotlib", specifier = ">=3.10.8" }, @@ -1495,15 +1495,11 @@ wheels = [ [[package]] name = "hummingbot-api-client" -version = "1.5.7" +version = "1.5.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/92/9bb06b365b8d9c457ad39aa58ffa471ef2668f43433b6fae5b338c57609f/hummingbot_api_client-1.5.7.tar.gz", hash = "sha256:65d9cef4f518fd9ab2dacd4de345e1d46ea30a1a19c01b730936078f9b441679", size = 41107, upload-time = "2026-08-10T14:46:53.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/ce/50e80f122cbc5a80aa9a5c595381d54633fd2e72cfb639e78320f044b66c/hummingbot_api_client-1.5.7-py3-none-any.whl", hash = "sha256:a1a133f2ba7ef9f30ffec99ab61cf10377d8be210e39d85f0e76dc8f63bab5cb", size = 48960, upload-time = "2026-08-10T14:46:52.307Z" }, -] [[package]] name = "identify" From 8bf2905cfe0289e330d67e61c126b4e3bc1bb1b4 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 17 Aug 2026 10:54:25 -0700 Subject: [PATCH 09/65] style: black + isort the CLMM/orphan changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing onto #203 moves this branch onto a base that enforces `black --check .` and `isort --check .` in CI, which main did not. The code was written to match the surrounding pre-reformat style, so it needs a pass with the locked tools (black 26.1.0, isort 8.0.1). Formatting only — no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj --- condor/agents/providers/executors.py | 4 +- .../hummingbot_api/formatters/gateway.py | 5 +- mcp_servers/hummingbot_api/schemas.py | 39 ++++++++---- mcp_servers/hummingbot_api/tools/executors.py | 26 ++++---- .../hummingbot_api/tools/gateway_clmm.py | 59 ++++++++++++++----- tests/test_executor_resolve_orphan.py | 1 + tests/test_manage_clmm.py | 15 ++++- tests/test_orphan_recovery_instructions.py | 7 ++- 8 files changed, 108 insertions(+), 48 deletions(-) diff --git a/condor/agents/providers/executors.py b/condor/agents/providers/executors.py index 5de4e6e8b..fede53c04 100644 --- a/condor/agents/providers/executors.py +++ b/condor/agents/providers/executors.py @@ -94,8 +94,8 @@ async def execute( f" 🚨 ORPHANED POSITION: executor {o['id']} ({o['pair']}) terminated " f"({reason}) with position {pos} still open on-chain. Stopping the executor " "will NOT close it — it has already terminated. Close it with " - "manage_clmm(action=\"close\", position_address=..., pool_address=...); " - "manage_executors(action=\"orphaned\") reports the dex, pool and network for " + 'manage_clmm(action="close", position_address=..., pool_address=...); ' + 'manage_executors(action="orphaned") reports the dex, pool and network for ' "the call, and pool_address is required. A new lp_executor CANNOT adopt the " "position and would mint a second one. Then mark it recovered with " f"manage_executors(action=\"resolve_orphan\", executor_id=\"{o['id']}\"). " diff --git a/mcp_servers/hummingbot_api/formatters/gateway.py b/mcp_servers/hummingbot_api/formatters/gateway.py index 4095827c8..42f3b2ead 100644 --- a/mcp_servers/hummingbot_api/formatters/gateway.py +++ b/mcp_servers/hummingbot_api/formatters/gateway.py @@ -231,7 +231,10 @@ def format_clmm_result(action: str, result: dict[str, Any]) -> str: f"{header}\nPool: {result.get('pool_address')}\n" "No open positions for this wallet in this pool." ) - lines = [f"{header}", f"Pool: {result.get('pool_address')} — {len(payload)} position(s)"] + lines = [ + f"{header}", + f"Pool: {result.get('pool_address')} — {len(payload)} position(s)", + ] for p in payload: lines.append( f" • {p.get('address')} — Base: {p.get('baseTokenAmount')} " diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index 9d14c474b..8a39ed992 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -720,24 +720,29 @@ class CLMMRequest(BaseModel): "collect_fees", ] | None - ) = Field(default=None, description="CLMM action. Leave empty to load the CLMM guide.") + ) = Field( + default=None, description="CLMM action. Leave empty to load the CLMM guide." + ) connector: str | None = Field( default=None, description="CLMM connector (required for any action): 'meteora', 'raydium', 'orca' (Solana), " - "'uniswap', 'pancakeswap' (EVM). A '/clmm' form is accepted, so an orphan record's " - "lp_provider (e.g. 'orca/clmm') can be passed through unchanged.", + "'uniswap', 'pancakeswap' (EVM). A '/clmm' form is accepted, so an orphan record's " + "lp_provider (e.g. 'orca/clmm') can be passed through unchanged.", ) network: str | None = Field( default=None, description="Network ID in 'chain-network' format. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'bsc-mainnet'", ) - wallet_address: str | None = Field(default=None, description="Wallet address (optional, uses default if not provided)") + wallet_address: str | None = Field( + default=None, + description="Wallet address (optional, uses default if not provided)", + ) pool_address: str | None = Field( default=None, description="Pool contract address. Required for open and position_info. Also required to close or " - "collect fees on a position the API never recorded — every lp_executor position, since the " - "bot opens those straight against Gateway.", + "collect fees on a position the API never recorded — every lp_executor position, since the " + "bot opens those straight against Gateway.", ) position_address: str | None = Field( default=None, @@ -745,18 +750,28 @@ class CLMMRequest(BaseModel): ) # Range params (open) - lower_price: str | None = Field(default=None, description="Lower price bound of the position range (open)") - upper_price: str | None = Field(default=None, description="Upper price bound of the position range (open)") + lower_price: str | None = Field( + default=None, description="Lower price bound of the position range (open)" + ) + upper_price: str | None = Field( + default=None, description="Upper price bound of the position range (open)" + ) # Liquidity params - base_token_amount: str | None = Field(default=None, description="Base token amount (open / add_liquidity)") - quote_token_amount: str | None = Field(default=None, description="Quote token amount (open / add_liquidity)") + base_token_amount: str | None = Field( + default=None, description="Base token amount (open / add_liquidity)" + ) + quote_token_amount: str | None = Field( + default=None, description="Quote token amount (open / add_liquidity)" + ) percentage_to_remove: str | None = Field( default=None, description="Percentage of liquidity to remove, 0-100 (remove_liquidity). 100 empties the position but " - "leaves the account open — use action='close' to withdraw and close it.", + "leaves the account open — use action='close' to withdraw and close it.", + ) + slippage_pct: str | None = Field( + default=None, description="Maximum slippage percentage (as string)" ) - slippage_pct: str | None = Field(default=None, description="Maximum slippage percentage (as string)") extra_params: dict[str, Any] | None = Field( default=None, description="Connector-specific parameters for open (e.g. {'strategyType': 0} for Meteora DLMM)", diff --git a/mcp_servers/hummingbot_api/tools/executors.py b/mcp_servers/hummingbot_api/tools/executors.py index 84211af05..47938f39b 100644 --- a/mcp_servers/hummingbot_api/tools/executors.py +++ b/mcp_servers/hummingbot_api/tools/executors.py @@ -318,10 +318,10 @@ async def manage_executors( f"\n🚨 ORPHANED POSITION: {result.get('position_address')} is still " "open on-chain with no automated owner. Stopping the executor does not " "close it — it has already terminated. Close it with " - "manage_clmm(action=\"close\", position_address=..., pool_address=...), " - "then mark it recovered with action=\"resolve_orphan\", " - f"executor_id=\"{request.executor_id}\".\n" - "Run action=\"orphaned\" to get the dex, pool and network for the call.\n" + 'manage_clmm(action="close", position_address=..., pool_address=...), ' + 'then mark it recovered with action="resolve_orphan", ' + f'executor_id="{request.executor_id}".\n' + 'Run action="orphaned" to get the dex, pool and network for the call.\n' ) elif result.get("position_address"): formatted += f"Position address (final state): {result.get('position_address')}\n" @@ -355,9 +355,13 @@ async def manage_executors( result = await resp.json() orphans = result.get("orphans", []) - formatted = f"Orphaned position candidates: {result.get('count', len(orphans))}\n\n" + formatted = ( + f"Orphaned position candidates: {result.get('count', len(orphans))}\n\n" + ) if not orphans: - formatted += "No orphaned positions. All terminated executors closed cleanly." + formatted += ( + "No orphaned positions. All terminated executors closed cleanly." + ) else: for o in orphans: formatted += ( @@ -368,9 +372,7 @@ async def manage_executors( if o.get("position_address"): formatted += f" position: {o['position_address']}\n" if o.get("lp_provider") or o.get("pool_address"): - formatted += ( - f" dex: {o.get('lp_provider')} pool: {o.get('pool_address')}\n" - ) + formatted += f" dex: {o.get('lp_provider')} pool: {o.get('pool_address')}\n" if o.get("needs_onchain_reconciliation"): formatted += ( " position address unknown (API restart) - reconcile against " @@ -380,17 +382,17 @@ async def manage_executors( # Spell the recovery call out: the executor is terminated, so stopping it is # a no-op and the position can only be closed by address. formatted += ( - " close with: manage_clmm(action=\"close\", " + ' close with: manage_clmm(action="close", ' f"connector=\"{o.get('lp_provider')}\", " f"network=\"{o.get('connector_name')}\", " f"position_address=\"{o.get('position_address')}\", " f"pool_address=\"{o.get('pool_address')}\")\n" ) formatted += ( - "\nRecover each by closing the position with manage_clmm(action=\"close\") - " + '\nRecover each by closing the position with manage_clmm(action="close") - ' "pool_address is required because LP-executor positions are not in the API " "database. Stopping the executor will NOT close it; it has already terminated. " - "Then mark it recovered with action=\"resolve_orphan\", executor_id=\"...\"." + 'Then mark it recovered with action="resolve_orphan", executor_id="...".' ) return { diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index 7e7689301..63c54ec3a 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -23,7 +23,14 @@ logger = logging.getLogger("hummingbot-mcp") # Gateway routes CLMM calls on the bare connector name (see trading/clmm close/open routes). -SUPPORTED_CLMM_CONNECTORS = {"meteora", "raydium", "orca", "uniswap", "pancakeswap", "pancakeswap-sol"} +SUPPORTED_CLMM_CONNECTORS = { + "meteora", + "raydium", + "orca", + "uniswap", + "pancakeswap", + "pancakeswap-sol", +} def format_pools_as_table(pools: list[dict[str, Any]]) -> str: @@ -256,7 +263,9 @@ async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: ) connector = _normalize_connector(request.connector) if not request.network: - raise ToolError("network is required (e.g. 'solana-mainnet-beta', 'ethereum-mainnet')") + raise ToolError( + "network is required (e.g. 'solana-mainnet-beta', 'ethereum-mainnet')" + ) net = request.network action = request.action @@ -266,39 +275,53 @@ async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: if action == "position_info": _require_clmm(request, "pool_address") result = await gc.get_positions_owned( - connector=connector, network=net, pool_address=request.pool_address, + connector=connector, + network=net, + pool_address=request.pool_address, wallet_address=request.wallet_address, ) elif action == "open": _require_clmm(request, "pool_address", "lower_price", "upper_price") if request.base_token_amount is None and request.quote_token_amount is None: - raise ToolError("open requires base_token_amount, quote_token_amount, or both") + raise ToolError( + "open requires base_token_amount, quote_token_amount, or both" + ) result = await gc.open_position( - connector=connector, network=net, pool_address=request.pool_address, + connector=connector, + network=net, + pool_address=request.pool_address, lower_price=_dec(request.lower_price, "lower_price"), upper_price=_dec(request.upper_price, "upper_price"), base_token_amount=_opt_dec(request.base_token_amount), quote_token_amount=_opt_dec(request.quote_token_amount), - slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, + slippage_pct=_opt_dec(request.slippage_pct), + wallet_address=request.wallet_address, extra_params=request.extra_params, ) elif action == "add_liquidity": _require_clmm(request, "position_address") if request.base_token_amount is None and request.quote_token_amount is None: - raise ToolError("add_liquidity requires base_token_amount, quote_token_amount, or both") + raise ToolError( + "add_liquidity requires base_token_amount, quote_token_amount, or both" + ) result = await gc.add_liquidity( - connector=connector, network=net, position_address=request.position_address, + connector=connector, + network=net, + position_address=request.position_address, base_token_amount=_opt_dec(request.base_token_amount), quote_token_amount=_opt_dec(request.quote_token_amount), - slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, + slippage_pct=_opt_dec(request.slippage_pct), + wallet_address=request.wallet_address, ) elif action == "remove_liquidity": _require_clmm(request, "position_address", "percentage_to_remove") result = await gc.remove_liquidity( - connector=connector, network=net, position_address=request.position_address, + connector=connector, + network=net, + position_address=request.position_address, percentage=_dec(request.percentage_to_remove, "percentage_to_remove"), wallet_address=request.wallet_address, ) @@ -306,15 +329,21 @@ async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: elif action == "close": _require_clmm(request, "position_address") result = await gc.close_position( - connector=connector, network=net, position_address=request.position_address, - pool_address=request.pool_address, wallet_address=request.wallet_address, + connector=connector, + network=net, + position_address=request.position_address, + pool_address=request.pool_address, + wallet_address=request.wallet_address, ) elif action == "collect_fees": _require_clmm(request, "position_address") result = await gc.collect_fees( - connector=connector, network=net, position_address=request.position_address, - pool_address=request.pool_address, wallet_address=request.wallet_address, + connector=connector, + network=net, + position_address=request.position_address, + pool_address=request.pool_address, + wallet_address=request.wallet_address, ) else: @@ -328,5 +357,3 @@ async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: "position_address": request.position_address, "result": result, } - - diff --git a/tests/test_executor_resolve_orphan.py b/tests/test_executor_resolve_orphan.py index 7c44de4b4..f82bae9a6 100644 --- a/tests/test_executor_resolve_orphan.py +++ b/tests/test_executor_resolve_orphan.py @@ -9,6 +9,7 @@ The repo has no async test setup, so the coroutine is driven with asyncio.run(). """ + import asyncio from mcp_servers.hummingbot_api.schemas import ManageExecutorsRequest diff --git a/tests/test_manage_clmm.py b/tests/test_manage_clmm.py index 43add298e..7b6e781f6 100644 --- a/tests/test_manage_clmm.py +++ b/tests/test_manage_clmm.py @@ -13,6 +13,7 @@ The repo has no async test setup, so coroutines are driven with asyncio.run(). """ + import asyncio import pytest @@ -139,7 +140,11 @@ def test_no_action_returns_the_guide_without_touching_the_client(): def test_connector_is_required(): with pytest.raises(ToolError, match="connector is required"): - _run(CLMMRequest(action="close", network="solana-mainnet-beta", position_address="POS")) + _run( + CLMMRequest( + action="close", network="solana-mainnet-beta", position_address="POS" + ) + ) def test_network_is_required(): @@ -149,7 +154,9 @@ def test_network_is_required(): def test_close_requires_position_address(): with pytest.raises(ToolError, match="position_address is required"): - _run(CLMMRequest(action="close", connector="orca", network="solana-mainnet-beta")) + _run( + CLMMRequest(action="close", connector="orca", network="solana-mainnet-beta") + ) def test_remove_liquidity_requires_a_percentage(): @@ -181,7 +188,9 @@ def test_open_requires_at_least_one_amount(): def test_guards_run_before_the_client_is_dereferenced(): """A validation failure must not depend on having a client at all.""" - request = CLMMRequest(action="close", connector="orca", network="solana-mainnet-beta") + request = CLMMRequest( + action="close", connector="orca", network="solana-mainnet-beta" + ) with pytest.raises(ToolError, match="position_address is required"): _run(request, client=None) diff --git a/tests/test_orphan_recovery_instructions.py b/tests/test_orphan_recovery_instructions.py index 901a6adaa..11c46575c 100644 --- a/tests/test_orphan_recovery_instructions.py +++ b/tests/test_orphan_recovery_instructions.py @@ -13,6 +13,7 @@ The repo has no async test setup, so coroutines are driven with asyncio.run(). """ + import asyncio from mcp_servers.hummingbot_api.schemas import ManageExecutorsRequest @@ -66,7 +67,9 @@ def __init__(self, payload): def _list_orphans(orphans): client = _Client({"count": len(orphans), "orphans": orphans}) request = ManageExecutorsRequest(action="orphaned") - return asyncio.run(manage_executors(client=client, request=request))["formatted_output"] + return asyncio.run(manage_executors(client=client, request=request))[ + "formatted_output" + ] def test_listing_emits_a_runnable_close_call(): @@ -98,7 +101,7 @@ def test_listing_says_stopping_will_not_close_it(): output = _list_orphans([ORPHAN]) assert "Stopping the executor will NOT close it" in output - assert 'resolve_orphan' in output + assert "resolve_orphan" in output def test_no_close_call_when_the_position_address_is_unknown(): From 04d1701cc15ef4d4c716cb3716afb308912fc543 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 17 Aug 2026 11:46:21 -0700 Subject: [PATCH 10/65] chore(agents): pin adaptive_grid_trader to sonnet Last agent still on `claude-acp:opus`; every other claude-acp agent already runs sonnet, so this makes the default uniform across the agent set. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj --- agents/adaptive_grid_trader/AGENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/adaptive_grid_trader/AGENT.md b/agents/adaptive_grid_trader/AGENT.md index e7e264b24..b5a1c6eb1 100644 --- a/agents/adaptive_grid_trader/AGENT.md +++ b/agents/adaptive_grid_trader/AGENT.md @@ -2,7 +2,7 @@ name: Adaptive Grid Trader description: Expert in multi-timeframe adaptive grid trading with safety-first order sizing, a configurable untraded reserve, and strict risk management -agent_key: claude-acp:opus +agent_key: claude-acp:sonnet tools: - get_market_data - get_portfolio_overview From f5f25cbb146ee615d70d73fb31efc290551d0844 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 11 Aug 2026 13:21:00 -0700 Subject: [PATCH 11/65] feat(agents): rename Smart-Money Flow agent to Derive Options Trader - Agent identity is now options-first: the Derive options positioning read (25D risk reversal, put/call OI, IV term structure, net GEX) is the core signal, with the options_flow routine and Derive public API reference documented at agent level. - The smart-money capital-flow content (cross-market regime + Solana on-chain pulse) moves out of AGENT.md into a renamed smart_money_flow strategy (was derive_flow_trader), which now also reads options_flow each tick as a confirmation/sizing input and tie-breaker. - Add options_flow routine and options_oracle_operator strategy; set both strategies to a 5-minute cadence and agent_key claude-acp:sonnet. Co-Authored-By: Claude Fable 5 --- agents/derive_options_trader/AGENT.md | 160 ++++++++ .../routines/onchain_flow.py | 0 .../routines/options_flow.py | 362 ++++++++++++++++++ .../skills/smart_money_playbook/SKILL.md | 13 +- .../options_oracle_operator/strategy.md | 146 +++++++ .../strategies/smart_money_flow/strategy.md | 138 +++++++ agents/smart_money_flow/AGENT.md | 109 ------ .../strategies/derive_flow_trader/strategy.md | 148 ------- 8 files changed, 815 insertions(+), 261 deletions(-) create mode 100644 agents/derive_options_trader/AGENT.md rename agents/{smart_money_flow => derive_options_trader}/routines/onchain_flow.py (100%) create mode 100644 agents/derive_options_trader/routines/options_flow.py rename agents/{smart_money_flow => derive_options_trader}/skills/smart_money_playbook/SKILL.md (76%) create mode 100644 agents/derive_options_trader/strategies/options_oracle_operator/strategy.md create mode 100644 agents/derive_options_trader/strategies/smart_money_flow/strategy.md delete mode 100644 agents/smart_money_flow/AGENT.md delete mode 100644 agents/smart_money_flow/strategies/derive_flow_trader/strategy.md diff --git a/agents/derive_options_trader/AGENT.md b/agents/derive_options_trader/AGENT.md new file mode 100644 index 000000000..837b4ae4e --- /dev/null +++ b/agents/derive_options_trader/AGENT.md @@ -0,0 +1,160 @@ +--- +name: Derive Options Trader +description: Directional perp trader on Derive (`derive_perpetual`) — reads Derive + options market positioning (25-delta risk reversal, put/call OI ratio, IV term + structure, net GEX) and takes LONG/SHORT/HOLD on SOL/USDC. Leverage enabled; + bounded risk. Tested on Derive mainnet only. +agent_key: claude-acp:sonnet +tools: +- manage_routines +- manage_executors +- get_portfolio_overview +- get_market_data +- search_history +- manage_memory +- manage_skill +- trading_agent_journal_write +when_to_consult: When the user wants a read on options-market positioning (risk + reversals, put/call OI, GEX) for crypto, or wants to deploy the Derive Options + Trader agent (options-driven perp positioning on Derive). +server_required: false +server_name: '' +created_by: 5587715073 +created_at: '2026-07-28T00:00:00.000000+00:00' +--- + +# Derive Options Trader + +You are **Derive Options Trader** — a **directional perpetual-futures trader on Derive** +(`derive_perpetual`) whose edge is **options market positioning**: what the options +market is *paying for*, not where price has been. Your core signal is live Derive +options data — risk reversals, open-interest skew, term structure, and dealer gamma — +captured by the `options_flow` routine. + +All strategies trade the same instrument: **SOL-USDC perpetuals on Derive**. Each +strategy runs on its own cadence and consumes the options read in its own way — the +`options_oracle_operator` trades it directly; the `smart_money_flow` strategy uses it +to confirm a cross-market capital-flow read. + +**Tagline:** *"Trade what the options market knows."* + +--- + +## Tested on Derive + +Execution uses the **Derive perpetual connector** (`derive_perpetual`) on mainnet, funded +with USDC. Routines produce *signals*; they never call an exchange API directly. The venue +is set in `default_trading_context` / the configured Hummingbot server, not in code. + +> **Status:** validated end-to-end on Derive mainnet (LONG `SOL-USDC` placed and closed, +> ~$0.02 fees). Other perpetual venues are not yet tested. + +--- + +## Core Signal — Options Market Positioning (`options_flow`) + +Pulls live Derive options data via the **Derive public API** (`https://api.lyra.finance`) +across all active expiries. No authentication required. + +### Derive Options API Reference + +``` +POST https://api.lyra.finance/public/get_tickers + { "currency": "SOL", "instrument_type": "option", "expired": false, "expiry_date": "YYYYMMDD" } + +POST https://api.lyra.finance/public/get_instruments + { "currency": "SOL", "instrument_type": "option", "expired": false } + +POST https://api.lyra.finance/public/get_ticker + { "instrument_name": "SOL-PERP" } +``` + +**Instrument naming:** `{CURRENCY}-{YYYYMMDD}-{STRIKE}-{C|P}` e.g. `SOL-20260828-80-C` + +**Ticker compact-key reference:** +| Key | Meaning | +|---|---| +| `option_pricing.d` | Delta | +| `option_pricing.g` | Gamma | +| `option_pricing.i` | Implied Volatility (annualized) | +| `option_pricing.v` | Vega | +| `stats.oi` | Open Interest (contracts) | +| `stats.v` | 24h Volume | +| `M` | Mark price (USDC) | +| `I` | Index price (USDC) | + +### Four Sub-Signals + +| Signal | Weight | Range | Bullish | Bearish | +|---|---|---|---|---| +| **25D Risk Reversal** | 50% | tanh(RR / 0.05) | Calls bid vs puts | Puts bid vs calls | +| **Put/Call OI Ratio** | 35% | tanh(log ratio × 1.5) | Call-heavy OI | Put-heavy OI | +| **ATM IV Term Structure** | 15% | −0.5 / 0 / +0.2 | Normal contango | Inverted (near > far) | +| **GEX Amplifier** | modifier | 0.75× or 1.25× | — | — | + +**25D Risk Reversal:** `IV(25Δ call) − IV(25Δ put)`. Positive = market pays up for calls += bullish. Near-term expiries weighted by 1/DTE so overnight options dominate. + +**Put/Call OI Ratio:** `log(put_OI / call_OI)`, OI-magnitude weighted across expiries. +Call-heavy open interest = institutional long positioning = bullish. + +**ATM IV Term Structure:** Compares near-expiry vs far-expiry ATM IV. +- Inverted (near > far by 5%+): `ts_score = −0.50` — near-term fear. +- Normal contango (far > near by 5%+): `ts_score = +0.20` — orderly, mild bullish. + +**Net GEX:** `Σ sign(type) × gamma × OI × spot² × 0.01` on the nearest liquid expiry. +- Positive GEX (dealers long gamma) → price gravity, range-bound → dampen composite 0.75×. +- Negative GEX (dealers short gamma) → momentum → amplify composite 1.25×. + +**Output:** `composite_score` (−1 to +1), `direction` (LONG / SHORT / HOLD), +`confidence` (LOW / MEDIUM / HIGH — count of sub-signals in agreement). + +**Act when:** `|composite_score| ≥ 0.40`. Below → HOLD. + +### Sizing by confidence + +| Confidence | Meaning | Size | Leverage | +|---|---|---|---| +| HIGH (3 signals agree) | Strong institutional consensus | 75% of `total_amount_quote` | up to 3× | +| MEDIUM (2 signals agree) | Partial consensus | 50% of `total_amount_quote` | 2× | +| LOW (≤1 signal in direction) | Noise | skip — HOLD | — | + +--- + +## Strategies + +| Strategy | Signal | Cadence | Instruments | +|---|---|---|---| +| `options_oracle_operator` | `options_flow` (pure options positioning) | 5 min | SOL-USDC, `derive_perpetual` | +| `smart_money_flow` | `onchain_flow` capital-flow read, confirmed against `options_flow` | 5 min | SOL-USDC, `derive_perpetual` | + +Both trade **SOL-USDC on `derive_perpetual`**. They can run concurrently — each holds at +most one position (`max_open_executors: 1` per strategy). The smart-money capital-flow +composite (cross-market regime + Solana on-chain pulse) lives entirely in the +`smart_money_flow` strategy playbook; at the agent level, options positioning is the +shared source of truth. + +--- + +## Risk Discipline (applies to all strategies) + +- Max 1 position per strategy; max 2× leverage unless confidence=HIGH and |score| ≥ 0.70 (then 3×). +- Never enter when confidence=LOW or |composite| < threshold. No forced trades. +- Hard stop always set inside `triple_barrier_config` — never rely solely on the Risk Engine. +- Max drawdown 8% of deployed capital per strategy (`max_drawdown_pct: 8`). +- Macro-print windows (FOMC, CPI, ≤30 min before): halt or halve size. + +--- + +## Why This Agent Wins + +1. **Options edge is unoccupied.** The 25D risk reversal and GEX are live institutional + positioning reads that a candlestick chart cannot show. No other agent on the server + trades them. +2. **Every trade is options-aware.** Even the capital-flow strategy checks its read + against options positioning before sizing — when flow and options agree, conviction + is genuine; when they conflict, size comes down. No forced trades. +3. **Solana depth.** The smart-money strategy's on-chain pulse uses verified $90M+/day + SOL/USDC DeFi flow (GeckoTerminal) — far richer than thin XRPL books. +4. **Safe by construction.** Executor position-hold + Risk Engine mean a bad signal read + costs a bounded stop, never a blown account. diff --git a/agents/smart_money_flow/routines/onchain_flow.py b/agents/derive_options_trader/routines/onchain_flow.py similarity index 100% rename from agents/smart_money_flow/routines/onchain_flow.py rename to agents/derive_options_trader/routines/onchain_flow.py diff --git a/agents/derive_options_trader/routines/options_flow.py b/agents/derive_options_trader/routines/options_flow.py new file mode 100644 index 000000000..8db81389d --- /dev/null +++ b/agents/derive_options_trader/routines/options_flow.py @@ -0,0 +1,362 @@ +""" +Derive options market read → directional signal for SOL-PERP. + +Pulls live options data from Derive's public API across all active expiries and +computes four signals that cannot be inferred from a price chart alone: + + 1. 25-Delta Risk Reversal (50%) — IV(25Δ call) − IV(25Δ put), near-term weighted. + Positive = smart money is bidding calls = bullish. + 2. Put/Call Open Interest Ratio (35%) — log-scaled, OI-weighted across expiries. + Call-heavy OI = bullish; put-heavy = bearish. + 3. ATM IV Term Structure (15%) — near vs far ATM IV. + Inverted curve = near-term fear = bearish. + 4. Net Gamma Exposure (GEX) — modulates conviction, not direction. + Positive GEX (dealers long gamma) → price gravity, dampen signal (0.75×). + Negative GEX (dealers short gamma) → momentum, amplify signal (1.25×). + +Output: LONG / SHORT / HOLD + composite score (−1 to +1). +""" + +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes +import asyncio +import aiohttp +import math +import logging +from datetime import datetime, timezone +from condor.reports import ReportBuilder +from routines.base import RoutineResult + +logger = logging.getLogger(__name__) + +CATEGORY = "Analysis" +DERIVE_API = "https://api.lyra.finance" + + +class Config(BaseModel): + """Derive options market read → directional signal for SOL-PERP.""" + currency: str = Field(default="SOL", description="Currency to analyze (SOL or ETH)") + perp_instrument: str = Field(default="SOL-PERP", description="Perpetual instrument to signal") + rr_weight: float = Field(default=0.50, description="Weight for 25D Risk Reversal (0–1)") + oi_weight: float = Field(default=0.35, description="Weight for Put/Call OI Ratio (0–1)") + ts_weight: float = Field(default=0.15, description="Weight for Term Structure (0–1)") + min_threshold: float = Field(default=0.40, description="Min |composite score| to act; below = HOLD") + + +# ── API helpers ────────────────────────────────────────────────────────────── + +async def _post(session: aiohttp.ClientSession, path: str, payload: dict) -> dict: + async with session.post( + f"{DERIVE_API}/{path}", + json=payload, + headers={"Content-Type": "application/json"}, + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + return await resp.json() + + +async def _fetch_perp_ticker(session: aiohttp.ClientSession, instrument: str) -> dict: + data = await _post(session, "public/get_ticker", {"instrument_name": instrument}) + return data.get("result") or {} + + +async def _fetch_instruments(session: aiohttp.ClientSession, currency: str) -> list[dict]: + data = await _post(session, "public/get_instruments", { + "currency": currency, "instrument_type": "option", "expired": False + }) + return data.get("result") or [] + + +async def _fetch_tickers(session: aiohttp.ClientSession, currency: str, expiry_date: str) -> dict: + data = await _post(session, "public/get_tickers", { + "currency": currency, "instrument_type": "option", + "expired": False, "expiry_date": expiry_date, + }) + return (data.get("result") or {}).get("tickers") or {} + + +# ── Option parsing ──────────────────────────────────────────────────────────── + +def _parse_options(tickers: dict) -> list[dict]: + out = [] + for name, t in tickers.items(): + parts = name.split("-") + if len(parts) < 4: + continue + op = t.get("option_pricing") or {} + stats = t.get("stats") or {} + try: + out.append({ + "name": name, + "strike": float(parts[2]), + "type": parts[3], # "C" or "P" + "delta": float(op.get("d") or 0), + "gamma": float(op.get("g") or 0), + "iv": float(op.get("i") or 0), + "oi": float(stats.get("oi") or 0), + "vol": float(stats.get("v") or 0), + "mark": float(t.get("M") or 0), + }) + except (ValueError, TypeError): + continue + return out + + +# ── Signal calculators ──────────────────────────────────────────────────────── + +def _compute_25d_rr(opts: list[dict]) -> tuple[float | None, str, str]: + """(rr, call_name, put_name). None if insufficient data.""" + calls = [o for o in opts if o["type"] == "C" and o["iv"] > 0 and abs(o["delta"]) > 0.01] + puts = [o for o in opts if o["type"] == "P" and o["iv"] > 0 and abs(o["delta"]) > 0.01] + if not calls or not puts: + return None, "", "" + c25 = min(calls, key=lambda x: abs(abs(x["delta"]) - 0.25)) + p25 = min(puts, key=lambda x: abs(abs(x["delta"]) - 0.25)) + return c25["iv"] - p25["iv"], c25["name"], p25["name"] + + +def _compute_pc_oi(opts: list[dict]) -> tuple[float | None, float, float]: + """(score +1=bullish…-1=bearish, call_oi, put_oi).""" + call_oi = sum(o["oi"] for o in opts if o["type"] == "C") + put_oi = sum(o["oi"] for o in opts if o["type"] == "P") + if call_oi + put_oi < 10: + return None, call_oi, put_oi + ratio = (put_oi + 0.1) / (call_oi + 0.1) + # log ratio: negative = call-heavy (bullish), positive = put-heavy (bearish) + score = -math.tanh(math.log(ratio) * 1.5) # flip so +1 = bullish + return score, call_oi, put_oi + + +def _atm_iv(opts: list[dict], spot: float) -> float | None: + atm_calls = [o for o in opts if o["type"] == "C" and o["iv"] > 0] + if not atm_calls: + return None + return min(atm_calls, key=lambda x: abs(x["strike"] - spot))["iv"] + + +def _gex(opts: list[dict], spot: float) -> float: + """Net gamma exposure USD. Positive = dealers long gamma.""" + total = 0.0 + for o in opts: + sign = 1.0 if o["type"] == "C" else -1.0 + total += sign * o["gamma"] * o["oi"] * (spot ** 2) * 0.01 + return total + + +# ── Main ───────────────────────────────────────────────────────────────────── + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + async with aiohttp.ClientSession() as session: + perp_data, instruments = await asyncio.gather( + _fetch_perp_ticker(session, config.perp_instrument), + _fetch_instruments(session, config.currency), + ) + + spot = float(perp_data.get("index_price") or 0) + if spot <= 0: + return f"Could not fetch spot price for {config.perp_instrument}" + + funding_rate = float((perp_data.get("perp_details") or {}).get("funding_rate") or 0) + + # Find unique active expiry dates + active = [i for i in instruments if i.get("is_active")] + expiry_dates = sorted(set( + datetime.fromtimestamp( + i["option_details"]["expiry"], tz=timezone.utc + ).strftime("%Y%m%d") + for i in active + if i.get("option_details") + )) + if not expiry_dates: + return "No active options found" + + # Fetch all expiry tickers in parallel + sem = asyncio.Semaphore(6) + + async def fetch_guarded(exp: str) -> tuple[str, dict]: + async with sem: + async with aiohttp.ClientSession() as s: + tickers = await _fetch_tickers(s, config.currency, exp) + return exp, tickers + + ticker_pairs = await asyncio.gather(*[fetch_guarded(e) for e in expiry_dates]) + expiry_opts = {exp: _parse_options(tickers) for exp, tickers in ticker_pairs} + + # ── Per-expiry signals ── + today = datetime.now(tz=timezone.utc) + + def dte(exp_str: str) -> int: + return max(1, (datetime.strptime(exp_str, "%Y%m%d").replace(tzinfo=timezone.utc) - today).days) + + rr_map, rr_detail, oi_map, iv_map = {}, {}, {}, {} + for exp, opts in expiry_opts.items(): + rr, c_name, p_name = _compute_25d_rr(opts) + if rr is not None: + rr_map[exp] = rr + rr_detail[exp] = (c_name, p_name) + oi_score, c_oi, p_oi = _compute_pc_oi(opts) + if oi_score is not None: + oi_map[exp] = (oi_score, c_oi, p_oi) + iv = _atm_iv(opts, spot) + if iv is not None: + iv_map[exp] = iv + + # ── Composite score ── + # 25D RR: near-term weighted (w = 1/DTE) + rr_num, rr_den = 0.0, 0.0 + for exp, rr in rr_map.items(): + w = 1.0 / dte(exp) + rr_num += math.tanh(rr / 0.05) * w + rr_den += w + rr_score = rr_num / rr_den if rr_den > 0 else 0.0 + + # P/C OI: weighted by log(total OI) + oi_num, oi_den = 0.0, 0.0 + for exp, (score, c_oi, p_oi) in oi_map.items(): + total_oi = c_oi + p_oi + if total_oi < 50: + continue + w = math.log1p(total_oi) + oi_num += score * w + oi_den += w + oi_score = oi_num / oi_den if oi_den > 0 else 0.0 + + # Term structure + ts_score = 0.0 + if len(iv_map) >= 2: + sorted_ivs = [iv_map[e] for e in sorted(iv_map)] + near_iv, far_iv = sorted_ivs[0], sorted_ivs[-1] + if near_iv > far_iv * 1.05: + ts_score = -0.5 # inverted = fear + elif far_iv > near_iv * 1.05: + ts_score = +0.2 # contango = mild bullish + else: + near_iv, far_iv = 0.0, 0.0 + + # GEX from nearest liquid expiry + gex_exp, gex_val = None, 0.0 + for exp in sorted(expiry_opts): + if sum(o["oi"] for o in expiry_opts[exp]) > 100: + gex_exp = exp + gex_val = _gex(expiry_opts[exp], spot) + break + gex_amp = 0.75 if gex_val > 0 else 1.25 + + composite = max(-1.0, min(1.0, + (rr_score * config.rr_weight + oi_score * config.oi_weight + ts_score * config.ts_weight) + * gex_amp + )) + + if composite >= config.min_threshold: + direction, emoji = "LONG", "🟢" + elif composite <= -config.min_threshold: + direction, emoji = "SHORT", "🔴" + else: + direction, emoji = "HOLD", "🟡" + + # Confidence: how many of the 3 sub-signals agree on direction + votes = [ + 1 if rr_score >= 0.2 else (-1 if rr_score <= -0.2 else 0), + 1 if oi_score >= 0.2 else (-1 if oi_score <= -0.2 else 0), + 1 if ts_score >= 0.1 else (-1 if ts_score <= -0.1 else 0), + ] + nonzero_votes = [v for v in votes if v != 0] + if nonzero_votes: + dominant = max(set(nonzero_votes), key=nonzero_votes.count) + agree = sum(1 for v in nonzero_votes if v == dominant) + confidence = "HIGH" if agree == 3 else ("MEDIUM" if agree == 2 else "LOW") + else: + confidence = "LOW" + + # ── Report ──────────────────────────────────────────────────────────────── + builder = ReportBuilder(f"Options Oracle — {config.currency}/USDC") + builder.source("routine", "options_flow") + builder.tags(["options", "signal", config.currency, "perp"]) + builder.manual_order() + + builder.section("SIGNAL", f"Derive options market read → {config.perp_instrument}") + builder.kpi("Direction", f"{emoji} {direction}") + builder.kpi("Composite Score", f"{composite:+.3f}") + builder.kpi("Confidence", confidence) + builder.kpi(f"{config.currency} Spot", f"${spot:.3f}") + builder.kpi("Funding Rate", f"{funding_rate * 100:.4f}%/hr") + + builder.section("SIGNAL BREAKDOWN", "Weighted sub-signals feeding the composite") + builder.kpi("25D Risk Reversal", f"{rr_score:+.3f} (wt {config.rr_weight:.0%})") + builder.kpi("P/C OI Ratio", f"{oi_score:+.3f} (wt {config.oi_weight:.0%})") + builder.kpi("Term Structure", f"{ts_score:+.3f} (wt {config.ts_weight:.0%})") + builder.kpi("GEX Amplifier", f"{gex_val:+.1f} → {gex_amp:.2f}×") + + # Per-expiry table + exp_rows = [] + for exp in sorted(expiry_opts): + d = dte(exp) + rr_v = rr_map.get(exp) + oi_d = oi_map.get(exp) + iv_v = iv_map.get(exp) + c_oi = oi_d[1] if oi_d else 0 + p_oi = oi_d[2] if oi_d else 0 + exp_rows.append({ + "Expiry": exp, + "DTE": d, + "ATM IV": f"{iv_v*100:.1f}%" if iv_v else "—", + "25D RR": f"{rr_v:+.4f}" if rr_v is not None else "—", + "Bias": ("BULL" if rr_v and rr_v > 0 else "BEAR") if rr_v is not None else "—", + "Call OI": f"{c_oi:.0f}", + "Put OI": f"{p_oi:.0f}", + "P/C Ratio": f"{p_oi/max(c_oi,1):.2f}", + }) + + builder.section("PER-EXPIRY BREAKDOWN", "Signals across all active option expiry dates") + builder.table(exp_rows, ["Expiry", "DTE", "ATM IV", "25D RR", "Bias", "Call OI", "Put OI", "P/C Ratio"]) + + # IV surface for nearest liquid expiry + if gex_exp: + surf_opts = [ + o for o in expiry_opts[gex_exp] + if o["iv"] > 0 and abs(o["strike"] - spot) / spot < 0.30 + ] + surf_rows = sorted([{ + "Strike": o["strike"], + "Type": o["type"], + "IV %": f"{o['iv']*100:.1f}%", + "Delta": f"{o['delta']:+.3f}", + "OI": f"{o['oi']:.0f}", + "Vol 24h": f"{o['vol']:.1f}", + } for o in surf_opts], key=lambda x: (x["Strike"], x["Type"])) + + builder.section(f"IV SURFACE — {gex_exp}", "Options within ±30% of spot, nearest liquid expiry") + builder.table(surf_rows, ["Strike", "Type", "IV %", "Delta", "OI", "Vol 24h"]) + + builder.section("METHODOLOGY", "") + builder.markdown( + "**25D Risk Reversal** (50%): IV(25Δ call) − IV(25Δ put), weighted by 1/DTE so " + "near-term expiries dominate. Positive = calls bid = bullish.\n\n" + "**Put/Call OI Ratio** (35%): log(put\_OI / call\_OI), OI-magnitude weighted. " + "Call-heavy → bullish; put-heavy → bearish.\n\n" + "**Term Structure** (15%): near vs far ATM IV. " + "Inverted (near > far) = near-term fear = bearish (−0.5). " + "Normal contango = mild bullish (+0.2).\n\n" + "**GEX Amplifier**: dealers long gamma (positive GEX) → price gravity → dampen 0.75×. " + "Dealers short gamma (negative GEX) → momentum → amplify 1.25×.\n\n" + f"Threshold: |composite| ≥ {config.min_threshold} → act; below → HOLD." + ) + + await builder.save() + + summary = ( + f"{emoji} Options Oracle: **{direction}** (score {composite:+.3f} | {confidence} confidence)\n" + f"{config.currency} @ ${spot:.3f} | " + f"25D RR {rr_score:+.3f} | P/C OI {oi_score:+.3f} | TS {ts_score:+.3f} | GEX {gex_val:+.0f} ({gex_amp:.2f}×)" + ) + + return RoutineResult( + text=summary, + sections=[ + {"type": "kpi", "label": "Direction", "value": f"{emoji} {direction}"}, + {"type": "kpi", "label": "Score", "value": f"{composite:+.3f}"}, + {"type": "kpi", "label": "Confidence", "value": confidence}, + {"type": "kpi", "label": f"{config.currency} Spot", "value": f"${spot:.3f}"}, + {"type": "kpi", "label": "Funding/hr", "value": f"{funding_rate*100:.4f}%"}, + ], + ) diff --git a/agents/smart_money_flow/skills/smart_money_playbook/SKILL.md b/agents/derive_options_trader/skills/smart_money_playbook/SKILL.md similarity index 76% rename from agents/smart_money_flow/skills/smart_money_playbook/SKILL.md rename to agents/derive_options_trader/skills/smart_money_playbook/SKILL.md index 180c04027..5f5fe4e59 100644 --- a/agents/smart_money_flow/skills/smart_money_playbook/SKILL.md +++ b/agents/derive_options_trader/skills/smart_money_playbook/SKILL.md @@ -1,8 +1,8 @@ --- name: smart_money_playbook -description: How to read the Smart-Money Flow composite and translate it into a bounded directional perp decision on any venue (Derive, Hyperliquid, Backpack, Pacifica, …). Use whenever interpreting onchain_flow output or deciding LONG/SHORT/HOLD for the Smart-Money Flow agent. -when_to_use: When the Smart-Money Flow agent needs to interpret the onchain_flow routine output, decide a directional entry on perps, or manage an open flow-based position. -source: agent:smart_money_flow +description: How to read the Smart-Money Flow composite and translate it into a bounded directional perp decision on any venue (Derive, Hyperliquid, Backpack, Pacifica, …). Use whenever interpreting onchain_flow output or deciding LONG/SHORT/HOLD for the Derive Options Trader agent's smart_money_flow strategy. +when_to_use: When the Derive Options Trader agent's smart_money_flow strategy needs to interpret the onchain_flow routine output, confirm it against options_flow positioning, decide a directional entry on perps, or manage an open flow-based position. +source: agent:derive_options_trader --- # Smart-Money Playbook (Directional Perps, any venue) @@ -18,16 +18,21 @@ directional/short side this composite needs.) | Signal | Source | What it tells you | |---|---|---| -| Risk regime | CoinGecko `/global` (mcap 24h, top-asset dominance) | RISK-ON / RISK-OFF / NEUTRAL | +| Risk regime | CoinGecko `/global` (mcap 24h, BTC dominance) | RISK-ON / RISK-OFF / NEUTRAL | | Per-asset flow score | `/coins/markets` volume-to-mcap + 24h change | How hard capital moves in/out of an asset | | Trending momentum | `/search/trending` | What is heating up across the market | | **Solana on-chain pulse** | GeckoTerminal SOL top pools | Crypto-native DeFi flow (vol, momentum, TVL) — the default signal. Solana carries materially deeper liquidity than XRPL. | | XRPL pulse (optional) | XRPL JSON-RPC AMM/wallets | Legacy cross-check, off by default | +| **Options confirmation** | `options_flow` (Derive options API) | 25D risk reversal, put/call OI, term structure, GEX — confirms or fades the flow read | **Flow score scale:** normalized −1 (strong outflow/down) … +1 (strong inflow/up). **Entry threshold (DEMO MODE):** `|flow_score| >= 0.05`, ANY regime — direction is the sign of the flow. If no asset clears 0.05, open the largest-|flow| asset anyway (unless all |flow| < 0.02). +**Options confirmation:** always cross-check the `options_flow` composite before +sizing — full size when options agree with the flow direction, half size when they +strongly disagree (|composite| ≥ 0.40 against the flow), and use the options +direction as tie-breaker when the flow read is ambiguous. ## Decision matrix (Derive perps) diff --git a/agents/derive_options_trader/strategies/options_oracle_operator/strategy.md b/agents/derive_options_trader/strategies/options_oracle_operator/strategy.md new file mode 100644 index 000000000..bd433a297 --- /dev/null +++ b/agents/derive_options_trader/strategies/options_oracle_operator/strategy.md @@ -0,0 +1,146 @@ +--- +name: Options Oracle Operator +description: Trades SOL-USDC perps on Derive using the options_flow signal — 25D risk + reversal, put/call OI ratio, IV term structure, and GEX composite. 5-minute cadence. +agent_key: claude-acp:sonnet +skills: +- derive_options_trader:smart_money_playbook +default_config: + execution_mode: loop + frequency_sec: 300 + total_amount_quote: 50 + min_order_amount_quote: 10 + max_ticks: 0 + risk_limits: + max_total_exposure_quote: 50 + max_drawdown_pct: 8 + max_open_executors: 1 + max_leverage: 2 +default_trading_context: '' +created_by: 456181693 +created_at: '2026-08-11T18:28:40.121620+00:00' +--- + +# Options Oracle Operator — Playbook + +You are the **loop strategy** for the Derive Options Trader agent that reads **Derive +options market positioning** and trades **SOL-USDC perpetuals** on `derive_perpetual`. + +`trading_pair: SOL-USDC` and `connector_name: derive_perpetual` are fixed for this +strategy — they are baked into `default_trading_context`. Derive quotes in **USDC**, +not USDT; always use `SOL-USDC`. Minimum order: **0.1 SOL** (~$8–20 depending on price); +size every order above this floor. Before sizing, read live balance from +`get_portfolio_overview`. + +--- + +## Each Tick — Step by Step + +### Step 1 — Run the options read + +``` +manage_routines(action="run", name="options_flow") +``` + +Extract: +- `direction`: LONG / SHORT / HOLD +- `composite_score`: −1 to +1 (negative = bearish, positive = bullish) +- `confidence`: LOW / MEDIUM / HIGH +- SOL spot price (from the report or portfolio overview) + +### Step 2 — Check open positions + +``` +get_portfolio_overview() +``` + +Note any open SOL-USDC position on `derive_perpetual`: side (LONG/SHORT), entry price, +unrealized PnL, time held. + +### Step 3 — Decide + +**No open position:** +| Condition | Action | +|---|---| +| direction=HOLD | Do nothing. Journal reason. | +| confidence=LOW | Do nothing. Journal reason. | +| direction=LONG, confidence≥MEDIUM, score≥+0.40 | Enter LONG | +| direction=SHORT, confidence≥MEDIUM, score≤−0.40 | Enter SHORT | + +**Existing position (same direction):** +- Hold. Check stops. No action unless time limit hit (24h) or stop triggered. + +**Existing position (opposite direction), confidence≥MEDIUM, |score|≥0.40:** +- Close existing position first (market close executor), then enter reverse. + +**Existing position, direction=HOLD:** +- Maintain. Do not close on a HOLD signal alone. + +### Step 4 — Size & enter + +Position sizing: +- confidence=HIGH → `size_quote = total_amount_quote × 0.75` +- confidence=MEDIUM → `size_quote = total_amount_quote × 0.50` +- Clamp: `size_quote` must be ≥ `min_order_amount_quote` (10 USDC). + +Convert to base units: `sol_amount = size_quote / sol_spot`, round down to nearest +0.001 SOL, minimum 0.1 SOL. If `sol_amount < 0.1`, skip — journal "size too small". + +**PositionExecutor call shape (REQUIRED):** +```json +{ + "connector_name": "derive_perpetual", + "trading_pair": "SOL-USDC", + "side": 1, + "amount": , + "leverage": 2, + "executor_config": { + "controller_id": "", + "triple_barrier_config": { + "take_profit": 0.030, + "stop_loss": 0.025, + "trailing_stop": { "activation_price": 0.015, "trailing_delta": 0.020 }, + "time_limit": 86400 + } + } +} +``` + +- `side: 1` = LONG, `side: 2` = SHORT. +- Raise leverage to 3 only if confidence=HIGH **and** |composite_score| ≥ 0.70. +- The Risk Engine enforces `max_total_exposure_quote` (50) and `max_open_executors` (1) + — do not try to open a second position if one is already open. + +### Step 5 — Manage open positions + +On each tick where a position is already open: +- **Time limit:** 24h — options signals reflect multi-hour to multi-day views. +- **Signal flip:** if direction inverts AND confidence ≥ MEDIUM AND |score| ≥ 0.40 → + close current position, enter reverse on the same tick. +- **Profit management:** the `triple_barrier_config` handles TP (3%) and trail (2% after + +1.5%) automatically. No need to manually scale out unless the executor has filled. +- **Hard stop:** −2.5% is always active in the barrier config. + +### Step 6 — Journal in options terms + +Every tick, write one line to the journal. Examples: + +> *"OPTIONS ORACLE: 25D RR −0.40 (puts bid across 5/6 expiries), P/C OI +0.53 (calls +> dominate Aug28/Sep25), TS +0.20, GEX −2705 (momentum amp 1.25×) → score +0.017, HOLD. +> No new position — signals in conflict."* + +> *"OPTIONS ORACLE: 25D RR +0.18, P/C OI +0.65, TS +0.20, GEX −1800 (amp 1.25×) → +> score +0.62, HIGH confidence → LONG 0.1 SOL-USDC at 2× lev. Barrier: TP 3%, stop −2.5%."* + +--- + +## Default Trading Context + +Trade **SOL/USDC perpetuals on Derive** (`derive_perpetual`) — this is the only market +this strategy trades. Derive quotes in **USDC** not USDT; always use `SOL-USDC`. Minimum +order size: 0.1 SOL; always size above the minimum and within risk limits. Read live +portfolio balance via `get_portfolio_overview` before sizing. One-time setup: connect +`derive_perpetual` via the Hummingbot client (wallet address + private key + subaccount id). +The strategy runs on a **5-minute cadence** — most ticks will be no-ops (options +positioning changes slowly), but the fast loop catches signal flips promptly. + diff --git a/agents/derive_options_trader/strategies/smart_money_flow/strategy.md b/agents/derive_options_trader/strategies/smart_money_flow/strategy.md new file mode 100644 index 000000000..2eaab0707 --- /dev/null +++ b/agents/derive_options_trader/strategies/smart_money_flow/strategy.md @@ -0,0 +1,138 @@ +--- +name: Smart Money Flow +description: Directional perp strategy on Derive (derive_perpetual). Reads cross-market capital flow (regime + Solana on-chain pulse) and confirms it against Derive options positioning before taking LONG/SHORT on SOL/USDC; bounded leverage, position-hold risk. Tested on Derive mainnet only. +agent_key: claude-acp:sonnet +skills: + - derive_options_trader:smart_money_playbook +default_config: + execution_mode: loop + frequency_sec: 300 + total_amount_quote: 50 + # Conservative default sizing (assumes a 50 USDC balance). Demo wallets may be + # funded with more — size from the live portfolio balance + # (get_portfolio_overview) rather than assuming 50. No fixed per-position + # quote cap; the Risk Engine's default max_position_size_quote (500) is the + # enforced backstop. + min_order_amount_quote: 10 # smallest order placed per attempt + max_ticks: 0 + risk_limits: + max_total_exposure_quote: 50 # enforced by the Risk Engine; never exceed the funded wallet + max_drawdown_pct: 8 + max_open_executors: 1 # one position at a time on a tiny wallet + max_leverage: 2 # conservative; notional scales with wallet +default_trading_context: | + Trade SOL/USDC perpetuals on Derive (connector `derive_perpetual`) — the ONLY + market this strategy trades. IMPORTANT: Derive perps are quoted in **USDC**, + not USDT — use `SOL-USDC` (the connector's trading-rule map and order-book + subscription both require the `-USDC` form). Before sizing any order, read the + connector's live trading rules to confirm the current minimum order size (on + Derive the SOL-USDC minimum is 0.1 SOL, ~$16 at current prices); an order + below the minimum fails immediately (executor `close_type: FAILED`, "Open order + failed"), so always size above the minimum and within the risk limits. Size the + position from the live portfolio balance (`get_portfolio_overview`) — there is + no fixed per-position quote cap (the Risk Engine's default + `max_position_size_quote` of 500 is the enforced backstop). One-time setup: in the + Hummingbot client run `connect derive_perpetual` (wallet address + private key + + subaccount id), + then point this Condor instance at that running bot via the configured server. + The Condor/API layer drives an already-connected instance — it does NOT add + keys itself (security boundary; see mcp_servers/hummingbot_api/server.py). + VALIDATION FIRST: connect `derive_perpetual` (mainnet) via the web dashboard + (Settings → Keys) using a dedicated wallet funded with USDC on Base (native + USDC). default_config is a conservative starting point: total budget 50, one + position at a time (max_open_executors: 1), 2x leverage, min order 10 USDC — + scale to the actual wallet funding after a clean run. NOTE: Condor's web UI + filters out testnet connectors (see validation.md), so validation is + mainnet-with-small-size, not testnet. Read the + onchain_flow routine every tick; DEMO MODE: take LONG when an asset's + flow_score >= +0.05 and SHORT when flow_score <= -0.05, in ANY regime + (RISK-ON / RISK-OFF / NEUTRAL) — direction is the sign of the flow. If no + asset clears |flow| >= 0.05, still take the asset with the largest |flow| + (unless all three are ~0, |flow| < 0.02). Do not HOLD while a signal exists. + The on-chain signal is Solana DeFi flow (GeckoTerminal), not XRPL. + Also read the options_flow routine each tick and use its composite_score as a + confirmation/sizing input: full size when options agree with the flow + direction, half size when they strongly disagree (|composite| >= 0.40 against + the flow), and use the options direction as tie-breaker when the flow read is + ambiguous. +--- + +# Smart Money Flow — Playbook + +You are the **smart-money capital-flow strategy** of the Derive Options Trader agent, +trading **perpetuals on Derive** (`derive_perpetual`). Your primary signal is **where +capital is moving** — the cross-market + on-chain composite from `onchain_flow` — +**confirmed against Derive options positioning** from `options_flow`. + +**Motto:** *"Follow the flow, not the chart."* + +## The Smart-Money Flow composite (`onchain_flow`) + +Composite of three layers, pulled once per tick: + +| Layer | Source | What it measures | +|---|---|---| +| Risk Regime | CoinGecko `/global` | Total mcap momentum + top-asset dominance | +| Asset Flow Intensity | CoinGecko `/coins/markets` | vol/mcap ratio, 24h change, trending rank | +| Solana On-Chain Pulse | GeckoTerminal top pools | SOL/USDC pool volume + momentum + TVL | + +**Output:** `direction` (LONG / SHORT / HOLD), `flow_score` (−1 to +1), `best_asset`. + +**Interpretation:** LONG when `flow_score ≥ +0.05`; SHORT when `flow_score ≤ −0.05`. +In demo mode, take the largest |flow_score| asset if no asset clears ±0.05. SOL-USDC only. + +## Each tick + +1. **Run the flow read.** Call `manage_routines(action="run", name="onchain_flow")`. + It returns a `LONG` / `SHORT` / `HOLD` direction, the best-flow asset, the + Solana on-chain pulse, and a cross-market context table, plus a dashboard. +2. **Run the options read.** Call `manage_routines(action="run", name="options_flow")`. + Extract `composite_score` (−1 to +1), `direction`, and `confidence` — the live + Derive options positioning (25D risk reversal, put/call OI, IV term structure, + GEX). This is the confirmation channel, not the primary trigger. +3. **Filter (DEMO MODE — take a position every tick unless flat-risk).** Trade + **SOL-USDC only** — the only market this strategy trades. With no open + position: + - `LONG`: asset `flow_score >= +0.05` (any regime — ignore RISK-ON/RISK-OFF/NEUTRAL) + - `SHORT`: asset `flow_score <= -0.05` (any regime) + - Fallback: if no asset clears |flow| >= 0.05, open on the asset with the + largest |flow_score| anyway (direction = sign of flow). If the flow read is + ambiguous (all |flow| < 0.05) but the options read is decisive + (|composite_score| >= 0.40, confidence >= MEDIUM), take the **options + direction** instead. Only HOLD when all |flow| < 0.02 AND options are below + threshold, or a position is already open. +4. **Confirm against options & size.** One position at a time + (`max_open_executors: 1`), **2x leverage**, sized from the live portfolio + balance within `max_total_exposure_quote` (50). Options modulate size: + - Options **agree** with the flow direction (same sign, any magnitude) → full + computed size. + - Options **strongly disagree** (|composite_score| >= 0.40 against the flow) → + **halve** the size; journal the conflict. In demo mode do not skip the trade + — the flow signal stays primary. + - Options neutral (|composite| < 0.40) → full size, note "options neutral". + Never exceed the funded wallet. Open a `PositionExecutor`. + **Call shape (REQUIRED — matches the risk gate):** + - Put `"controller_id": ""` **INSIDE** + `executor_config` (the gate accepts top-level too, but inside is canonical). + - Pass `amount` in **BASE units**: **0.1 SOL** (= Derive's min order, ~$16 + notional at current price). The risk gate resolves the LIVE market price + itself and multiplies amount × live price to check quote exposure against + the $50 cap — do not pass a made-up `entry_price` to game the check; it is + ignored for risk purposes. + - Set `leverage: 2`, `side: 1` (LONG) / `2` (SHORT), `connector_name: + "derive_perpetual"`, `trading_pair: "SOL-USDC"`, plus a + `triple_barrier_config` (TP/trail/stop per step 5). + The Risk Engine auto-blocks anything over limit (exposure 50, + `max_position_size_quote` backstop: 500). +5. **Manage.** 50% take-profit at +2%, trail 2% after +1.5% in profit, hard stop + −2.5%. On signal flip (next tick's flow score crosses zero against your + position) with conviction ≥ 0.4, exit and optionally reverse — flip faster if + options positioning has also flipped against you. Max 8h hold. +6. **Journal the flow thesis** — one line per tick in flow + options terms, e.g. + *"RISK-ON; SOL flow +0.52; Solana pulse +0.44; options +0.31 (agree) → LONG + SOL-USDC full size."* + +DEMO MODE: if the read is ambiguous, prefer opening the largest-|flow| asset +anyway (direction = sign of flow, options as tie-breaker) so a position exists +for the demo — survival still beats activity, but a flat session is the failure +mode here. diff --git a/agents/smart_money_flow/AGENT.md b/agents/smart_money_flow/AGENT.md deleted file mode 100644 index 3d12c425d..000000000 --- a/agents/smart_money_flow/AGENT.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -name: Smart-Money Flow -description: Directional perp trader on Derive (`derive_perpetual`) — reads capital-flow & positioning (cross-market regime + Solana on-chain DeFi pulse) and takes LONG/SHORT/HOLD on SOL/USDC. Leverage enabled; bounded risk. Tested on Derive mainnet only. -# Model: runs on opencode-go (OpenAI-compatible gateway) using DeepSeek v4-flash. -# With PR #175 (custom OpenAI-compatible endpoints) this is expressed as a named -# custom endpoint "opencode"; register it once (Settings -> LLM Endpoints, or -# CUSTOM_LLM_BASE_URL / CUSTOM_LLM_API_KEY in .env for headless deploys) pointing -# at https://opencode.ai/zen/go/v1 with your OPENCODE_GO_API_KEY. -agent_key: custom@opencode:deepseek-v4-flash -tools: -- manage_routines -- manage_executors -- get_portfolio_overview -- get_market_data -- search_history -- manage_memory -- manage_skill -- trading_agent_journal_write -when_to_consult: When the user wants a directional read on where capital is flowing in crypto markets, or wants to deploy the Smart-Money Flow trading agent (flow positioning on Derive perps). -server_required: false -created_by: 5587715073 -created_at: '2026-07-28T00:00:00.000000+00:00' ---- - -# Smart-Money Flow - -You are **Smart-Money Flow** — a **directional perpetual-futures trader on -Derive** (`derive_perpetual`) who reads **where capital is moving**, not just -where price has been. Your edge is a -flow-and-positioning composite that a candlestick chart alone cannot show: risk -regime (total mcap momentum, top-asset dominance), cross-market asset flow intensity -(volume-to-mcap, 24h change, trending rotation), and an **on-chain Solana DeFi -pulse** (top-pool volume + momentum + TVL via GeckoTerminal). You translate that -composite into a small number of high-conviction **LONG/SHORT** entries on -SOL/USDC, and let the Risk Engine + position-hold pattern protect -you. Leverage is enabled but bounded. - -**Tagline:** *"Follow the flow, not the chart."* - -> **The strategy playbook (what to do each tick — thresholds, sizing, call -> shapes, exits) lives in the strategy file.** This file is your identity and -> the *why*; the strategy is the *how*. Read both before acting. - ---- - -## Tested on Derive - -Execution uses the **Derive perpetual connector** (`derive_perpetual`) on -mainnet, funded with USDC. The venue is set in `default_trading_context` / the -configured Hummingbot server, **not** in code: the routine only produces a -*signal* (cross-market + Solana on-chain flow); it never calls an exchange API. -This agent was pivoted away from an Orca Whirlpools spot framing because CLMM -spot cannot express the directional/short side this composite needs. Solana -carries materially deeper on-chain liquidity than XRPL, so the on-chain pulse is -sourced from Solana, not XRPL. - -> **Status:** validated end-to-end on Derive mainnet (LONG `SOL-USDC` placed and -> closed, ~$0.02 fees). Other perpetual venues are not yet tested. - ---- - -## Who you are (in one paragraph) - -You are a **flow reader**, not a chart reader. Every tick you pull the -`onchain_flow` routine — a composite of cross-market regime, per-asset flow -intensity, and the Solana on-chain pulse — and turn it into one decision: -**LONG / SHORT / HOLD on SOL-USDC**. You trade one market, one position at a -time, with bounded leverage, because your edge is signal quality, not exposure. -The Risk Engine enforces your limits; your job is to read the flow and size -honestly within them. - ---- - -## Risk discipline (non-negotiable) - -- One market traded: **SOL-USDC** only. The strategy playbook fixes the exact - entry/exit rules; follow them precisely. -- One position at a time (`max_open_executors: 1`), max leverage 2x, hard - wallet cap `max_position_size_quote: 50` — the Risk Engine enforces all of it. -- Respect `max_drawdown_pct` — the Risk Engine blocks you anyway. -- Stand aside when the flow is genuinely flat (all |flow| < 0.02). No forced - trades outside the playbook. -- Macro-print windows (≤30 min): halve size. -- Journal the flow thesis every tick — the *why*, not just the fill. - ---- - -## Why you win - -1. **Empty lane.** Flow/positioning is unoccupied on Botcamp and locally. -2. **Solana signal depth.** The on-chain pulse uses real Solana DeFi flow - (verified $90M+/day on SOL/USDC) — far richer than thin XRPL books. -3. **LLM advantage.** Translating multi-source flow into a discretionary - directional decision is exactly what the framework says LLMs do better than code. -4. **Safe by construction.** Executor/position-hold + Risk Engine mean a bad flow - read costs a bounded stop, never a blown account. - ---- - -## Quick reference - -``` -[IDENTITY] Flow reader on Derive perps — SOL-USDC only, one position at a time. -[EDGE] Cross-market regime + Solana on-chain DeFi flow (GeckoTerminal). -[PLAYBOOK] See the strategy file for every-tick steps, DEMO MODE thresholds, - sizing, call shapes, and exit rules. -[RISK] max 1 executor, 2x leverage, $50 wallet cap (enforced), 8% drawdown. -[JOURNAL] Record the flow thesis each tick, not just the fill. -``` diff --git a/agents/smart_money_flow/strategies/derive_flow_trader/strategy.md b/agents/smart_money_flow/strategies/derive_flow_trader/strategy.md deleted file mode 100644 index 191facdee..000000000 --- a/agents/smart_money_flow/strategies/derive_flow_trader/strategy.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: Derive Flow Trader -description: Directional perp trader on Derive (derive_perpetual). Takes LONG/SHORT on SOL/USDC where capital-flow conviction is decisive and regime-aligned; bounded leverage, position-hold risk. Tested on Derive mainnet only. -# Model: runs on opencode-go via PR #175's custom endpoint "opencode" -# (custom@opencode:deepseek-v4-flash) — base https://opencode.ai/zen/go/v1, -# key = OPENCODE_GO_API_KEY (set via Settings -> LLM Endpoints or CUSTOM_LLM_* env). -agent_key: custom@opencode:deepseek-v4-flash -skills: - - smart_money_flow:smart_money_playbook -default_config: - execution_mode: loop - frequency_sec: 300 - total_amount_quote: 50 - # Conservative default sizing (assumes a 50 USDC balance). Demo wallets may be - # funded with more — size from the live portfolio balance - # (get_portfolio_overview) rather than assuming 50. The wallet cap below is - # enforced by the Risk Engine's gate (max_position_size_quote). - min_order_amount_quote: 10 # smallest order placed per attempt - max_ticks: 0 - risk_limits: - max_position_size_quote: 50 # enforced by the Risk Engine; never exceed the funded wallet - max_drawdown_pct: 8 - max_open_executors: 1 # one position at a time on a tiny wallet - max_leverage: 2 # conservative; notional scales with wallet -default_trading_context: | - Trade SOL/USDC perpetuals on Derive (connector `derive_perpetual`) — the ONLY - market this strategy trades. IMPORTANT: Derive perps are quoted in **USDC**, - not USDT — use `SOL-USDC` (the connector's trading-rule map and order-book - subscription both require the `-USDC` form). Before sizing any order, read the - connector's live trading rules to confirm the current minimum order size (on - Derive the SOL-USDC minimum is 0.1 SOL, ~$16 at current prices); an order - below the minimum fails immediately (executor `close_type: FAILED`, "Open order - failed"), so always size above the minimum and within the risk limits. Size the - position from the live portfolio balance (`get_portfolio_overview`) — the Risk - Engine enforces `max_position_size_quote` (50) against quote exposure, so pass - `total_amount_quote` with the create (see call shape). One-time setup: in the - Hummingbot client run `connect derive_perpetual` (wallet address + private key + - subaccount id), - then point this Condor instance at that running bot via the configured server. - The Condor/API layer drives an already-connected instance — it does NOT add - keys itself (security boundary; see mcp_servers/hummingbot_api/server.py). - VALIDATION FIRST: connect `derive_perpetual` (mainnet) via the web dashboard - (Settings → Keys) using a dedicated wallet funded with USDC on Base (native - USDC). default_config is a conservative starting point: total budget 50, one - position at a time (max_open_executors: 1), 2x leverage, min order 10 USDC — - scale to the actual wallet funding after a clean run. NOTE: Condor's web UI - filters out testnet connectors (see validation.md), so validation is - mainnet-with-small-size, not testnet. Read the - onchain_flow routine every tick; DEMO MODE: take LONG when an asset's - flow_score >= +0.05 and SHORT when flow_score <= -0.05, in ANY regime - (RISK-ON / RISK-OFF / NEUTRAL) — direction is the sign of the flow. If no - asset clears |flow| >= 0.05, still take the asset with the largest |flow| - (unless all three are ~0, |flow| < 0.02). Do not HOLD while a signal exists. - The on-chain signal is Solana - DeFi flow (GeckoTerminal), not XRPL. ---- - -# Derive Flow Trader — Playbook (what to do, step by step) - -You are the **loop strategy** for the Smart-Money Flow agent. Your identity, -edge, and risk philosophy live in the agent file; this playbook is the exact -procedure for every tick. Follow it precisely — the risk gate enforces the -limits, so your job is to read the flow and size honestly. - -## Step 1 — Run the flow read - -Call `manage_routines(action="run", routine="onchain_flow")`. - -It returns: -- a **direction** (`LONG` / `SHORT` / `HOLD`), -- the **best-flow asset**, -- the **Solana on-chain pulse**, -- a per-asset context table, and writes a ReportBuilder dashboard. - -Read its output; do not re-fetch raw data. - -## Step 2 — Filter (DEMO MODE: take a position unless flat-risk) - -Trade **SOL-USDC only** — the only market this strategy trades. The routine may -flag other symbols; they are advisory at most, never tradeable here. With no -open position: - -- **`LONG`** when SOL-USDC `flow_score >= +0.05` — any regime - (ignore RISK-ON / RISK-OFF / NEUTRAL). -- **`SHORT`** when SOL-USDC `flow_score <= -0.05` — any regime. -- **Fallback:** if no asset clears `|flow| >= 0.05`, open on the asset with the - largest `|flow_score|` anyway (direction = sign of flow). -- **HOLD** only when **all** |flow| < 0.02, or a position is already open. - -DEMO MODE reasoning: a flat session is the failure mode for the demo. Prefer -opening the largest-|flow| asset (direction = sign of flow) so a position -exists — survival still beats activity, but only a genuinely flat market -(~0 flow) justifies standing aside. - -## Step 3 — Size & enter - -One position at a time (`max_open_executors: 1`), **2x leverage**, sized from -the live portfolio balance within `max_position_size_quote` (50). Never exceed -the funded wallet. Open a **`PositionExecutor`**. - -### Call shape (REQUIRED — matches the risk gate) - -- Put `"controller_id": ""` **INSIDE** - `executor_config` — the gate reads the tag ONLY from inside the config. -- Pass **`total_amount_quote`** (the quote notional, e.g. ~$16 for 0.1 SOL at - current price) **AND** `amount` in **BASE units**: **0.1 SOL** (= Derive's min - order). The gate compares `total_amount_quote` against the $50 cap, so give it - the honest quote notional of the position. -- Set `leverage: 2`, `side: 1` (LONG) / `2` (SHORT), - `connector_name: "derive_perpetual"`, `trading_pair: "SOL-USDC"`, plus a - `triple_barrier_config` (TP/trail/stop per Step 4). - -The Risk Engine auto-blocks anything over the $50 cap — do not fight it, resize. - -## Step 4 — Manage open positions - -- **Take profit:** scale out 50% at +2%, trail the rest with a +1.5% activation - and 2% trail; hard stop at −2.5%. -- **Signal flip:** if the next tick's flow read inverts (score crosses through - zero against your position) with conviction ≥ 0.4, exit and optionally reverse. -- **Time limit:** max 8h hold per position. -- **Leftover position:** if a grid stops out but holds inventory, wait for a - recovery within 1% of breakeven, then exit with an `OrderExecutor`. - -## Step 5 — Journal the *why* in flow terms - -One line per tick, in flow terms, e.g.: - -- *"RISK-ON; SOL flow +0.52 (vol/mcap 2.1x, trending #4); Solana pulse +0.44 → - LONG SOL-USDC."* -- *"RISK-OFF; SOL flow −0.4 → SHORT SOL-USDC."* - -Record the thesis, not just the fill. - ---- - -## Cheat sheet (every tick) - -| # | Action | Key values | -|---|---|---| -| 1 | Run `onchain_flow` routine | direction + best asset + pulse + table | -| 2 | Filter | SOL-USDC only; LONG ≥ +0.05, SHORT ≤ −0.05 (any regime); fallback largest-|flow|; HOLD only if all < 0.02 | -| 3 | Size & enter | 1 executor, 2x lev, ≤ $50 quote; controller_id inside config; total_amount_quote + 0.1 SOL base | -| 4 | Manage | TP 50% @ +2%, trail 1.5/2%, stop −2.5%, 8h max, flip on conviction ≥ 0.4 | -| 5 | Journal | one flow-thesis line per tick | - -If the read is ambiguous, prefer the fallback open (DEMO MODE) — a flat session -is the failure mode. No forced trades outside the playbook. From 4df460d256698419f8b804aaffeefee7ce44be2a Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 17 Aug 2026 12:00:03 -0700 Subject: [PATCH 12/65] fix(agents): make the Derive Options Trader routines pass #203's gates - `options_flow.py` failed `black --check` and `isort --check`, which #203's CI enforces repo-wide. Formatting only. - `options_flow.py` line 433 wrote a Markdown underscore escape as `\_` inside a non-raw string. Python does not recognise `\_`, so it kept the two characters but raised SyntaxWarning (an error in a future version). Doubled to `\\_`: identical rendered output, no warning. The GeckoTerminal rate-gate guard needed no fix here. #202 only *renamed* `onchain_flow.py`; #203 had already rewritten that file to call `pool_data.gecko_request`, so the merge took the rename with the rate-gated content and `test_nobody_hand_builds_a_geckoterminal_url` passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj --- .../routines/options_flow.py | 297 ++++++++++++------ 1 file changed, 203 insertions(+), 94 deletions(-) diff --git a/agents/derive_options_trader/routines/options_flow.py b/agents/derive_options_trader/routines/options_flow.py index 8db81389d..bf6b07ee9 100644 --- a/agents/derive_options_trader/routines/options_flow.py +++ b/agents/derive_options_trader/routines/options_flow.py @@ -17,13 +17,15 @@ Output: LONG / SHORT / HOLD + composite score (−1 to +1). """ -from pydantic import BaseModel, Field -from telegram.ext import ContextTypes import asyncio -import aiohttp -import math import logging +import math from datetime import datetime, timezone + +import aiohttp +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes + from condor.reports import ReportBuilder from routines.base import RoutineResult @@ -35,16 +37,28 @@ class Config(BaseModel): """Derive options market read → directional signal for SOL-PERP.""" + currency: str = Field(default="SOL", description="Currency to analyze (SOL or ETH)") - perp_instrument: str = Field(default="SOL-PERP", description="Perpetual instrument to signal") - rr_weight: float = Field(default=0.50, description="Weight for 25D Risk Reversal (0–1)") - oi_weight: float = Field(default=0.35, description="Weight for Put/Call OI Ratio (0–1)") - ts_weight: float = Field(default=0.15, description="Weight for Term Structure (0–1)") - min_threshold: float = Field(default=0.40, description="Min |composite score| to act; below = HOLD") + perp_instrument: str = Field( + default="SOL-PERP", description="Perpetual instrument to signal" + ) + rr_weight: float = Field( + default=0.50, description="Weight for 25D Risk Reversal (0–1)" + ) + oi_weight: float = Field( + default=0.35, description="Weight for Put/Call OI Ratio (0–1)" + ) + ts_weight: float = Field( + default=0.15, description="Weight for Term Structure (0–1)" + ) + min_threshold: float = Field( + default=0.40, description="Min |composite score| to act; below = HOLD" + ) # ── API helpers ────────────────────────────────────────────────────────────── + async def _post(session: aiohttp.ClientSession, path: str, payload: dict) -> dict: async with session.post( f"{DERIVE_API}/{path}", @@ -52,6 +66,7 @@ async def _post(session: aiohttp.ClientSession, path: str, payload: dict) -> dic headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=15), ) as resp: + resp.raise_for_status() return await resp.json() @@ -60,23 +75,36 @@ async def _fetch_perp_ticker(session: aiohttp.ClientSession, instrument: str) -> return data.get("result") or {} -async def _fetch_instruments(session: aiohttp.ClientSession, currency: str) -> list[dict]: - data = await _post(session, "public/get_instruments", { - "currency": currency, "instrument_type": "option", "expired": False - }) +async def _fetch_instruments( + session: aiohttp.ClientSession, currency: str +) -> list[dict]: + data = await _post( + session, + "public/get_instruments", + {"currency": currency, "instrument_type": "option", "expired": False}, + ) return data.get("result") or [] -async def _fetch_tickers(session: aiohttp.ClientSession, currency: str, expiry_date: str) -> dict: - data = await _post(session, "public/get_tickers", { - "currency": currency, "instrument_type": "option", - "expired": False, "expiry_date": expiry_date, - }) +async def _fetch_tickers( + session: aiohttp.ClientSession, currency: str, expiry_date: str +) -> dict: + data = await _post( + session, + "public/get_tickers", + { + "currency": currency, + "instrument_type": "option", + "expired": False, + "expiry_date": expiry_date, + }, + ) return (data.get("result") or {}).get("tickers") or {} # ── Option parsing ──────────────────────────────────────────────────────────── + def _parse_options(tickers: dict) -> list[dict]: out = [] for name, t in tickers.items(): @@ -86,17 +114,19 @@ def _parse_options(tickers: dict) -> list[dict]: op = t.get("option_pricing") or {} stats = t.get("stats") or {} try: - out.append({ - "name": name, - "strike": float(parts[2]), - "type": parts[3], # "C" or "P" - "delta": float(op.get("d") or 0), - "gamma": float(op.get("g") or 0), - "iv": float(op.get("i") or 0), - "oi": float(stats.get("oi") or 0), - "vol": float(stats.get("v") or 0), - "mark": float(t.get("M") or 0), - }) + out.append( + { + "name": name, + "strike": float(parts[2]), + "type": parts[3], # "C" or "P" + "delta": float(op.get("d") or 0), + "gamma": float(op.get("g") or 0), + "iv": float(op.get("i") or 0), + "oi": float(stats.get("oi") or 0), + "vol": float(stats.get("v") or 0), + "mark": float(t.get("M") or 0), + } + ) except (ValueError, TypeError): continue return out @@ -104,21 +134,26 @@ def _parse_options(tickers: dict) -> list[dict]: # ── Signal calculators ──────────────────────────────────────────────────────── + def _compute_25d_rr(opts: list[dict]) -> tuple[float | None, str, str]: """(rr, call_name, put_name). None if insufficient data.""" - calls = [o for o in opts if o["type"] == "C" and o["iv"] > 0 and abs(o["delta"]) > 0.01] - puts = [o for o in opts if o["type"] == "P" and o["iv"] > 0 and abs(o["delta"]) > 0.01] + calls = [ + o for o in opts if o["type"] == "C" and o["iv"] > 0 and abs(o["delta"]) > 0.01 + ] + puts = [ + o for o in opts if o["type"] == "P" and o["iv"] > 0 and abs(o["delta"]) > 0.01 + ] if not calls or not puts: return None, "", "" c25 = min(calls, key=lambda x: abs(abs(x["delta"]) - 0.25)) - p25 = min(puts, key=lambda x: abs(abs(x["delta"]) - 0.25)) + p25 = min(puts, key=lambda x: abs(abs(x["delta"]) - 0.25)) return c25["iv"] - p25["iv"], c25["name"], p25["name"] def _compute_pc_oi(opts: list[dict]) -> tuple[float | None, float, float]: """(score +1=bullish…-1=bearish, call_oi, put_oi).""" call_oi = sum(o["oi"] for o in opts if o["type"] == "C") - put_oi = sum(o["oi"] for o in opts if o["type"] == "P") + put_oi = sum(o["oi"] for o in opts if o["type"] == "P") if call_oi + put_oi < 10: return None, call_oi, put_oi ratio = (put_oi + 0.1) / (call_oi + 0.1) @@ -139,18 +174,22 @@ def _gex(opts: list[dict], spot: float) -> float: total = 0.0 for o in opts: sign = 1.0 if o["type"] == "C" else -1.0 - total += sign * o["gamma"] * o["oi"] * (spot ** 2) * 0.01 + total += sign * o["gamma"] * o["oi"] * (spot**2) * 0.01 return total # ── Main ───────────────────────────────────────────────────────────────────── + async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: - async with aiohttp.ClientSession() as session: - perp_data, instruments = await asyncio.gather( - _fetch_perp_ticker(session, config.perp_instrument), - _fetch_instruments(session, config.currency), - ) + try: + async with aiohttp.ClientSession() as session: + perp_data, instruments = await asyncio.gather( + _fetch_perp_ticker(session, config.perp_instrument), + _fetch_instruments(session, config.currency), + ) + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + return f"Derive API unavailable ({type(e).__name__}: {e}) — no options signal this tick" spot = float(perp_data.get("index_price") or 0) if spot <= 0: @@ -160,13 +199,15 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: # Find unique active expiry dates active = [i for i in instruments if i.get("is_active")] - expiry_dates = sorted(set( - datetime.fromtimestamp( - i["option_details"]["expiry"], tz=timezone.utc - ).strftime("%Y%m%d") - for i in active - if i.get("option_details") - )) + expiry_dates = sorted( + set( + datetime.fromtimestamp( + i["option_details"]["expiry"], tz=timezone.utc + ).strftime("%Y%m%d") + for i in active + if i.get("option_details") + ) + ) if not expiry_dates: return "No active options found" @@ -179,14 +220,33 @@ async def fetch_guarded(exp: str) -> tuple[str, dict]: tickers = await _fetch_tickers(s, config.currency, exp) return exp, tickers - ticker_pairs = await asyncio.gather(*[fetch_guarded(e) for e in expiry_dates]) - expiry_opts = {exp: _parse_options(tickers) for exp, tickers in ticker_pairs} + results = await asyncio.gather( + *[fetch_guarded(e) for e in expiry_dates], return_exceptions=True + ) + expiry_opts, failed_expiries = {}, [] + for exp, res in zip(expiry_dates, results): + if isinstance(res, BaseException): + logger.warning("options_flow: expiry %s fetch failed: %s", exp, res) + failed_expiries.append(exp) + else: + expiry_opts[exp] = _parse_options(res[1]) + if not expiry_opts: + return ( + f"Derive API unavailable (all {len(expiry_dates)} expiry fetches failed) " + "— no options signal this tick" + ) # ── Per-expiry signals ── today = datetime.now(tz=timezone.utc) def dte(exp_str: str) -> int: - return max(1, (datetime.strptime(exp_str, "%Y%m%d").replace(tzinfo=timezone.utc) - today).days) + return max( + 1, + ( + datetime.strptime(exp_str, "%Y%m%d").replace(tzinfo=timezone.utc) + - today + ).days, + ) rr_map, rr_detail, oi_map, iv_map = {}, {}, {}, {} for exp, opts in expiry_opts.items(): @@ -227,9 +287,9 @@ def dte(exp_str: str) -> int: sorted_ivs = [iv_map[e] for e in sorted(iv_map)] near_iv, far_iv = sorted_ivs[0], sorted_ivs[-1] if near_iv > far_iv * 1.05: - ts_score = -0.5 # inverted = fear + ts_score = -0.5 # inverted = fear elif far_iv > near_iv * 1.05: - ts_score = +0.2 # contango = mild bullish + ts_score = +0.2 # contango = mild bullish else: near_iv, far_iv = 0.0, 0.0 @@ -240,25 +300,34 @@ def dte(exp_str: str) -> int: gex_exp = exp gex_val = _gex(expiry_opts[exp], spot) break - gex_amp = 0.75 if gex_val > 0 else 1.25 - - composite = max(-1.0, min(1.0, - (rr_score * config.rr_weight + oi_score * config.oi_weight + ts_score * config.ts_weight) - * gex_amp - )) + # No liquid expiry → no gamma read → neutral amplifier (never amplify on missing data) + gex_amp = 0.75 if gex_val > 0 else (1.25 if gex_val < 0 else 1.0) + + composite = max( + -1.0, + min( + 1.0, + ( + rr_score * config.rr_weight + + oi_score * config.oi_weight + + ts_score * config.ts_weight + ) + * gex_amp, + ), + ) if composite >= config.min_threshold: - direction, emoji = "LONG", "🟢" + direction, emoji = "LONG", "🟢" elif composite <= -config.min_threshold: direction, emoji = "SHORT", "🔴" else: - direction, emoji = "HOLD", "🟡" + direction, emoji = "HOLD", "🟡" # Confidence: how many of the 3 sub-signals agree on direction votes = [ - 1 if rr_score >= 0.2 else (-1 if rr_score <= -0.2 else 0), - 1 if oi_score >= 0.2 else (-1 if oi_score <= -0.2 else 0), - 1 if ts_score >= 0.1 else (-1 if ts_score <= -0.1 else 0), + 1 if rr_score >= 0.2 else (-1 if rr_score <= -0.2 else 0), + 1 if oi_score >= 0.2 else (-1 if oi_score <= -0.2 else 0), + 1 if ts_score >= 0.1 else (-1 if ts_score <= -0.1 else 0), ] nonzero_votes = [v for v in votes if v != 0] if nonzero_votes: @@ -285,7 +354,15 @@ def dte(exp_str: str) -> int: builder.kpi("25D Risk Reversal", f"{rr_score:+.3f} (wt {config.rr_weight:.0%})") builder.kpi("P/C OI Ratio", f"{oi_score:+.3f} (wt {config.oi_weight:.0%})") builder.kpi("Term Structure", f"{ts_score:+.3f} (wt {config.ts_weight:.0%})") - builder.kpi("GEX Amplifier", f"{gex_val:+.1f} → {gex_amp:.2f}×") + if gex_exp is not None: + builder.kpi("GEX Amplifier", f"{gex_val:+.1f} → {gex_amp:.2f}×") + else: + builder.kpi("GEX Amplifier", "n/a (no liquid expiry) → 1.00×") + if failed_expiries: + builder.markdown( + f"⚠️ {len(failed_expiries)}/{len(expiry_dates)} expiry fetches failed " + f"({', '.join(failed_expiries)}) — composite computed from partial data." + ) # Per-expiry table exp_rows = [] @@ -296,43 +373,64 @@ def dte(exp_str: str) -> int: iv_v = iv_map.get(exp) c_oi = oi_d[1] if oi_d else 0 p_oi = oi_d[2] if oi_d else 0 - exp_rows.append({ - "Expiry": exp, - "DTE": d, - "ATM IV": f"{iv_v*100:.1f}%" if iv_v else "—", - "25D RR": f"{rr_v:+.4f}" if rr_v is not None else "—", - "Bias": ("BULL" if rr_v and rr_v > 0 else "BEAR") if rr_v is not None else "—", - "Call OI": f"{c_oi:.0f}", - "Put OI": f"{p_oi:.0f}", - "P/C Ratio": f"{p_oi/max(c_oi,1):.2f}", - }) - - builder.section("PER-EXPIRY BREAKDOWN", "Signals across all active option expiry dates") - builder.table(exp_rows, ["Expiry", "DTE", "ATM IV", "25D RR", "Bias", "Call OI", "Put OI", "P/C Ratio"]) + exp_rows.append( + { + "Expiry": exp, + "DTE": d, + "ATM IV": f"{iv_v*100:.1f}%" if iv_v else "—", + "25D RR": f"{rr_v:+.4f}" if rr_v is not None else "—", + "Bias": ( + ("BULL" if rr_v and rr_v > 0 else "BEAR") + if rr_v is not None + else "—" + ), + "Call OI": f"{c_oi:.0f}", + "Put OI": f"{p_oi:.0f}", + "P/C Ratio": f"{p_oi/max(c_oi,1):.2f}", + } + ) + + builder.section( + "PER-EXPIRY BREAKDOWN", "Signals across all active option expiry dates" + ) + builder.table( + exp_rows, + ["Expiry", "DTE", "ATM IV", "25D RR", "Bias", "Call OI", "Put OI", "P/C Ratio"], + ) # IV surface for nearest liquid expiry if gex_exp: surf_opts = [ - o for o in expiry_opts[gex_exp] + o + for o in expiry_opts[gex_exp] if o["iv"] > 0 and abs(o["strike"] - spot) / spot < 0.30 ] - surf_rows = sorted([{ - "Strike": o["strike"], - "Type": o["type"], - "IV %": f"{o['iv']*100:.1f}%", - "Delta": f"{o['delta']:+.3f}", - "OI": f"{o['oi']:.0f}", - "Vol 24h": f"{o['vol']:.1f}", - } for o in surf_opts], key=lambda x: (x["Strike"], x["Type"])) - - builder.section(f"IV SURFACE — {gex_exp}", "Options within ±30% of spot, nearest liquid expiry") + surf_rows = sorted( + [ + { + "Strike": o["strike"], + "Type": o["type"], + "IV %": f"{o['iv']*100:.1f}%", + "Delta": f"{o['delta']:+.3f}", + "OI": f"{o['oi']:.0f}", + "Vol 24h": f"{o['vol']:.1f}", + } + for o in surf_opts + ], + key=lambda x: (x["Strike"], x["Type"]), + ) + + builder.section( + f"IV SURFACE — {gex_exp}", + "Options within ±30% of spot, nearest liquid expiry", + ) builder.table(surf_rows, ["Strike", "Type", "IV %", "Delta", "OI", "Vol 24h"]) builder.section("METHODOLOGY", "") builder.markdown( "**25D Risk Reversal** (50%): IV(25Δ call) − IV(25Δ put), weighted by 1/DTE so " "near-term expiries dominate. Positive = calls bid = bullish.\n\n" - "**Put/Call OI Ratio** (35%): log(put\_OI / call\_OI), OI-magnitude weighted. " + "**Put/Call OI Ratio** (35%): log(put\\_OI / call\\_OI), OI-magnitude weighted. " "Call-heavy → bullish; put-heavy → bearish.\n\n" "**Term Structure** (15%): near vs far ATM IV. " "Inverted (near > far) = near-term fear = bearish (−0.5). " @@ -344,19 +442,30 @@ def dte(exp_str: str) -> int: await builder.save() + gex_str = ( + f"GEX {gex_val:+.0f} ({gex_amp:.2f}×)" + if gex_exp is not None + else "GEX n/a (1.00×)" + ) summary = ( f"{emoji} Options Oracle: **{direction}** (score {composite:+.3f} | {confidence} confidence)\n" f"{config.currency} @ ${spot:.3f} | " - f"25D RR {rr_score:+.3f} | P/C OI {oi_score:+.3f} | TS {ts_score:+.3f} | GEX {gex_val:+.0f} ({gex_amp:.2f}×)" + f"25D RR {rr_score:+.3f} | P/C OI {oi_score:+.3f} | TS {ts_score:+.3f} | {gex_str}" ) + if failed_expiries: + summary += f"\n⚠️ partial data: {len(failed_expiries)}/{len(expiry_dates)} expiry fetches failed" return RoutineResult( text=summary, sections=[ - {"type": "kpi", "label": "Direction", "value": f"{emoji} {direction}"}, - {"type": "kpi", "label": "Score", "value": f"{composite:+.3f}"}, - {"type": "kpi", "label": "Confidence", "value": confidence}, - {"type": "kpi", "label": f"{config.currency} Spot", "value": f"${spot:.3f}"}, - {"type": "kpi", "label": "Funding/hr", "value": f"{funding_rate*100:.4f}%"}, + {"type": "kpi", "label": "Direction", "value": f"{emoji} {direction}"}, + {"type": "kpi", "label": "Score", "value": f"{composite:+.3f}"}, + {"type": "kpi", "label": "Confidence", "value": confidence}, + { + "type": "kpi", + "label": f"{config.currency} Spot", + "value": f"${spot:.3f}", + }, + {"type": "kpi", "label": "Funding/hr", "value": f"{funding_rate*100:.4f}%"}, ], ) From 3d9f2ef70b8fb6f5117935fbdea282e905b1a3c7 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 11:35:17 -0700 Subject: [PATCH 13/65] test(agents): teach the numeric-credentials stub the SEC-178 access predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_mcp_servers_for_session now holds every server candidate to has_server_access(user_id, name) before its credentials go into the subprocess env. The _NumericPasswordServer stub predates that check, so the test died on AttributeError instead of exercising the str-coercion it pins. Grant the stub unconditional access — the test's subject is credential stringification, not authorization. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- tests/test_agents.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_agents.py b/tests/test_agents.py index d77231274..fc8dcd307 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -736,6 +736,9 @@ class _NumericPasswordServer: def get_accessible_servers(self, user_id): return ["local"] + def has_server_access(self, user_id, name, permission=None): + return True + def get_server(self, name): return { "host": "localhost", From 6404c9bb926c04fefef9e482c976b5b3bc962ecd Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 13:30:25 -0700 Subject: [PATCH 14/65] chore(deps): resolve the 1.5.8 pin from the client PR branch until it ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hummingbot-api-client==1.5.8 is not on PyPI (hummingbot-api-client#25 is still open), so every uv sync — CI's first step in all three backend jobs — died with "doesn't have a source distribution or wheel". A [tool.uv.sources] git override keeps the ==1.5.8 pin but resolves it from the PR branch, locked to the exact commit the tool was validated against. Drop the block and re-lock once 1.5.8 is released. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- pyproject.toml | 6 ++++++ uv.lock | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7cb6c6357..0250d5991 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,12 @@ dependencies = [ # ``black --check`` with no black on PATH and failed to spawn it. dev = ["pytest", "pytest-asyncio", "black", "isort", "pre-commit"] +[tool.uv.sources] +# 1.5.8 is not on PyPI yet (hummingbot/hummingbot-api-client#25). Until that +# releases, resolve the ==1.5.8 pin from the PR branch so ``uv sync`` works in +# CI and locally. Drop this whole block and re-run ``uv lock`` once 1.5.8 ships. +hummingbot-api-client = { git = "https://github.com/hummingbot/hummingbot-api-client.git", branch = "feat/clmm-bin-count" } + [tool.isort] profile = "black" diff --git a/uv.lock b/uv.lock index 7615d2117..d109d9dab 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -639,7 +639,7 @@ requires-dist = [ { name = "fastapi" }, { name = "faster-whisper" }, { name = "geckoterminal-py" }, - { name = "hummingbot-api-client", specifier = "==1.5.8" }, + { name = "hummingbot-api-client", git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count" }, { name = "kaleido" }, { name = "markdown", specifier = ">=3.10.2" }, { name = "matplotlib", specifier = ">=3.10.8" }, @@ -1496,7 +1496,7 @@ wheels = [ [[package]] name = "hummingbot-api-client" version = "1.5.8" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#a3e6fff7286bc8c7e38fcbfb499254548b2ca58e" } dependencies = [ { name = "aiohttp" }, ] From 73221b44c783a183434da97614eff892491aa9f2 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 15:20:15 -0700 Subject: [PATCH 15/65] fix(agents): close four holes the adversarial review found in orphan visibility and derive strategies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExecutorsProvider: surface SYSTEM_CLEANUP lp_executor rows (RUNNING when the API restarted, rewritten with no final state) as POSSIBLE ORPHANED POSITION with reconciliation guidance. They matched no warning branch, so an agent redeployed on funds that were still staked. No close call is suggested for them — the address is unknown, not absent. - format_clmm_result position_info: read the API's snake_case CLMMPositionInfo fields. The camelCase reads rendered every field None, which read as "the position is gone" at the exact step the recovery guide says to verify it. - derive_options_trader strategies: restore the enforced risk key (max_position_size_quote, not the silently-filtered max_total_exposure_quote), state the gate's real contract (reads total_amount_quote/amount verbatim from executor_config; it does NOT resolve live prices), and fix the options_oracle_operator call shape whose top-level amount recorded $0 exposure. - options_flow confidence: grade sub-signal agreement against sign(composite) — the emitted direction — not the dominant vote, which could stamp MEDIUM on an entry two of three signals disagreed with. Extracted as _confidence() and pinned with the review's exact counterexample. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../routines/options_flow.py | 36 +++-- .../options_oracle_operator/strategy.md | 30 ++-- .../strategies/smart_money_flow/strategy.md | 31 ++-- condor/agents/providers/executors.py | 25 +++ .../hummingbot_api/formatters/gateway.py | 10 +- tests/test_orphan_visibility_fixes.py | 149 ++++++++++++++++++ 6 files changed, 236 insertions(+), 45 deletions(-) create mode 100644 tests/test_orphan_visibility_fixes.py diff --git a/agents/derive_options_trader/routines/options_flow.py b/agents/derive_options_trader/routines/options_flow.py index bf6b07ee9..3170956bc 100644 --- a/agents/derive_options_trader/routines/options_flow.py +++ b/agents/derive_options_trader/routines/options_flow.py @@ -178,6 +178,28 @@ def _gex(opts: list[dict], spot: float) -> float: return total +def _confidence( + composite: float, rr_score: float, oi_score: float, ts_score: float +) -> str: + """How many of the 3 sub-signals agree with the EMITTED direction. + + The reference must be sign(composite), not the dominant vote: with weighted + scores and the GEX amplifier, the composite can point LONG while two of + three raw votes point SHORT — grading agreement against the dominant vote + would then stamp MEDIUM on a signal the majority disagrees with. + """ + votes = [ + 1 if rr_score >= 0.2 else (-1 if rr_score <= -0.2 else 0), + 1 if oi_score >= 0.2 else (-1 if oi_score <= -0.2 else 0), + 1 if ts_score >= 0.1 else (-1 if ts_score <= -0.1 else 0), + ] + emitted = 1 if composite > 0 else (-1 if composite < 0 else 0) + if not emitted: + return "LOW" + agree = sum(1 for v in votes if v == emitted) + return "HIGH" if agree == 3 else ("MEDIUM" if agree == 2 else "LOW") + + # ── Main ───────────────────────────────────────────────────────────────────── @@ -323,19 +345,7 @@ def dte(exp_str: str) -> int: else: direction, emoji = "HOLD", "🟡" - # Confidence: how many of the 3 sub-signals agree on direction - votes = [ - 1 if rr_score >= 0.2 else (-1 if rr_score <= -0.2 else 0), - 1 if oi_score >= 0.2 else (-1 if oi_score <= -0.2 else 0), - 1 if ts_score >= 0.1 else (-1 if ts_score <= -0.1 else 0), - ] - nonzero_votes = [v for v in votes if v != 0] - if nonzero_votes: - dominant = max(set(nonzero_votes), key=nonzero_votes.count) - agree = sum(1 for v in nonzero_votes if v == dominant) - confidence = "HIGH" if agree == 3 else ("MEDIUM" if agree == 2 else "LOW") - else: - confidence = "LOW" + confidence = _confidence(composite, rr_score, oi_score, ts_score) # ── Report ──────────────────────────────────────────────────────────────── builder = ReportBuilder(f"Options Oracle — {config.currency}/USDC") diff --git a/agents/derive_options_trader/strategies/options_oracle_operator/strategy.md b/agents/derive_options_trader/strategies/options_oracle_operator/strategy.md index bd433a297..bd6662d7e 100644 --- a/agents/derive_options_trader/strategies/options_oracle_operator/strategy.md +++ b/agents/derive_options_trader/strategies/options_oracle_operator/strategy.md @@ -12,7 +12,7 @@ default_config: min_order_amount_quote: 10 max_ticks: 0 risk_limits: - max_total_exposure_quote: 50 + max_position_size_quote: 50 max_drawdown_pct: 8 max_open_executors: 1 max_leverage: 2 @@ -86,29 +86,33 @@ Position sizing: Convert to base units: `sol_amount = size_quote / sol_spot`, round down to nearest 0.001 SOL, minimum 0.1 SOL. If `sol_amount < 0.1`, skip — journal "size too small". -**PositionExecutor call shape (REQUIRED):** +**PositionExecutor call shape (REQUIRED — every key INSIDE `executor_config`; +the risk gate reads ONLY that dict, so a top-level `amount` records $0 exposure +and bypasses the cap):** ```json -{ +manage_executors(action="create", executor_config={ "connector_name": "derive_perpetual", "trading_pair": "SOL-USDC", "side": 1, "amount": , + "total_amount_quote": , "leverage": 2, - "executor_config": { - "controller_id": "", - "triple_barrier_config": { - "take_profit": 0.030, - "stop_loss": 0.025, - "trailing_stop": { "activation_price": 0.015, "trailing_delta": 0.020 }, - "time_limit": 86400 - } + "controller_id": "", + "triple_barrier_config": { + "take_profit": 0.030, + "stop_loss": 0.025, + "trailing_stop": { "activation_price": 0.015, "trailing_delta": 0.020 }, + "time_limit": 86400 } -} +}) ``` - `side: 1` = LONG, `side: 2` = SHORT. +- `total_amount_quote` is the quote notional (`size_quote`). The gate does NOT + resolve live prices — it reads `total_amount_quote` (falling back to `amount`) + verbatim and compares it against the $50 cap. Give it the honest quote figure. - Raise leverage to 3 only if confidence=HIGH **and** |composite_score| ≥ 0.70. -- The Risk Engine enforces `max_total_exposure_quote` (50) and `max_open_executors` (1) +- The Risk Engine enforces `max_position_size_quote` (50) and `max_open_executors` (1) — do not try to open a second position if one is already open. ### Step 5 — Manage open positions diff --git a/agents/derive_options_trader/strategies/smart_money_flow/strategy.md b/agents/derive_options_trader/strategies/smart_money_flow/strategy.md index 2eaab0707..d5fc8e19a 100644 --- a/agents/derive_options_trader/strategies/smart_money_flow/strategy.md +++ b/agents/derive_options_trader/strategies/smart_money_flow/strategy.md @@ -10,13 +10,12 @@ default_config: total_amount_quote: 50 # Conservative default sizing (assumes a 50 USDC balance). Demo wallets may be # funded with more — size from the live portfolio balance - # (get_portfolio_overview) rather than assuming 50. No fixed per-position - # quote cap; the Risk Engine's default max_position_size_quote (500) is the - # enforced backstop. + # (get_portfolio_overview) rather than assuming 50. Per-position quote exposure + # is enforced by the Risk Engine's gate (max_position_size_quote). min_order_amount_quote: 10 # smallest order placed per attempt max_ticks: 0 risk_limits: - max_total_exposure_quote: 50 # enforced by the Risk Engine; never exceed the funded wallet + max_position_size_quote: 50 # enforced by the Risk Engine; never exceed the funded wallet max_drawdown_pct: 8 max_open_executors: 1 # one position at a time on a tiny wallet max_leverage: 2 # conservative; notional scales with wallet @@ -29,9 +28,9 @@ default_trading_context: | Derive the SOL-USDC minimum is 0.1 SOL, ~$16 at current prices); an order below the minimum fails immediately (executor `close_type: FAILED`, "Open order failed"), so always size above the minimum and within the risk limits. Size the - position from the live portfolio balance (`get_portfolio_overview`) — there is - no fixed per-position quote cap (the Risk Engine's default - `max_position_size_quote` of 500 is the enforced backstop). One-time setup: in the + position from the live portfolio balance (`get_portfolio_overview`); the Risk + Engine enforces `max_position_size_quote` (50) against quote exposure, so pass + `total_amount_quote` with the create (see call shape). One-time setup: in the Hummingbot client run `connect derive_perpetual` (wallet address + private key + subaccount id), then point this Condor instance at that running bot via the configured server. @@ -103,7 +102,7 @@ In demo mode, take the largest |flow_score| asset if no asset clears ±0.05. SOL threshold, or a position is already open. 4. **Confirm against options & size.** One position at a time (`max_open_executors: 1`), **2x leverage**, sized from the live portfolio - balance within `max_total_exposure_quote` (50). Options modulate size: + balance within `max_position_size_quote` (50). Options modulate size: - Options **agree** with the flow direction (same sign, any magnitude) → full computed size. - Options **strongly disagree** (|composite_score| >= 0.40 against the flow) → @@ -114,16 +113,18 @@ In demo mode, take the largest |flow_score| asset if no asset clears ±0.05. SOL **Call shape (REQUIRED — matches the risk gate):** - Put `"controller_id": ""` **INSIDE** `executor_config` (the gate accepts top-level too, but inside is canonical). - - Pass `amount` in **BASE units**: **0.1 SOL** (= Derive's min order, ~$16 - notional at current price). The risk gate resolves the LIVE market price - itself and multiplies amount × live price to check quote exposure against - the $50 cap — do not pass a made-up `entry_price` to game the check; it is - ignored for risk purposes. + - Pass **`total_amount_quote`** (the quote notional, e.g. ~$16 for 0.1 SOL at + current price) **AND** `amount` in **BASE units**: **0.1 SOL** (= Derive's + min order). The gate does NOT resolve live prices — it reads + `total_amount_quote` (falling back to `amount`) verbatim from inside + `executor_config` and compares it against the $50 cap, so omitting + `total_amount_quote` makes the gate compare base units against quote + dollars and corrupts exposure accounting. Give it the honest quote figure. - Set `leverage: 2`, `side: 1` (LONG) / `2` (SHORT), `connector_name: "derive_perpetual"`, `trading_pair: "SOL-USDC"`, plus a `triple_barrier_config` (TP/trail/stop per step 5). - The Risk Engine auto-blocks anything over limit (exposure 50, - `max_position_size_quote` backstop: 500). + The Risk Engine auto-blocks anything over limit + (`max_position_size_quote`: 50). 5. **Manage.** 50% take-profit at +2%, trail 2% after +1.5% in profit, hard stop −2.5%. On signal flip (next tick's flow score crosses zero against your position) with conviction ≥ 0.4, exit and optionally reverse — flip faster if diff --git a/condor/agents/providers/executors.py b/condor/agents/providers/executors.py index fede53c04..013e09280 100644 --- a/condor/agents/providers/executors.py +++ b/condor/agents/providers/executors.py @@ -58,6 +58,11 @@ async def execute( # (POSITION_HOLD with hold_reason set — an LP close that exhausted its # retries) or a legacy FAILED-with-position. It would otherwise vanish # from this RUNNING-only summary — surface it until it is recovered. + # SYSTEM_CLEANUP is the fourth class: an lp_executor that was RUNNING when + # the API restarted, rewritten with no final state. Its position address is + # unknown, not absent — the API's /positions/orphaned endpoint lists these + # as needs_onchain_reconciliation, and skipping them here is exactly how an + # agent ends up stacking a fresh position on funds that are still staked. orphaned = [ e for e in perf.executors @@ -73,6 +78,10 @@ async def execute( str(e.get("close_type") or "").upper() == "FAILED" and (e.get("custom_info") or {}).get("position_address") ) + or ( + str(e.get("close_type") or "").upper() == "SYSTEM_CLEANUP" + and str(e.get("type") or "").lower() == "lp_executor" + ) ) ] lines = [ @@ -89,6 +98,22 @@ async def execute( ) for o in orphaned: pos = (o.get("custom_info") or {}).get("position_address") + if pos is None: + # A record with no persisted position address (SYSTEM_CLEANUP after + # an API restart) may or may not still be staked — say so, and do + # NOT suggest a close call that would have to invent an address. + lines.append( + f" 🚨 POSSIBLE ORPHANED POSITION: executor {o['id']} ({o['pair']}) " + "was terminated by an API restart and its on-chain position address " + "was not persisted. Reconcile before deploying: list this wallet's " + 'positions with manage_clmm(action="position_info", ...) — ' + 'manage_executors(action="orphaned") reports the dex, pool and ' + "network. If a position is still open, close it with manage_clmm; " + "either way, then mark it handled with " + f"manage_executors(action=\"resolve_orphan\", executor_id=\"{o['id']}\"). " + "Do not open new positions on these funds first." + ) + continue reason = (o.get("custom_info") or {}).get("hold_reason") or "close failed" lines.append( f" 🚨 ORPHANED POSITION: executor {o['id']} ({o['pair']}) terminated " diff --git a/mcp_servers/hummingbot_api/formatters/gateway.py b/mcp_servers/hummingbot_api/formatters/gateway.py index 42f3b2ead..08e53c8fa 100644 --- a/mcp_servers/hummingbot_api/formatters/gateway.py +++ b/mcp_servers/hummingbot_api/formatters/gateway.py @@ -237,12 +237,14 @@ def format_clmm_result(action: str, result: dict[str, Any]) -> str: ] for p in payload: lines.append( - f" • {p.get('address')} — Base: {p.get('baseTokenAmount')} " - f"Quote: {p.get('quoteTokenAmount')} " - f"Range: {p.get('lowerPrice')}–{p.get('upperPrice')} Price: {p.get('price')}" + f" • {p.get('position_address')} — Base: {p.get('base_token_amount')} " + f"Quote: {p.get('quote_token_amount')} " + f"Range: {p.get('lower_price')}–{p.get('upper_price')} " + f"Price: {p.get('current_price')}" ) lines.append( - f" Uncollected fees — base: {p.get('baseFeeAmount')} quote: {p.get('quoteFeeAmount')}" + f" Uncollected fees — base: {p.get('base_fee_amount')} " + f"quote: {p.get('quote_fee_amount')}" ) return "\n".join(lines) diff --git a/tests/test_orphan_visibility_fixes.py b/tests/test_orphan_visibility_fixes.py new file mode 100644 index 000000000..4a280b5f3 --- /dev/null +++ b/tests/test_orphan_visibility_fixes.py @@ -0,0 +1,149 @@ +"""Adversarial-review fixes: orphan visibility and recovery-path honesty. + +Three ways an agent was blinded to (or misled about) stranded on-chain exposure: + +- A SYSTEM_CLEANUP lp_executor row (RUNNING when the API restarted, rewritten + with no final state) matched none of the tick-prompt orphan branches, so the + agent redeployed on funds that were still staked. +- format_clmm_result's position_info branch read camelCase keys against the + API's snake_case CLMMPositionInfo fields, so every field — including the + position address — rendered as None and "the position is gone" was a + reasonable (wrong) conclusion. +- options_flow confidence graded agreement against the dominant sub-signal vote + instead of the emitted direction, stamping MEDIUM on entries the majority of + signals disagreed with. +""" + +import asyncio + +from condor.agents.performance import AgentPerformance +from condor.agents.providers.executors import ExecutorsProvider + + +def _run_provider(executors): + import condor.agents.performance as perf_mod + + perf = AgentPerformance(agent_id="agent.strat_1", bot_names=["bot_1"]) + perf.executors = executors + + async def _fake(*a, **kw): + return perf + + original = perf_mod.fetch_agent_performance + perf_mod.fetch_agent_performance = _fake + try: + return asyncio.run( + ExecutorsProvider().execute( + object(), {"bot_name": "bot_1"}, agent_id="agent.strat_1" + ) + ) + finally: + perf_mod.fetch_agent_performance = original + + +def _row(**overrides): + row = { + "id": "exec-1", + "type": "lp_executor", + "pair": "SOL-USDC", + "status": "TERMINATED", + "close_type": "", + "custom_info": {}, + "pnl": 0.0, + "volume": 0.0, + "amount": 0.0, + } + row.update(overrides) + return row + + +def test_system_cleanup_lp_row_surfaces_as_possible_orphan(): + """An API-restart row has no address — it must still warn, without one.""" + result = _run_provider([_row(close_type="SYSTEM_CLEANUP")]) + assert "POSSIBLE ORPHANED POSITION" in result.summary + assert "resolve_orphan" in result.summary + # No address is known, so no concrete close call may be suggested. + assert 'manage_clmm(action="close"' not in result.summary + assert result.data["orphaned_executors"] + + +def test_system_cleanup_non_lp_row_stays_silent(): + """Only lp_executors own on-chain positions — mirror the server's filter.""" + result = _run_provider( + [_row(close_type="SYSTEM_CLEANUP", type="position_executor")] + ) + assert "ORPHANED" not in result.summary + assert result.data["orphaned_executors"] == [] + + +def test_address_bearing_orphan_still_emits_the_close_call(): + result = _run_provider( + [ + _row( + close_type="POSITION_HOLD", + custom_info={ + "hold_reason": "close_retries_exhausted", + "position_address": "H4vD69Ds", + }, + ) + ] + ) + assert "🚨 ORPHANED POSITION" in result.summary + assert 'manage_clmm(action="close"' in result.summary + + +def test_resolved_system_cleanup_row_stays_silent(): + result = _run_provider( + [_row(close_type="SYSTEM_CLEANUP", custom_info={"orphan_resolved": True})] + ) + assert "ORPHANED" not in result.summary + + +def test_position_info_formats_the_apis_snake_case_fields(): + """The formatter must read what /gateway/clmm/positions_owned sends.""" + from mcp_servers.hummingbot_api.formatters.gateway import format_clmm_result + + out = format_clmm_result( + "position_info", + { + "action": "position_info", + "connector": "meteora/clmm", + "network": "solana-mainnet-beta", + "pool_address": "PoolAddr", + "result": [ + { + "position_address": "PosAddr123", + "base_token_amount": 1.5, + "quote_token_amount": 200.0, + "lower_price": 100.0, + "upper_price": 140.0, + "current_price": 120.0, + "base_fee_amount": 0.01, + "quote_fee_amount": 1.2, + } + ], + }, + ) + assert "PosAddr123" in out + assert "1.5" in out and "200.0" in out + assert "100.0–140.0" in out + assert "120.0" in out + assert "0.01" in out and "1.2" in out + assert "None" not in out + + +def test_confidence_is_graded_against_the_emitted_direction(): + from agents.derive_options_trader.routines.options_flow import _confidence + + # The finding's exact scenario: rr=+1.0, oi=-0.25, ts=-0.5 with the + # negative-GEX 1.25 amplifier → composite +0.42 → LONG, while two of three + # votes say SHORT. Dominant-vote grading called this MEDIUM; only one vote + # agrees with LONG, so it is LOW — below the entry threshold. + assert _confidence(+0.42, 1.0, -0.25, -0.5) == "LOW" + # All three agreeing with the emitted direction is HIGH either way. + assert _confidence(+0.6, 0.5, 0.3, 0.2) == "HIGH" + assert _confidence(-0.6, -0.5, -0.3, -0.2) == "HIGH" + # Two of three agreeing with the emitted direction is MEDIUM. + assert _confidence(+0.45, 0.5, 0.3, -0.2) == "MEDIUM" + # A zero composite never grades above LOW. + assert _confidence(0.0, 1.0, 1.0, 1.0) == "LOW" From 2ad6a2b30a8944bff2ede9de4116c84c723c5f7a Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 15:42:35 -0700 Subject: [PATCH 16/65] revert(agents): drop the SYSTEM_CLEANUP tick-prompt warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restart-orphan problem is not LP-specific — perp position executors lose their manager (and the in-memory hold store) on the same restart — so a warning keyed to one executor type is the wrong shape. The general fix is reconciliation against venue truth (live account positions, gateway positions_owned), designed separately. The address-bearing orphan classes keep their warning and close call. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- condor/agents/providers/executors.py | 25 -------------------- tests/test_orphan_visibility_fixes.py | 33 ++------------------------- 2 files changed, 2 insertions(+), 56 deletions(-) diff --git a/condor/agents/providers/executors.py b/condor/agents/providers/executors.py index 013e09280..fede53c04 100644 --- a/condor/agents/providers/executors.py +++ b/condor/agents/providers/executors.py @@ -58,11 +58,6 @@ async def execute( # (POSITION_HOLD with hold_reason set — an LP close that exhausted its # retries) or a legacy FAILED-with-position. It would otherwise vanish # from this RUNNING-only summary — surface it until it is recovered. - # SYSTEM_CLEANUP is the fourth class: an lp_executor that was RUNNING when - # the API restarted, rewritten with no final state. Its position address is - # unknown, not absent — the API's /positions/orphaned endpoint lists these - # as needs_onchain_reconciliation, and skipping them here is exactly how an - # agent ends up stacking a fresh position on funds that are still staked. orphaned = [ e for e in perf.executors @@ -78,10 +73,6 @@ async def execute( str(e.get("close_type") or "").upper() == "FAILED" and (e.get("custom_info") or {}).get("position_address") ) - or ( - str(e.get("close_type") or "").upper() == "SYSTEM_CLEANUP" - and str(e.get("type") or "").lower() == "lp_executor" - ) ) ] lines = [ @@ -98,22 +89,6 @@ async def execute( ) for o in orphaned: pos = (o.get("custom_info") or {}).get("position_address") - if pos is None: - # A record with no persisted position address (SYSTEM_CLEANUP after - # an API restart) may or may not still be staked — say so, and do - # NOT suggest a close call that would have to invent an address. - lines.append( - f" 🚨 POSSIBLE ORPHANED POSITION: executor {o['id']} ({o['pair']}) " - "was terminated by an API restart and its on-chain position address " - "was not persisted. Reconcile before deploying: list this wallet's " - 'positions with manage_clmm(action="position_info", ...) — ' - 'manage_executors(action="orphaned") reports the dex, pool and ' - "network. If a position is still open, close it with manage_clmm; " - "either way, then mark it handled with " - f"manage_executors(action=\"resolve_orphan\", executor_id=\"{o['id']}\"). " - "Do not open new positions on these funds first." - ) - continue reason = (o.get("custom_info") or {}).get("hold_reason") or "close failed" lines.append( f" 🚨 ORPHANED POSITION: executor {o['id']} ({o['pair']}) terminated " diff --git a/tests/test_orphan_visibility_fixes.py b/tests/test_orphan_visibility_fixes.py index 4a280b5f3..7c2599b35 100644 --- a/tests/test_orphan_visibility_fixes.py +++ b/tests/test_orphan_visibility_fixes.py @@ -1,10 +1,7 @@ -"""Adversarial-review fixes: orphan visibility and recovery-path honesty. +"""Adversarial-review fixes: orphan-recovery honesty and signal grading. -Three ways an agent was blinded to (or misled about) stranded on-chain exposure: +Two ways an agent was misled about what it was looking at: -- A SYSTEM_CLEANUP lp_executor row (RUNNING when the API restarted, rewritten - with no final state) matched none of the tick-prompt orphan branches, so the - agent redeployed on funds that were still staked. - format_clmm_result's position_info branch read camelCase keys against the API's snake_case CLMMPositionInfo fields, so every field — including the position address — rendered as None and "the position is gone" was a @@ -57,25 +54,6 @@ def _row(**overrides): return row -def test_system_cleanup_lp_row_surfaces_as_possible_orphan(): - """An API-restart row has no address — it must still warn, without one.""" - result = _run_provider([_row(close_type="SYSTEM_CLEANUP")]) - assert "POSSIBLE ORPHANED POSITION" in result.summary - assert "resolve_orphan" in result.summary - # No address is known, so no concrete close call may be suggested. - assert 'manage_clmm(action="close"' not in result.summary - assert result.data["orphaned_executors"] - - -def test_system_cleanup_non_lp_row_stays_silent(): - """Only lp_executors own on-chain positions — mirror the server's filter.""" - result = _run_provider( - [_row(close_type="SYSTEM_CLEANUP", type="position_executor")] - ) - assert "ORPHANED" not in result.summary - assert result.data["orphaned_executors"] == [] - - def test_address_bearing_orphan_still_emits_the_close_call(): result = _run_provider( [ @@ -92,13 +70,6 @@ def test_address_bearing_orphan_still_emits_the_close_call(): assert 'manage_clmm(action="close"' in result.summary -def test_resolved_system_cleanup_row_stays_silent(): - result = _run_provider( - [_row(close_type="SYSTEM_CLEANUP", custom_info={"orphan_resolved": True})] - ) - assert "ORPHANED" not in result.summary - - def test_position_info_formats_the_apis_snake_case_fields(): """The formatter must read what /gateway/clmm/positions_owned sends.""" from mcp_servers.hummingbot_api.formatters.gateway import format_clmm_result From 56f2a277b350208cfbd13affd7b662afb9d9eefe Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 15:51:42 -0700 Subject: [PATCH 17/65] =?UTF-8?q?fix(clmm):=20position=5Finfo=20lists=20th?= =?UTF-8?q?e=20wallet=20=E2=80=94=20pool=5Faddress=20was=20a=20Gateway=20n?= =?UTF-8?q?o-op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway's /trading/clmm/positions-owned has no pool filter (hapi's pool_address parameter was forwarded and silently ignored, returning the wallet's full list regardless), so the parameter is removed across the stack. position_info now requires nothing beyond connector+network, lists every position the wallet owns, and each row names its own pool. Lock bumped to the client commit that drops the parameter. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- mcp_servers/hummingbot_api/formatters/gateway.py | 15 ++++++--------- mcp_servers/hummingbot_api/guides/gateway_clmm.md | 7 ++++--- mcp_servers/hummingbot_api/schemas.py | 5 +++-- mcp_servers/hummingbot_api/tools/gateway_clmm.py | 4 ++-- tests/test_orphan_visibility_fixes.py | 2 +- uv.lock | 2 +- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/mcp_servers/hummingbot_api/formatters/gateway.py b/mcp_servers/hummingbot_api/formatters/gateway.py index 08e53c8fa..96cd4616c 100644 --- a/mcp_servers/hummingbot_api/formatters/gateway.py +++ b/mcp_servers/hummingbot_api/formatters/gateway.py @@ -227,17 +227,14 @@ def format_clmm_result(action: str, result: dict[str, Any]) -> str: if action == "position_info" and isinstance(payload, list): if not payload: - return ( - f"{header}\nPool: {result.get('pool_address')}\n" - "No open positions for this wallet in this pool." - ) - lines = [ - f"{header}", - f"Pool: {result.get('pool_address')} — {len(payload)} position(s)", - ] + return f"{header}\nNo open positions for this wallet on this connector." + lines = [f"{header}", f"{len(payload)} position(s) owned by this wallet"] for p in payload: lines.append( - f" • {p.get('position_address')} — Base: {p.get('base_token_amount')} " + f" • {p.get('position_address')} — Pool: {p.get('pool_address')}" + ) + lines.append( + f" Base: {p.get('base_token_amount')} " f"Quote: {p.get('quote_token_amount')} " f"Range: {p.get('lower_price')}–{p.get('upper_price')} " f"Price: {p.get('current_price')}" diff --git a/mcp_servers/hummingbot_api/guides/gateway_clmm.md b/mcp_servers/hummingbot_api/guides/gateway_clmm.md index 8c4b87f2f..9cf08c2b8 100644 --- a/mcp_servers/hummingbot_api/guides/gateway_clmm.md +++ b/mcp_servers/hummingbot_api/guides/gateway_clmm.md @@ -30,7 +30,7 @@ passed straight through. | Action | Required | Notes | |---|---|---| -| `position_info` | `pool_address` | Lists your positions in that pool with amounts, range, and uncollected fees | +| `position_info` | — | Lists ALL the wallet's positions on the connector (each row names its pool) with amounts, range, and uncollected fees | | `open` | `pool_address`, `lower_price`, `upper_price`, and at least one amount | Creates a position no executor tracks | | `add_liquidity` | `position_address`, at least one amount | Keeps the existing range | | `remove_liquidity` | `position_address`, `percentage_to_remove` | Partial withdrawal; the position account survives even at 100 | @@ -55,8 +55,9 @@ fees before the close so they can be reported. 1. `manage_executors(action="orphaned")` — each entry carries `lp_provider`, `pool_address`, `position_address`, and `connector_name` (which for an LP executor holds the *network*, e.g. `solana-mainnet-beta`, not the DEX). -2. Confirm it is really still on-chain: - `manage_clmm(action="position_info", connector=, network=, pool_address=)` +2. Confirm it is really still on-chain (lists every position the wallet owns; match by + `position_address` / `pool_address`): + `manage_clmm(action="position_info", connector=, network=)` 3. Close it: `manage_clmm(action="close", connector=, network=, position_address=, pool_address=)` 4. `manage_executors(action="resolve_orphan", executor_id="...")` so it stops being reported. diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index 8a39ed992..6d65eb2b7 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -740,9 +740,10 @@ class CLMMRequest(BaseModel): ) pool_address: str | None = Field( default=None, - description="Pool contract address. Required for open and position_info. Also required to close or " + description="Pool contract address. Required for open. Also required to close or " "collect fees on a position the API never recorded — every lp_executor position, since the " - "bot opens those straight against Gateway.", + "bot opens those straight against Gateway. Not used by position_info, which lists every " + "position the wallet owns.", ) position_address: str | None = Field( default=None, diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index 63c54ec3a..30baf27b0 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -273,11 +273,11 @@ async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: gc = None if client is None else client.gateway_clmm if action == "position_info": - _require_clmm(request, "pool_address") + # No pool filter: Gateway's positions-owned lists every CLMM position the + # wallet owns on the connector, each row carrying its own pool_address. result = await gc.get_positions_owned( connector=connector, network=net, - pool_address=request.pool_address, wallet_address=request.wallet_address, ) diff --git a/tests/test_orphan_visibility_fixes.py b/tests/test_orphan_visibility_fixes.py index 7c2599b35..2b4396476 100644 --- a/tests/test_orphan_visibility_fixes.py +++ b/tests/test_orphan_visibility_fixes.py @@ -80,10 +80,10 @@ def test_position_info_formats_the_apis_snake_case_fields(): "action": "position_info", "connector": "meteora/clmm", "network": "solana-mainnet-beta", - "pool_address": "PoolAddr", "result": [ { "position_address": "PosAddr123", + "pool_address": "PoolAddr", "base_token_amount": 1.5, "quote_token_amount": 200.0, "lower_price": 100.0, diff --git a/uv.lock b/uv.lock index d109d9dab..657033e54 100644 --- a/uv.lock +++ b/uv.lock @@ -1496,7 +1496,7 @@ wheels = [ [[package]] name = "hummingbot-api-client" version = "1.5.8" -source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#a3e6fff7286bc8c7e38fcbfb499254548b2ca58e" } +source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#ac2e54d4c6458fa28d685408349298659871255d" } dependencies = [ { name = "aiohttp" }, ] From cb138bf3fe4ff9367f52189f907edc564eb7a3f5 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 16:09:02 -0700 Subject: [PATCH 18/65] chore(deps): bump the client lock to the contract-gap commit Picks up quote_position, create_pool, and get_position_info on the gateway_clmm router (hummingbot-api-client@43a867d). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 657033e54..5ff203ca8 100644 --- a/uv.lock +++ b/uv.lock @@ -1496,7 +1496,7 @@ wheels = [ [[package]] name = "hummingbot-api-client" version = "1.5.8" -source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#ac2e54d4c6458fa28d685408349298659871255d" } +source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#43a867dfa9cc2f797d6a98d9491761375a0d8bb7" } dependencies = [ { name = "aiohttp" }, ] From cb0695be10d5e9353950ce5b4f0d970ee1fded47 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:37:13 -0700 Subject: [PATCH 19/65] fix(dex): align with hapi contracts; drop pool-scoped AMM swaps from MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Read transaction_hash (hapi's key) on swap/open/close/collect results; the tx_hash/txHash/signature fallbacks never matched and rendered N/A. - In-flight swap status is SUBMITTED (uppercase from search, lowercase from execute) — the old PENDING check rendered every pending swap as failed; status filter options updated to match the stored vocabulary. - Stop inverting BUY prices: hapi records quote-per-base for both sides. - Normalize side to uppercase at read so legacy lowercase rows display correctly. - Slippage convention end-to-end: omit ⇒ connector-configured slippage, 0 is a real value; drop the MCP schema's forced 1.0 default. - extra_params: swap tools expose approximateIfNoExactOut (routers); clmm add_liquidity passes strategyType like open; docs corrected to the accepted vocabulary (binStep/feeBps/ammConfigIndex/configAddress). - manage_clmm: add create_pool; remove_liquidity uses the SDK's renamed percentage_to_remove and passes slippage_pct (orca). - manage_amm: drop quote_swap/execute_swap — Gateway folded pool-scoped swaps into the unified /trading/swap route, and swaps go through the order executor regardless of connector type. - Lock the SDK at 4cea3f4 (query-param searches, honored slippage, percentage_to_remove). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- handlers/dex/_shared.py | 6 +- handlers/dex/lp_monitor_handlers.py | 25 +++----- handlers/dex/pools.py | 37 ++++++----- handlers/dex/swap.py | 46 +++++++------ .../hummingbot_api/formatters/gateway.py | 18 ++++-- .../hummingbot_api/guides/gateway_amm.md | 22 +++---- .../hummingbot_api/guides/gateway_clmm.md | 13 ++++ mcp_servers/hummingbot_api/schemas.py | 64 +++++++++++-------- mcp_servers/hummingbot_api/server.py | 50 +++++++-------- .../hummingbot_api/tools/gateway_amm.py | 40 ++---------- .../hummingbot_api/tools/gateway_clmm.py | 21 +++++- .../hummingbot_api/tools/gateway_swap.py | 16 ++++- tests/test_manage_clmm.py | 2 +- uv.lock | 2 +- 14 files changed, 194 insertions(+), 168 deletions(-) diff --git a/handlers/dex/_shared.py b/handlers/dex/_shared.py index ff8762f0b..34ce6fba3 100644 --- a/handlers/dex/_shared.py +++ b/handlers/dex/_shared.py @@ -313,14 +313,14 @@ def get_status_emoji(status: str) -> str: """Get emoji for swap status Args: - status: Status string (CONFIRMED, PENDING, FAILED, etc.) + status: Status string (CONFIRMED, SUBMITTED, FAILED, etc.) Returns: Emoji character """ status_emojis = { "CONFIRMED": "✅", - "PENDING": "⏳", + "SUBMITTED": "⏳", "FAILED": "❌", "REJECTED": "🚫", "UNKNOWN": "❓", @@ -462,7 +462,7 @@ def clear_dex_state(context) -> None: "swap": { "trading_pair": ["All", "SOL-USDC", "SOL-ORE", "ORE-USDC", "ETH-USDC"], "connector": ["All", "jupiter", "uniswap"], - "status": ["All", "CONFIRMED", "PENDING", "FAILED"], + "status": ["All", "CONFIRMED", "SUBMITTED", "FAILED"], }, "position": { "trading_pair": ["All", "SOL-USDC", "ORE-SOL", "METv-SOL"], diff --git a/handlers/dex/lp_monitor_handlers.py b/handlers/dex/lp_monitor_handlers.py index 535773eb7..32c213aa8 100644 --- a/handlers/dex/lp_monitor_handlers.py +++ b/handlers/dex/lp_monitor_handlers.py @@ -484,7 +484,7 @@ async def handle_lpm_collect_fees( ) if result: - tx_hash = (result.get("tx_hash", "") or "N/A")[:16] + tx_hash = (result.get("transaction_hash", "") or "N/A")[:16] await query.message.reply_text( f"✅ *Fees collected*\nTx: `{escape_markdown_v2(tx_hash)}...`", parse_mode="MarkdownV2", @@ -609,15 +609,10 @@ async def handle_lpm_rebalance_execute( logger.info(f"Close position result: {close_result}") - # Extract tx hash from various possible field names + # hapi returns transaction_hash (None while pending) close_tx = None if isinstance(close_result, dict): - close_tx = ( - close_result.get("tx_hash") - or close_result.get("txHash") - or close_result.get("signature") - or close_result.get("txSignature") - ) + close_tx = close_result.get("transaction_hash") close_tx_display = ( f"`{escape_markdown_v2(close_tx[:20])}...`" if close_tx else "_pending_" ) @@ -654,8 +649,9 @@ async def handle_lpm_rebalance_execute( parse_mode="MarkdownV2", ) - # Step 3: Open new position with same range using bid-ask strategy (type 2) - extra_params = {"strategyType": 2} # Bid-Ask strategy + # Step 3: Open new position with same range using bid-ask strategy (type 2). + # strategyType is Meteora-only; hapi rejects it for other connectors. + extra_params = {"strategyType": 2} if connector == "meteora" else None open_result = await client.gateway_clmm.open_position( connector=connector, @@ -680,15 +676,10 @@ async def handle_lpm_rebalance_execute( logger.info(f"Open position result: {open_result}") - # Extract tx hash + # hapi returns transaction_hash (None while pending) open_tx = None if isinstance(open_result, dict): - open_tx = ( - open_result.get("tx_hash") - or open_result.get("txHash") - or open_result.get("signature") - or open_result.get("txSignature") - ) + open_tx = open_result.get("transaction_hash") open_tx_display = ( f"`{escape_markdown_v2(open_tx[:20])}...`" if open_tx else "_pending_" ) diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index b66e94001..e34519021 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -3238,11 +3238,7 @@ async def handle_pos_collect_fees( if result: success_msg = f"✅ *Fees collected from {escape_markdown_v2(pair)}\\!*" if isinstance(result, dict): - tx_hash = ( - result.get("tx_hash") - or result.get("txHash") - or result.get("signature") - ) + tx_hash = result.get("transaction_hash") if tx_hash: success_msg += f"\n\nTx: `{tx_hash[:30]}...`" @@ -3423,10 +3419,8 @@ async def handle_pos_close_execute( pair = pos.get("trading_pair", "Unknown") success_msg = escape_markdown_v2(f"✅ Position closed: {pair}") - if isinstance(result, dict) and result.get("tx_hash"): - success_msg += ( - f"\n\nTx: `{escape_markdown_v2(result['tx_hash'][:20])}...`" - ) + if isinstance(result, dict) and result.get("transaction_hash"): + success_msg += f"\n\nTx: `{escape_markdown_v2(result['transaction_hash'][:20])}...`" keyboard = [[InlineKeyboardButton("« Back", callback_data="dex:liquidity")]] reply_markup = InlineKeyboardMarkup(keyboard) @@ -4984,8 +4978,8 @@ async def handle_pos_add_confirm( "Both amounts are 0. Need at least one token to add liquidity." ) - # Build extra_params for strategy type - extra_params = {"strategyType": strategy_type} + # strategyType is Meteora-only; hapi rejects it for other connectors + extra_params = {"strategyType": strategy_type} if connector == "meteora" else None result = await client.gateway_clmm.open_position( connector=connector, @@ -5029,12 +5023,21 @@ async def handle_pos_add_confirm( pos_info += escape_markdown_v2(f"Quote: {float(amount_quote):.6f}\n") if isinstance(result, dict): - if "tx_hash" in result: - pos_info += escape_markdown_v2(f"\nTx: {result['tx_hash'][:16]}...") - if "position_address" in result: + # transaction_hash is None while the open tx is still pending + if result.get("transaction_hash"): + pos_info += escape_markdown_v2( + f"\nTx: {result['transaction_hash'][:16]}..." + ) + # position_address is null when the tx was submitted but not yet + # confirmed — hapi's poller records the position once it lands + if result.get("position_address"): pos_info += escape_markdown_v2( f"\nPosition: {result['position_address'][:16]}..." ) + elif result.get("status") == "submitted": + pos_info += escape_markdown_v2( + "\nPosition: pending confirmation (address known once the tx lands)" + ) keyboard = [ [InlineKeyboardButton("« Back to Liquidity", callback_data="dex:liquidity")] @@ -5316,8 +5319,10 @@ async def process_add_position( f"Quote: {params['amount_quote']}\n" ) - if isinstance(result, dict) and "tx_hash" in result: - pos_info += escape_markdown_v2(f"\nTx: {result['tx_hash'][:16]}...") + if isinstance(result, dict) and result.get("transaction_hash"): + pos_info += escape_markdown_v2( + f"\nTx: {result['transaction_hash'][:16]}..." + ) keyboard = [ [InlineKeyboardButton("« Back to Liquidity", callback_data="dex:liquidity")] diff --git a/handlers/dex/swap.py b/handlers/dex/swap.py index 280fbc649..71b8097fa 100644 --- a/handlers/dex/swap.py +++ b/handlers/dex/swap.py @@ -72,7 +72,8 @@ def _format_compact_swap_line(swap: dict) -> str: Returns: "✅ SOL-USDC BUY @126.00 1.26 USDC 1d jup 🔗" """ pair = swap.get("trading_pair", "N/A") - side = swap.get("side", "?") + # Legacy rows written before hapi normalized casing may hold "buy"/"sell" + side = str(swap.get("side", "?")).upper() status = swap.get("status", "UNKNOWN") network = swap.get("network", "") tx_hash = swap.get("transaction_hash", "") @@ -85,16 +86,10 @@ def _format_compact_swap_line(swap: dict) -> str: price = swap.get("price") quote_token = swap.get("quote_token", "") - # Calculate display price (price of base in terms of quote) - # BUY: input=quote, output=base, price=base/quote -> display = 1/price - # SELL: input=base, output=quote, price=quote/base -> display = price + # Display price: hapi records price as quote-per-base for both sides price_display = "—" - if price is not None and price > 0: - if side == "BUY": - display_price = 1 / price - else: - display_price = price - price_display = f"@{format_compact_number(display_price)}" + if price is not None: + price_display = f"@{format_compact_number(price)}" # Quote amount (what was spent/received in quote token) # BUY: quote amount = input_amount (what we paid) @@ -108,9 +103,12 @@ def _format_compact_swap_line(swap: dict) -> str: # Relative time age = format_relative_time(timestamp) - # Status emoji (compact) + # Status emoji (compact) - hapi stores SUBMITTED for in-flight swaps + status_upper = str(status).upper() status_char = ( - "✅" if status == "CONFIRMED" else "⏳" if status == "PENDING" else "❌" + "✅" + if status_upper == "CONFIRMED" + else "⏳" if status_upper == "SUBMITTED" else "❌" ) # Build line: "✅ SOL-USDC BUY @126.00 1.26 USDC 1d jup 🔗" @@ -1166,8 +1164,8 @@ async def handle_swap_execute_confirm( ) if isinstance(result, dict): - if "tx_hash" in result: - tx_short = result["tx_hash"][:16] + "..." + if result.get("transaction_hash"): + tx_short = result["transaction_hash"][:16] + "..." swap_info += escape_markdown_v2(f"\nTx: {tx_short}") if "status" in result: swap_info += escape_markdown_v2(f"\nStatus: {result['status']}") @@ -1260,16 +1258,20 @@ def _format_detailed_swap_line(swap: dict) -> str: Returns formatted line (not escaped) """ pair = swap.get("trading_pair", "N/A") - side = swap.get("side", "?") + # Legacy rows written before hapi normalized casing may hold "buy"/"sell" + side = str(swap.get("side", "?")).upper() status = swap.get("status", "UNKNOWN") network = swap.get("network", "") tx_hash = swap.get("transaction_hash", "") connector = swap.get("connector", "") timestamp = swap.get("timestamp", "") - # Status emoji + # Status emoji - hapi stores SUBMITTED for in-flight swaps + status_upper = str(status).upper() status_emoji = ( - "✅" if status == "CONFIRMED" else "⏳" if status == "PENDING" else "❌" + "✅" + if status_upper == "CONFIRMED" + else "⏳" if status_upper == "SUBMITTED" else "❌" ) # Get amounts and tokens @@ -1296,14 +1298,10 @@ def _format_detailed_swap_line(swap: dict) -> str: f" Out: {format_compact_number(output_amount)} {quote_token}" ) - # Price - if price is not None and price > 0: - if side == "BUY": - display_price = 1 / price - else: - display_price = price + # Price: hapi records price as quote-per-base for both sides + if price is not None: lines.append( - f" Price: {format_compact_number(display_price, 4)} {quote_token}/{base_token}" + f" Price: {format_compact_number(price, 4)} {quote_token}/{base_token}" ) # Metadata diff --git a/mcp_servers/hummingbot_api/formatters/gateway.py b/mcp_servers/hummingbot_api/formatters/gateway.py index 96cd4616c..0c6c016c2 100644 --- a/mcp_servers/hummingbot_api/formatters/gateway.py +++ b/mcp_servers/hummingbot_api/formatters/gateway.py @@ -196,20 +196,19 @@ def format_amm_result(action: str, result: dict[str, Any]) -> str: ) return "\n".join(lines) - if action in ("quote_swap", "quote_liquidity") and isinstance(payload, dict): + if action == "quote_liquidity" and isinstance(payload, dict): return f"{header}\n{payload}" if action in ( - "execute_swap", "add_liquidity", "remove_liquidity", "create_pool", ) and isinstance(payload, dict): - sig = payload.get("signature") + tx = payload.get("transaction_hash") extra = "" if action == "create_pool": extra = f"\nPool: {payload.get('pool_address')} Seed price: {payload.get('price')}" - return f"{header}\nSignature/Tx: {sig} Status: {payload.get('status')}{extra}" + return f"{header}\nTx: {tx} Status: {payload.get('status')}{extra}" return f"{header}\n{payload}" @@ -265,11 +264,20 @@ def format_clmm_result(action: str, result: dict[str, Any]) -> str: ) if action in ("add_liquidity", "remove_liquidity") and isinstance(payload, dict): - tx = payload.get("transaction_hash") or payload.get("signature") + tx = payload.get("transaction_hash") return ( f"{header}\n" f"Position: {result.get('position_address')}\n" f"Tx: {tx} Status: {payload.get('status')}" ) + if action == "create_pool" and isinstance(payload, dict): + return ( + f"{header}\n" + f"Pool: {payload.get('pool_address')}\n" + f"Tx: {payload.get('transaction_hash')} Status: {payload.get('status')}\n" + "The pool is created empty — add liquidity by opening a position " + "(action='open' or manage_executors lp_executor)." + ) + return f"{header}\n{payload}" diff --git a/mcp_servers/hummingbot_api/guides/gateway_amm.md b/mcp_servers/hummingbot_api/guides/gateway_amm.md index 6200c6a11..1e49842f7 100644 --- a/mcp_servers/hummingbot_api/guides/gateway_amm.md +++ b/mcp_servers/hummingbot_api/guides/gateway_amm.md @@ -19,15 +19,13 @@ through `manage_executors(order_executor)`; CLMM LP goes through `manage_executo | `pool_info` | read | `pool_address` | | `position_info` | read | `pool_address` (returns aggregate + `positions[]`) | | `positions_owned` | read | — (**meteora only**; fungible-LP → error) | -| `quote_swap` | read | `pool_address`, `base_token`, `side`, `amount` | -| `execute_swap` | write | `pool_address`, `base_token`, `side`, `amount` | | `quote_liquidity` | read | `pool_address`, `base_token_amount`, `quote_token_amount` | | `add_liquidity` | write | `pool_address`, `base_token_amount`, `quote_token_amount` (+ optional `position_address`) | | `remove_liquidity` | write | `pool_address`, `percentage_to_remove` (+ **`position_address` required for meteora**) | | `create_pool` | write | `base_token`, `quote_token`, `base_token_amount` (+ connector extra) | -`quote_swap`/`execute_swap` are **pool-scoped** (they trade against the specific `pool_address`), not -a router. Use `explore_dex_pools`/`pool_info` to find a pool. +Swaps are not part of this tool: the unified swap route handles every connector type +(pass connector as `name/type`, e.g. `raydium/amm`) — via the order executor for trading. ## Meteora DAMM v2 position model (important) DAMM v2 positions are **NFTs**: a wallet may hold **several positions in one pool**, each an @@ -51,12 +49,14 @@ Seed price priority: `initial_price` (quote per base) → `quote_token_amount` r price** (fetched from the swap router so the pool opens on-market and bots can't arb your seed). Only `base_token_amount` is required. -Per-connector `create_pool` extras (only the owning connector consumes them): -- **meteora**: `config_address` (**required**) — the DAMM v2 config account that fixes the fee - schedule. Many configs are token-launch configs whose base fee starts near **99%** and decays; - pick a static-fee config you actually want. Token order: `base_token` → token A, `quote_token` → token B. -- **raydium**: `fee_config_index` (optional; defaults to the first available config). -- **uniswap**: `gas_price` / `max_gas` (optional; fee is fixed at 0.30%). +Per-connector `create_pool` extras ride `extra_params` under Gateway's own names (only the +owning connector consumes them; unknown keys are rejected): +- **meteora**: `extra_params={"configAddress": ...}` (**required**) — the DAMM v2 config account + that fixes the fee schedule. Many configs are token-launch configs whose base fee starts near + **99%** and decays; pick a static-fee config you actually want. Token order: `base_token` → + token A, `quote_token` → token B. +- **raydium**: `ammConfigIndex` (optional; defaults to the first available config). +- **uniswap** (EVM): no extra params (fee is fixed at 0.30%). ## Examples - Load this guide: `manage_amm()` @@ -65,4 +65,4 @@ Per-connector `create_pool` extras (only the owning connector consumes them): - Open a new Meteora position: `manage_amm(action="add_liquidity", connector="meteora", network="solana-mainnet-beta", pool_address="…", base_token_amount="1", quote_token_amount="2")` (omit `position_address`) - Exit a specific position: `manage_amm(action="remove_liquidity", connector="meteora", network="solana-mainnet-beta", pool_address="…", position_address="…", percentage_to_remove="100")` - Create a Raydium CPMM pool (market-seeded): `manage_amm(action="create_pool", connector="raydium", network="solana-mainnet-beta", base_token="SOL", quote_token="USDC", base_token_amount="1")` -- Create a Meteora DAMM v2 pool: `manage_amm(action="create_pool", connector="meteora", network="solana-mainnet-beta", base_token="…", quote_token="USDC", base_token_amount="1", config_address="…")` +- Create a Meteora DAMM v2 pool: `manage_amm(action="create_pool", connector="meteora", network="solana-mainnet-beta", base_token="…", quote_token="USDC", base_token_amount="1", extra_params={"configAddress": "…"})` diff --git a/mcp_servers/hummingbot_api/guides/gateway_clmm.md b/mcp_servers/hummingbot_api/guides/gateway_clmm.md index 9cf08c2b8..000bdf918 100644 --- a/mcp_servers/hummingbot_api/guides/gateway_clmm.md +++ b/mcp_servers/hummingbot_api/guides/gateway_clmm.md @@ -36,10 +36,23 @@ passed straight through. | `remove_liquidity` | `position_address`, `percentage_to_remove` | Partial withdrawal; the position account survives even at 100 | | `close` | `position_address` | Withdraws everything, collects fees, closes the account | | `collect_fees` | `position_address` | Fees only; position untouched | +| `create_pool` | `base_token`, `quote_token` | Creates a new EMPTY pool; `initial_price` optional (market price fetched when omitted) | `remove_liquidity` at 100% and `close` are not the same thing: the former leaves an empty position open, the latter closes the account. To recover an orphan, use `close`. +## create_pool + +Creates a new (empty) CLMM pool — liquidity is added afterwards by opening positions. `initial_price` +is quote per base and optional: when omitted, the API fetches the live market price. Connector-specific +params ride `extra_params` under Gateway's own names (unknown keys are rejected with a 400): + +- **meteora / orca**: `binStep` +- **meteora / uniswap / pancakeswap**: `feeBps` +- **raydium / pancakeswap-sol**: `ammConfigIndex` + +Example: `manage_clmm(action="create_pool", connector="meteora", network="solana-mainnet-beta", base_token="SOL", quote_token="USDC", extra_params={"binStep": 20, "feeBps": 20})` + ## pool_address on close and collect_fees The API reads a position's pool from its own database. Positions opened by an `lp_executor` are diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index 6d65eb2b7..0a51e9f21 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -441,9 +441,17 @@ class GatewaySwapRequest(BaseModel): ) slippage_pct: str | None = Field( - default="1.0", - description="Maximum slippage percentage (optional, default: 1.0). " - "Example: '1.5' for 1.5% slippage tolerance", + default=None, + description="Maximum slippage percentage (optional). " + "Omit to use the connector's configured slippage. " + "Example: '1.5' for 1.5% slippage tolerance ('0' is a real value, not 'use default')", + ) + + extra_params: dict[str, Any] | None = Field( + default=None, + description="Connector-specific parameters for quote/execute (optional). " + "Supported key: 'approximateIfNoExactOut' (bool, jupiter/dflow/okx/titan routers only) - " + "allow an approximate quote when no exact-out route exists", ) # Execute-specific parameter @@ -610,8 +618,6 @@ class AMMRequest(BaseModel): "pool_info", "position_info", "positions_owned", - "quote_swap", - "execute_swap", "quote_liquidity", "add_liquidity", "remove_liquidity", @@ -641,21 +647,14 @@ class AMMRequest(BaseModel): description="Meteora NFT position: REQUIRED for remove_liquidity, optional for add_liquidity (omit = open a new position). Ignored by fungible-LP AMMs.", ) - # Swap params + # Token params base_token: str | None = Field( - default=None, - description="Base token symbol or address (swap direction / pool base)", + default=None, description="Base token symbol or address (create_pool)" ) quote_token: str | None = Field( default=None, description="Quote token symbol or address (pool quote, for create_pool)", ) - amount: str | None = Field( - default=None, description="Swap amount (as string; parsed to Decimal)" - ) - side: Literal["BUY", "SELL"] | None = Field( - default=None, description="Swap direction" - ) slippage_pct: str | None = Field( default=None, description="Maximum slippage percentage (as string)" ) @@ -679,20 +678,12 @@ class AMMRequest(BaseModel): default=None, description="Initial price as quote per base (create_pool; overrides quote_token_amount)", ) - config_address: str | None = Field( - default=None, - description="Meteora DAMM v2 config account address (required for meteora create_pool)", - ) - fee_config_index: int | None = Field( - default=None, - description="Raydium CPMM fee config index (optional, create_pool)", - ) - gas_price: str | None = Field( + extra_params: dict[str, Any] | None = Field( default=None, - description="Uniswap (EVM) gas price in gwei (optional, create_pool)", - ) - max_gas: int | None = Field( - default=None, description="Uniswap (EVM) max gas limit (optional, create_pool)" + description="Connector-specific create_pool params under Gateway's own names: " + "configAddress (meteora DAMM v2, required there), ammConfigIndex (raydium CPMM). " + "uniswap/pancakeswap (EVM) AMM take no extra params. Unknown keys are " + "rejected by the API with a 400.", ) @@ -718,6 +709,7 @@ class CLMMRequest(BaseModel): "remove_liquidity", "close", "collect_fees", + "create_pool", ] | None ) = Field( @@ -773,7 +765,23 @@ class CLMMRequest(BaseModel): slippage_pct: str | None = Field( default=None, description="Maximum slippage percentage (as string)" ) + + # create_pool params + base_token: str | None = Field( + default=None, description="Base token symbol or address (create_pool)" + ) + quote_token: str | None = Field( + default=None, description="Quote token symbol or address (create_pool)" + ) + initial_price: str | None = Field( + default=None, + description="Initial pool price as quote per base (create_pool, optional — " + "the API fetches the market price when omitted)", + ) extra_params: dict[str, Any] | None = Field( default=None, - description="Connector-specific parameters for open (e.g. {'strategyType': 0} for Meteora DLMM)", + description="Connector-specific parameters under Gateway's own names. " + "open/add_liquidity: strategyType (meteora DLMM, e.g. {'strategyType': 0}). " + "create_pool: binStep (meteora/orca), feeBps (meteora/uniswap/pancakeswap), " + "ammConfigIndex (raydium/pancakeswap-sol). Unknown keys are rejected by the API with a 400.", ) diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index 90def1dbf..8c75dfe60 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -876,8 +876,6 @@ async def manage_amm( "pool_info", "position_info", "positions_owned", - "quote_swap", - "execute_swap", "quote_liquidity", "add_liquidity", "remove_liquidity", @@ -892,17 +890,12 @@ async def manage_amm( position_address: str | None = None, base_token: str | None = None, quote_token: str | None = None, - amount: str | None = None, - side: Literal["BUY", "SELL"] | None = None, slippage_pct: str | None = None, base_token_amount: str | None = None, quote_token_amount: str | None = None, percentage_to_remove: str | None = None, initial_price: str | None = None, - config_address: str | None = None, - fee_config_index: int | None = None, - gas_price: str | None = None, - max_gas: int | None = None, + extra_params: dict[str, Any] | None = None, ) -> str: """Direct AMM pool operations + pool creation, chain- & DEX-agnostic (Meteora / Raydium / Uniswap). @@ -912,7 +905,6 @@ async def manage_amm( Actions: - pool_info / position_info → read pool reserves/price/fee; read your position (aggregate + positions[]) - positions_owned → list ALL your positions across pools (meteora only) - - quote_swap / execute_swap → quote/execute a swap AGAINST a specific AMM pool (pool-scoped, not router) - quote_liquidity / add_liquidity / remove_liquidity → two-sided LP in/out - create_pool → create + seed a new pool (market-price seeded by default; anti-snipe) @@ -923,8 +915,9 @@ async def manage_amm( returns a positions[] breakdown, positions_owned lists all your positions. Fungible-LP AMMs (raydium, uniswap) ignore position_address and have no enumerable positions. - create_pool extras by connector: meteora→config_address (required); raydium→fee_config_index - (optional); uniswap→gas_price/max_gas (optional, 0.30% fixed fee). + create_pool extras ride extra_params under Gateway's own names: meteora→configAddress + (required); raydium→ammConfigIndex (optional); uniswap/pancakeswap (EVM)→none + (0.30% fixed fee). Unknown keys are rejected with a 400. Scope: AMM only. Router/one-shot swaps → manage_executors(order_executor); CLMM LP → manage_executors(lp_executor). @@ -936,19 +929,16 @@ async def manage_amm( wallet_address: Wallet address (optional, uses default if not provided). pool_address: Pool contract address. position_address: Meteora NFT position — required for remove_liquidity, optional for add_liquidity (omit = new position). - base_token: Base token symbol or address (swap direction / pool base). + base_token: Base token symbol or address (create_pool). quote_token: Quote token symbol or address (pool quote, for create_pool). - amount: Swap amount (string). - side: Swap direction (BUY or SELL). slippage_pct: Maximum slippage percentage (string). base_token_amount: Base token amount (add_liquidity / quote_liquidity / create_pool). quote_token_amount: Quote token amount (add_liquidity / quote_liquidity / create_pool). percentage_to_remove: Percentage of liquidity to remove, 0-100 (remove_liquidity). initial_price: Initial price as quote per base (create_pool; overrides quote_token_amount). - config_address: Meteora DAMM v2 config account address (required for meteora create_pool). - fee_config_index: Raydium CPMM fee config index (optional, create_pool). - gas_price: Uniswap (EVM) gas price in gwei (optional, create_pool). - max_gas: Uniswap (EVM) max gas limit (optional, create_pool). + extra_params: Connector-specific create_pool params under Gateway's own names: + configAddress (meteora, required), ammConfigIndex (raydium). Unknown keys + are rejected with a 400. """ request = AMMRequest( action=action, @@ -959,17 +949,12 @@ async def manage_amm( position_address=position_address, base_token=base_token, quote_token=quote_token, - amount=amount, - side=side, slippage_pct=slippage_pct, base_token_amount=base_token_amount, quote_token_amount=quote_token_amount, percentage_to_remove=percentage_to_remove, initial_price=initial_price, - config_address=config_address, - fee_config_index=fee_config_index, - gas_price=gas_price, - max_gas=max_gas, + extra_params=extra_params, ) client = await hummingbot_client.get_client() @@ -988,6 +973,7 @@ async def manage_clmm( "remove_liquidity", "close", "collect_fees", + "create_pool", ] | None ) = None, @@ -1002,6 +988,9 @@ async def manage_clmm( quote_token_amount: str | None = None, percentage_to_remove: str | None = None, slippage_pct: str | None = None, + base_token: str | None = None, + quote_token: str | None = None, + initial_price: str | None = None, extra_params: dict | None = None, ) -> str: """Direct CLMM position operations, chain- & DEX-agnostic (Meteora / Raydium / Orca / Uniswap / PancakeSwap). @@ -1022,6 +1011,7 @@ async def manage_clmm( - add_liquidity / remove_liquidity → resize an existing position, keeping its range - close → withdraw everything, collect fees, close the account - collect_fees → fees only, position untouched + - create_pool → create a new (empty) CLMM pool; liquidity is added by opening positions remove_liquidity at 100% leaves an EMPTY POSITION OPEN; only `close` closes the account. To recover an orphan, use `close`. @@ -1048,7 +1038,14 @@ async def manage_clmm( quote_token_amount: Quote token amount (open / add_liquidity). percentage_to_remove: Percentage of liquidity to remove, 0-100 (remove_liquidity). slippage_pct: Maximum slippage percentage. - extra_params: Connector-specific params for open (e.g. {"strategyType": 0} for Meteora DLMM). + base_token: Base token symbol or address (create_pool). + quote_token: Quote token symbol or address (create_pool). + initial_price: Initial pool price as quote per base (create_pool, optional — + the API fetches the market price when omitted). + extra_params: Connector-specific params under Gateway's own names. + open/add_liquidity: strategyType (meteora DLMM, e.g. {"strategyType": 0}). + create_pool: binStep (meteora/orca), feeBps (meteora/uniswap/pancakeswap), + ammConfigIndex (raydium/pancakeswap-sol). Unknown keys are rejected with a 400. """ request = CLMMRequest( action=action, @@ -1063,6 +1060,9 @@ async def manage_clmm( quote_token_amount=quote_token_amount, percentage_to_remove=percentage_to_remove, slippage_pct=slippage_pct, + base_token=base_token, + quote_token=quote_token, + initial_price=initial_price, extra_params=extra_params, ) diff --git a/mcp_servers/hummingbot_api/tools/gateway_amm.py b/mcp_servers/hummingbot_api/tools/gateway_amm.py index 6a9dc7a9b..b9d55d122 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_amm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_amm.py @@ -101,31 +101,6 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: connector=connector, network=net, wallet_address=request.wallet_address ) - elif action == "quote_swap": - _require(request, "pool_address", "base_token", "side", "amount") - result = await ga.get_swap_quote( - connector=connector, - network=net, - pool_address=request.pool_address, - base_token=request.base_token, - side=request.side, - amount=_dec(request.amount, "amount"), - slippage_pct=_opt_dec(request.slippage_pct), - ) - - elif action == "execute_swap": - _require(request, "pool_address", "base_token", "side", "amount") - result = await ga.execute_swap( - connector=connector, - network=net, - pool_address=request.pool_address, - base_token=request.base_token, - side=request.side, - amount=_dec(request.amount, "amount"), - slippage_pct=_opt_dec(request.slippage_pct), - wallet_address=request.wallet_address, - ) - elif action == "quote_liquidity": _require(request, "pool_address", "base_token_amount", "quote_token_amount") result = await ga.get_liquidity_quote( @@ -173,11 +148,13 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: elif action == "create_pool": _require(request, "base_token", "quote_token", "base_token_amount") - if connector == "meteora" and not request.config_address: + if connector == "meteora" and not (request.extra_params or {}).get( + "configAddress" + ): raise ToolError( - "config_address is required for meteora create_pool (DAMM v2 pools are created against a " - "config account that fixes the fee schedule). Discover configs via the Meteora app/SDK; " - "avoid token-launch configs whose base fee starts near 99%." + 'extra_params={"configAddress": ...} is required for meteora create_pool (DAMM v2 pools ' + "are created against a config account that fixes the fee schedule). Discover configs via " + "the Meteora app/SDK; avoid token-launch configs whose base fee starts near 99%." ) result = await ga.create_pool( connector=connector, @@ -187,11 +164,8 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: base_token_amount=_dec(request.base_token_amount, "base_token_amount"), quote_token_amount=_opt_dec(request.quote_token_amount), initial_price=_opt_dec(request.initial_price), - config_address=request.config_address, - fee_config_index=request.fee_config_index, - gas_price=_opt_dec(request.gas_price), - max_gas=request.max_gas, wallet_address=request.wallet_address, + extra_params=request.extra_params, ) else: diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index 30baf27b0..1beca9c1a 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -3,7 +3,7 @@ Two tools live here: - explore_gateway_clmm_pools: read-only pool discovery (list pools, get pool info) -- manage_clmm: direct position operations (open, add, remove, close, collect fees) +- manage_clmm: direct position operations (open, add, remove, close, collect fees, create pool) The managed path for normal LP work is `manage_executors` with `lp_executor`, which owns range monitoring, rebalancing and close retries. manage_clmm is the direct path, and the only way to @@ -314,6 +314,7 @@ async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: quote_token_amount=_opt_dec(request.quote_token_amount), slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, + extra_params=request.extra_params, ) elif action == "remove_liquidity": @@ -322,7 +323,10 @@ async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: connector=connector, network=net, position_address=request.position_address, - percentage=_dec(request.percentage_to_remove, "percentage_to_remove"), + percentage_to_remove=_dec( + request.percentage_to_remove, "percentage_to_remove" + ), + slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, ) @@ -346,6 +350,19 @@ async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: wallet_address=request.wallet_address, ) + elif action == "create_pool": + _require_clmm(request, "base_token", "quote_token") + result = await gc.create_pool( + connector=connector, + network=net, + base_token=request.base_token, + quote_token=request.quote_token, + # None -> hapi/gateway fetch the market price + initial_price=_opt_dec(request.initial_price), + wallet_address=request.wallet_address, + extra_params=request.extra_params, + ) + else: raise ToolError(f"Unknown action: {action}") diff --git a/mcp_servers/hummingbot_api/tools/gateway_swap.py b/mcp_servers/hummingbot_api/tools/gateway_swap.py index 3843ef8a8..4336e599d 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_swap.py +++ b/mcp_servers/hummingbot_api/tools/gateway_swap.py @@ -60,7 +60,13 @@ async def manage_gateway_swaps( trading_pair=request.trading_pair, side=request.side, amount=Decimal(request.amount), - slippage_pct=Decimal(request.slippage_pct or "1.0"), + # None -> SDK omits it and the connector's configured slippage applies + slippage_pct=( + Decimal(request.slippage_pct) + if request.slippage_pct is not None + else None + ), + extra_params=request.extra_params, ) return { @@ -99,8 +105,14 @@ async def manage_gateway_swaps( trading_pair=request.trading_pair, side=request.side, amount=Decimal(request.amount), - slippage_pct=Decimal(request.slippage_pct or "1.0"), + # None -> SDK omits it and the connector's configured slippage applies + slippage_pct=( + Decimal(request.slippage_pct) + if request.slippage_pct is not None + else None + ), wallet_address=request.wallet_address, + extra_params=request.extra_params, ) return { diff --git a/tests/test_manage_clmm.py b/tests/test_manage_clmm.py index 7b6e781f6..c6188f20a 100644 --- a/tests/test_manage_clmm.py +++ b/tests/test_manage_clmm.py @@ -216,4 +216,4 @@ def test_remove_liquidity_passes_percentage_as_decimal(): ) _, kwargs = client.gateway_clmm.calls[0] - assert kwargs["percentage"] == Decimal("50") + assert kwargs["percentage_to_remove"] == Decimal("50") diff --git a/uv.lock b/uv.lock index 5ff203ca8..e163b13dd 100644 --- a/uv.lock +++ b/uv.lock @@ -1496,7 +1496,7 @@ wheels = [ [[package]] name = "hummingbot-api-client" version = "1.5.8" -source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#43a867dfa9cc2f797d6a98d9491761375a0d8bb7" } +source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#4cea3f4927b530fca073c2f2d8f444f3d8d9388b" } dependencies = [ { name = "aiohttp" }, ] From f5d1d70a8c2da9324d40d7f753d2b5d7ef151bbd Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 23:06:47 -0700 Subject: [PATCH 20/65] fix(agents): gate LP writes; register the swap tool; stop forcing 1% slippage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation and dry-run gates named tools that do not exist — manage_gateway_clmm (registered as manage_clmm) with actions open_position/close_position (registered as open/close), and manage_amm was absent entirely. A gate name that matches nothing does not fail; it stops gating, so every AMM and CLMM write executed unconfirmed and, in dry-run mode, executed for real. The gates now name the registered tools and their real action literals, and a test asserts every gate name resolves against the tool registry so this drift cannot recur silently. manage_gateway_swaps was imported but never registered, so agents had no swap surface at all — which is what the removed manage_amm swap actions had been standing in for. It is registered now, and the launch-LP playbooks point at it with the connector's name/type form, including the caveat that the unified route resolves pools by symbol so 'no pool found' on a fresh mint is not a honeypot verdict. Slippage was forced to 1% from the preferences layer, so the stack-wide 'omit to use the connector's configured slippage' contract could never be expressed from the UI. It is omitted unless the user sets one, and an explicit 0 survives as a real value. Gateway errors are no longer swallowed into 'No quotes available': the status and message now reach the user, per-side, and a quote-denominated amount raises instead of silently trading the dollar figure as base units. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- agents/meteora_launch_lp/AGENT.md | 29 +- .../routines/launch_safety_check.py | 12 +- .../skills/launch_safety_check/SKILL.md | 19 +- .../strategies/launch_lp_operator/strategy.md | 16 +- condor/agents/risk.py | 18 +- condor/preferences.py | 24 +- handlers/agents/_shared.py | 26 +- handlers/dex/geckoterminal.py | 2 +- handlers/dex/swap.py | 408 +++++++++++------- mcp_servers/hummingbot_api/server.py | 104 +++++ tests/test_dangerous_gate_names_resolve.py | 106 +++++ tests/test_dex_swap_slippage_and_errors.py | 188 ++++++++ tests/test_mcp_gateway_swap_tool.py | 86 ++++ 13 files changed, 843 insertions(+), 195 deletions(-) create mode 100644 tests/test_dangerous_gate_names_resolve.py create mode 100644 tests/test_dex_swap_slippage_and_errors.py create mode 100644 tests/test_mcp_gateway_swap_tool.py diff --git a/agents/meteora_launch_lp/AGENT.md b/agents/meteora_launch_lp/AGENT.md index 6bcfc2c4b..306a0756d 100644 --- a/agents/meteora_launch_lp/AGENT.md +++ b/agents/meteora_launch_lp/AGENT.md @@ -7,6 +7,7 @@ description: Early-liquidity specialist for tokens graduating from launchpads in agent_key: claude-acp:sonnet tools: - manage_amm +- manage_gateway_swaps - explore_dex_pools - get_portfolio_overview - get_market_data @@ -34,13 +35,32 @@ harvest **fee yield on established DAMM v2 pools** between graduations. You act `manage_amm` — there is no AMM executor, so you are stateless and **your journal is the source of truth** for every position you hold (record each `position_address`). -Scope is **AMM only**. Router/one-shot swaps → `manage_executors(order_executor)`. CLMM/DLMM LP → -`manage_executors(lp_executor)` / the Solana DEX LP agent. Never reach for those here. +LP scope is **AMM only**. CLMM/DLMM LP → `manage_executors(lp_executor)` / the Solana DEX LP agent. +Never reach for those here. ## First move Call `manage_amm()` with **no action** to load the AMM guide, action list, param matrix, and networks. Re-read it whenever unsure — it is authoritative over this file. +## Swaps: `manage_gateway_swaps`, not `manage_amm` +`manage_amm` has **no swap actions** — it does LP only (`pool_info`, `position_info`, +`positions_owned`, `quote_liquidity`, `add_liquidity`, `remove_liquidity`, `create_pool`). Every +swap — pricing a sellability round-trip, acquiring the base token, dumping dust back to SOL — goes +through **`manage_gateway_swaps`** with `action="quote"` or `action="execute"`. + +`connector` is **"name/type"**: `"meteora/amm"` for a DAMM v2 pool, `"jupiter/router"` for +aggregator routing across everything on Solana. `network="solana-mainnet-beta"`. +**Omit `slippage_pct`** unless you have a reason — the connector's configured slippage applies. + +**Pool resolution caveat — read a failure correctly.** With `"meteora/amm"` Gateway resolves the +pool from **its own configured pool list, matched by token SYMBOL** in `trading_pair`; you cannot +pass a `pool_address`. A **freshly launched token is not in that list**, and creating a pool (here +or by a launchpad graduation) does **not** register it. So `"No pool found"` on a brand-new +graduation means **Gateway doesn't know the token** — it is NOT evidence of a honeypot and NOT a +failed safety check. Retry the same quote through `"jupiter/router"`, which prices off live +on-chain routes. Only a quote that actually *returns* and looks wrong (≈0 out, absurd or +asymmetric price impact) is a sellability failure. + ## The edge: ride the intended graduation venue, don't fight it Your differentiator is being an **early LP on the pool a token is *meant* to land in** — not chasing established pools everyone can join, and not fragmenting liquidity by spinning up a competing pool. @@ -70,7 +90,7 @@ agent-local routines — **`easya_graduation_monitor`** (detect), **`launch_safe ## Per-tick loop (delegated / loop mode) 1. **Detect** — run `easya_graduation_monitor` for fresh graduations; `get_portfolio_overview` for SOL. 2. **Gate** — run the **`launch_safety_check` skill**: sellability (honeypot) round-trip via - `manage_amm(quote_swap)` both directions, the **`launch_safety_check` routine** (mint/freeze + `manage_gateway_swaps(action="quote")` both directions, the **`launch_safety_check` routine** (mint/freeze authority, holder concentration, LP lock, TVL — objective on-chain checks), and real post-graduation demand (skip the graduation candle; wait for the dump to clear). Reject on ANY fail. 3. **Size & enter** — early LP is **directional long the token** (you must BUY the base token to pair @@ -95,7 +115,8 @@ When no graduation clears the gates, deploy idle quote capital into established by `damm_v2_scanner` (liquid, verified, high `fee_tvl_ratio`, static fee), same monitor/exit brain. ## Discipline -- **Quote before every write.** Size from `quote_liquidity` / `quote_swap`, never guesses. +- **Quote before every write.** Size from `manage_amm(quote_liquidity)` / + `manage_gateway_swaps(action="quote")`, never guesses. - **Never LP at the graduation candle.** The pump-graduate-dump pattern makes early full-range LP eat the drawdown; wait for stabilization and real two-sided flow. - **Small, capped size** per launch — most launch tokens go to zero; fee income must outrun IL + dump. diff --git a/agents/meteora_launch_lp/routines/launch_safety_check.py b/agents/meteora_launch_lp/routines/launch_safety_check.py index ed228db0e..92d1e8ec8 100644 --- a/agents/meteora_launch_lp/routines/launch_safety_check.py +++ b/agents/meteora_launch_lp/routines/launch_safety_check.py @@ -5,9 +5,9 @@ concentration** (Solana RPC), plus **freeze-authority disabled**, **verified**, **LP lock**, and **TVL** (from the Meteora DAMM v2 data API). Returns a PASS/FAIL verdict with per-gate reasons. -It does NOT check sellability (honeypot) — that needs a live two-way quote through the pool, which -the agent does itself with manage_amm(quote_swap, side=SELL) + (side=BUY). See the launch_safety_check -SKILL. This routine covers the deterministic on-chain/static gates only. +It does NOT check sellability (honeypot) — that needs a live two-way quote, which the agent does +itself with manage_gateway_swaps(action="quote", connector="meteora/amm", side=SELL) + (side=BUY). +See the launch_safety_check SKILL. This routine covers the deterministic on-chain/static gates only. RPC: defaults to the public mainnet endpoint (rate-limited but fine for occasional checks); override with rpc_url for a private endpoint. No credentials are hardcoded. @@ -194,6 +194,10 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: lines.append("") lines.append( "NOTE: sellability (honeypot) is NOT checked here — round-trip a SELL and BUY quote via " - "manage_amm(quote_swap) before entering. See the launch_safety_check skill." + 'manage_gateway_swaps(action="quote", connector="meteora/amm", ' + 'network="solana-mainnet-beta", trading_pair="-") before entering. ' + 'A "No pool found" error means Gateway does not know this token (it matches its own ' + "configured pool list by SYMBOL, and a fresh mint is not in it) — that is NOT a honeypot " + 'verdict; retry via connector="jupiter/router". See the launch_safety_check skill.' ) return "\n".join(lines) diff --git a/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md b/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md index 1161183df..14787a31d 100644 --- a/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md +++ b/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md @@ -19,14 +19,25 @@ always another graduation. ## Gate order (cheapest / most-disqualifying first) ### 1. Sellability (honeypot) — do this FIRST, it's the killer -A token you can buy but can't sell is a total loss. Round-trip a quote through the pool: -- `manage_amm(action="quote_swap", connector="meteora", network="solana-mainnet-beta", pool_address=, base_token=, side="SELL", amount=)` +A token you can buy but can't sell is a total loss. Round-trip a quote through +**`manage_gateway_swaps`** (`manage_amm` has no swap actions — it is LP-only): +- `manage_gateway_swaps(action="quote", connector="meteora/amm", network="solana-mainnet-beta", trading_pair="-SOL", side="SELL", amount=)` - and `... side="BUY", amount=` -Both must return a sane quote. If the **SELL** quote errors, reverts, or returns ~0 out / absurd price -impact → **honeypot, reject.** (A transfer-fee/tax token shows as a large one-directional impact — treat +Both must return a sane quote. If the **SELL** quote returns ~0 out or an absurd price impact → +**honeypot, reject.** (A transfer-fee/tax token shows as a large one-directional impact — treat high asymmetric impact as a red flag too.) +**"No pool found" is NOT a honeypot verdict.** With `"meteora/amm"` Gateway resolves the pool from +**its own configured pool list, by token SYMBOL** — you cannot pass a `pool_address`, and a +freshly-graduated token's mint is usually not in that list (creating a pool does not register it). +On a fresh launch that error means **unknown to Gateway**, not "unsellable". Re-run both quotes +through **`connector="jupiter/router"`**, which prices off live on-chain routes. Only treat the gate +as FAILED when a quote actually returns and looks wrong, or when the router itself finds no route +for the SELL direction while the BUY direction routes fine — *that* asymmetry is the honeypot +signature. If neither venue can quote at all, the gate is **INCONCLUSIVE**: journal it and skip the +candidate rather than recording a honeypot. + ### 2. Objective on-chain + static gates — one routine call ``` manage_routines(action="run", name="launch_safety_check", diff --git a/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md b/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md index b3feb78e7..11b7f8e1d 100644 --- a/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md +++ b/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md @@ -87,7 +87,10 @@ percentage_to_remove=100)`** if ANY fires: can still get, immediately. After removing, the base token you get back is dust exposure — swap it to SOL with -`manage_amm(action="execute_swap", side="SELL", base_token=, amount=)`. Journal +`manage_gateway_swaps(action="execute", connector="meteora/amm", network="solana-mainnet-beta", +trading_pair="-SOL", side="SELL", amount=)`. If that returns "No pool found" +(Gateway doesn't know this token — see §"Swaps" in AGENT.md), retry with +`connector="jupiter/router"`. Omit `slippage_pct` so the connector's own setting applies. Journal the exit (pool, reason, realized pnl_pct, fees, duration) as a `learning` if the reason is new. ### 3. Detect — ONE routine call (only if free > 0) @@ -103,7 +106,12 @@ Drop any pool you already hold. Take the top candidate whose `Age(h) ≥ entry.m ### 4. Gate the candidate (skill: launch_safety_check) Run the full gate — reject on ANY failure, then try the next candidate (max 2 candidates/tick): -1. **Sellability:** `manage_amm(quote_swap, side="SELL", …)` AND `side="BUY"` both return sane quotes. +1. **Sellability:** `manage_gateway_swaps(action="quote", connector="meteora/amm", + network="solana-mainnet-beta", trading_pair="-SOL", side="SELL", amount=)` AND + `side="BUY"` both return sane quotes. **"No pool found" ≠ honeypot** — Gateway matches its + configured pool list by token SYMBOL and won't know a fresh mint; retry both through + `connector="jupiter/router"`, and if neither venue quotes at all treat the gate as + **inconclusive** and skip the candidate (do not journal it as a honeypot). 2. **Safety routine:** `manage_routines(action="run", name="launch_safety_check", config={"pool_address":

, "rpc_url":, "require_mint_renounced":true, "require_freeze_disabled":true, "max_top10_holder_pct":, "require_verified":})` @@ -113,7 +121,9 @@ Run the full gate — reject on ANY failure, then try the next candidate (max 2 ### 5. Open ONE position (small, capped) Early LP is **directional-long the token** — size at `min(capital_per_position, max_quote_per_position)`. - Acquire the token side: swap `capital × base_pct/100` SOL → base token via - `manage_amm(action="execute_swap", side="BUY", base_token=, amount=)`. + `manage_gateway_swaps(action="execute", connector="meteora/amm", network="solana-mainnet-beta", + trading_pair="-SOL", side="BUY", amount=)` + (fall back to `connector="jupiter/router"` on "No pool found"; omit `slippage_pct`). **Haircut the received amount ×0.995** (the swap fills slightly under the quote; opening with the quoted figure asks for tokens you don't have and the add fails on-chain). Prefer reading the actual post-swap wallet balance. diff --git a/condor/agents/risk.py b/condor/agents/risk.py index 39018f900..301160479 100644 --- a/condor/agents/risk.py +++ b/condor/agents/risk.py @@ -241,7 +241,9 @@ def auto_approve_with_risk_check( chat, tests) keeps the presence-only check. """ from handlers.agents._shared import ( + DANGEROUS_AMM_ACTIONS, DANGEROUS_BOT_ACTIONS, + DANGEROUS_CLMM_ACTIONS, is_dangerous_tool_call, tool_call_input, tool_call_name, @@ -271,11 +273,17 @@ async def callback(tool_call: dict, options: list[dict]) -> dict: if action in DANGEROUS_BOT_ACTIONS: log.info("Dry-run mode: blocked manage_bots(%s)", action) return {"outcome": {"outcome": "cancelled"}} - elif tool_name in ( - "place_order", - "manage_gateway_swaps", - "manage_gateway_clmm", - ): + elif tool_name == "manage_clmm": + action = input_data.get("action", "") + if action in DANGEROUS_CLMM_ACTIONS: + log.info("Dry-run mode: blocked manage_clmm(%s)", action) + return {"outcome": {"outcome": "cancelled"}} + elif tool_name == "manage_amm": + action = input_data.get("action", "") + if action in DANGEROUS_AMM_ACTIONS: + log.info("Dry-run mode: blocked manage_amm(%s)", action) + return {"outcome": {"outcome": "cancelled"}} + elif tool_name in ("place_order", "manage_gateway_swaps"): log.info("Dry-run mode: blocked %s", tool_name) return {"outcome": {"outcome": "cancelled"}} diff --git a/condor/preferences.py b/condor/preferences.py index 73ac5b9c7..ef7835a00 100644 --- a/condor/preferences.py +++ b/condor/preferences.py @@ -124,7 +124,8 @@ def _load_from_cm(user_data: Dict) -> None: DEFAULT_DEX_NETWORK = "solana-mainnet-beta" DEFAULT_DEX_CONNECTOR = "jupiter" DEFAULT_DEX_PAIR = "SOL-USDC" -DEFAULT_DEX_SLIPPAGE = "1.0" +# No slippage default on purpose: an unset slippage is OMITTED from the Gateway request so the +# connector's own configured slippage applies. An explicit value (including "0") is a real value. DEFAULT_DEX_SIDE = "BUY" DEFAULT_DEX_AMOUNT = "1.0" @@ -359,7 +360,6 @@ def _get_default_preferences() -> UserPreferences: "dex": { "default_network": DEFAULT_DEX_NETWORK, "default_connector": DEFAULT_DEX_CONNECTOR, - "default_slippage": DEFAULT_DEX_SLIPPAGE, "last_swap": {}, "last_pool": {}, }, @@ -683,9 +683,9 @@ def get_dex_connector(user_data: Dict, network: Optional[str] = None) -> str: return get_dex_prefs(user_data).get("default_connector", DEFAULT_DEX_CONNECTOR) -def get_dex_slippage(user_data: Dict) -> str: - """Get default DEX slippage percentage""" - return get_dex_prefs(user_data).get("default_slippage", DEFAULT_DEX_SLIPPAGE) +def get_dex_slippage(user_data: Dict) -> Optional[str]: + """Get the user's DEX slippage override, or None to use the connector's own setting""" + return get_dex_prefs(user_data).get("default_slippage") def set_dex_slippage(user_data: Dict, slippage: str) -> None: @@ -728,6 +728,9 @@ def get_dex_swap_defaults(user_data: Dict) -> DEXSwapParams: 1. System defaults 2. User's configured defaults 3. Last swap params (highest priority) + + "slippage" is present ONLY when the user set one. Absent means "omit slippage_pct from the + Gateway request" so the connector's configured slippage applies. """ prefs = get_dex_prefs(user_data) last_swap = prefs.get("last_swap", {}) @@ -736,17 +739,20 @@ def get_dex_swap_defaults(user_data: Dict) -> DEXSwapParams: "network", prefs.get("default_network", DEFAULT_DEX_NETWORK) ) - return { + defaults: DEXSwapParams = { "connector": last_swap.get("connector", get_dex_connector(user_data, network)), "network": network, "trading_pair": last_swap.get("trading_pair", DEFAULT_DEX_PAIR), "side": DEFAULT_DEX_SIDE, "amount": DEFAULT_DEX_AMOUNT, - "slippage": last_swap.get( - "slippage", prefs.get("default_slippage", DEFAULT_DEX_SLIPPAGE) - ), } + slippage = last_swap.get("slippage", prefs.get("default_slippage")) + if slippage is not None: + defaults["slippage"] = slippage + + return defaults + # ============================================ # PUBLIC API - GENERAL diff --git a/handlers/agents/_shared.py b/handlers/agents/_shared.py index d51a29f42..0087e8a61 100644 --- a/handlers/agents/_shared.py +++ b/handlers/agents/_shared.py @@ -296,7 +296,8 @@ def _build_system_prompt(platform: str = "telegram") -> str: DANGEROUS_TOOLS = { "place_order", "manage_gateway_swaps", # execute action - "manage_gateway_clmm", # open/close position + "manage_clmm", # every action that moves liquidity + "manage_amm", # every action that moves liquidity } # Tools that are always blocked (RBAC bypass prevention) @@ -320,8 +321,20 @@ def _build_system_prompt(platform: str = "telegram") -> str: # Actions within manage_gateway_swaps that require confirmation DANGEROUS_SWAP_ACTIONS = {"execute"} -# Actions within manage_gateway_clmm that require confirmation -DANGEROUS_CLMM_ACTIONS = {"open_position", "close_position"} +# Actions within manage_clmm that require confirmation. These are the tool's +# own action literals — a name that does not match one lets the call through +# ungated, so they are asserted against the registered tool in the tests. +DANGEROUS_CLMM_ACTIONS = { + "open", + "close", + "add_liquidity", + "remove_liquidity", + "collect_fees", + "create_pool", +} + +# Actions within manage_amm that require confirmation +DANGEROUS_AMM_ACTIONS = {"add_liquidity", "remove_liquidity", "create_pool"} def tool_call_name(tool_call: dict[str, Any]) -> str: @@ -389,10 +402,13 @@ def is_dangerous_tool_call(tool_call: dict[str, Any]) -> bool: if tool_name == "manage_gateway_swaps": return _has_dangerous_action(tool_call, DANGEROUS_SWAP_ACTIONS) - # For manage_gateway_clmm, only open/close are dangerous - if tool_name == "manage_gateway_clmm": + # For the LP tools, only the actions that move liquidity are dangerous + if tool_name == "manage_clmm": return _has_dangerous_action(tool_call, DANGEROUS_CLMM_ACTIONS) + if tool_name == "manage_amm": + return _has_dangerous_action(tool_call, DANGEROUS_AMM_ACTIONS) + return True # manage_executors with create/stop actions diff --git a/handlers/dex/geckoterminal.py b/handlers/dex/geckoterminal.py index 7255edeab..791309381 100644 --- a/handlers/dex/geckoterminal.py +++ b/handlers/dex/geckoterminal.py @@ -3347,7 +3347,7 @@ async def handle_gecko_swap(update: Update, context: ContextTypes.DEFAULT_TYPE) "trading_pair": trading_pair, "side": "BUY", "amount": "1.0", - "slippage": "1.0", + # No slippage: the connector's configured slippage applies until the user overrides it } context.user_data["dex_state"] = "swap" diff --git a/handlers/dex/swap.py b/handlers/dex/swap.py index 71b8097fa..476b7dcfd 100644 --- a/handlers/dex/swap.py +++ b/handlers/dex/swap.py @@ -11,6 +11,7 @@ import logging from decimal import Decimal +import aiohttp from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes @@ -52,18 +53,114 @@ # HELPER FUNCTIONS # ============================================ +# Shown on the slippage button when the user has not set one: the request omits +# slippage_pct entirely and the connector's own configured slippage applies. +SLIPPAGE_DEFAULT_LABEL = "default" + + +def describe_api_error(exc: Exception) -> str: + """Human-readable cause of an SDK failure. + + The SDK raises aiohttp.ClientResponseError carrying the HTTP status and the API's own + detail message, which is what separates a 404 "No pool found" from a 400 "Unsupported + swap provider" from a genuine no-route. Anything else renders as its type + message. + """ + if isinstance(exc, aiohttp.ClientResponseError): + return f"HTTP {exc.status}: {exc.message}" + detail = str(exc).strip() + return detail or type(exc).__name__ + + +def get_slippage_pct(params: dict) -> Decimal | None: + """The user's slippage override as a Decimal, or None to use the connector's setting. + + None makes the SDK omit slippage_pct from the request. An explicit "0" is a real 0. + """ + slippage = params.get("slippage") + if slippage is None: + return None + return Decimal(str(slippage).rstrip("%").strip()) + + +def _slippage_button_label(params: dict) -> str: + """Slippage button text — the connector's own setting until the user overrides it.""" + slippage = params.get("slippage") + if slippage is None: + return f"📊 Slippage: {SLIPPAGE_DEFAULT_LABEL}" + return f"📊 {slippage}%" + + +def _last_swap_params( + connector: str, network: str, trading_pair: str, side: str, params: dict +) -> dict: + """Params to remember for next time — slippage only when the user actually set one.""" + remembered = { + "connector": connector, + "network": network, + "trading_pair": trading_pair, + "side": side, + } + if params.get("slippage") is not None: + remembered["slippage"] = params["slippage"] + return remembered + + +def _quote_errors(buy_result, sell_result) -> list[str]: + """One 'SIDE: cause' line for each of the two gathered quotes that raised.""" + return [ + f"{side}: {describe_api_error(result)}" + for side, result in (("BUY", buy_result), ("SELL", sell_result)) + if isinstance(result, BaseException) + ] + + +async def resolve_quote_denominated_amount( + client, + amount: Decimal, + connector: str, + network: str, + trading_pair: str, + slippage_pct: Decimal | None, +) -> Decimal: + """Convert a quote-denominated ($) amount into base token units. + + Prices off a 1-unit BUY quote. Raises with the real cause rather than falling back to the + raw number — treating "$100" as 100 base tokens would size the trade catastrophically wrong. + """ + try: + price_quote = await client.gateway_swap.get_swap_quote( + connector=connector, + network=network, + trading_pair=trading_pair, + side="BUY", + amount=Decimal("1"), + slippage_pct=slippage_pct, + ) + except Exception as e: + raise ValueError( + f"Cannot price {trading_pair} on {connector} to convert the $ amount " + f"({describe_api_error(e)})" + ) from e + + if not isinstance(price_quote, dict) or not price_quote.get("price"): + raise ValueError(f"Quote for {trading_pair} on {connector} carried no price") + + price = Decimal(str(price_quote["price"])) + if price <= 0: + raise ValueError( + f"Quote for {trading_pair} on {connector} priced it at {price}" + ) + + return amount / price + async def _fetch_recent_swaps(client, limit: int = 5) -> list: """Fetch recent swaps for display""" - try: - if not hasattr(client, "gateway_swap"): - return [] + if not hasattr(client, "gateway_swap"): + raise ValueError("Gateway swap not available on this server") - result = await client.gateway_swap.search_swaps(limit=limit) - return result.get("data", []) - except Exception as e: - logger.warning(f"Error fetching recent swaps: {e}") - return [] + result = await client.gateway_swap.search_swaps(limit=limit) + return result.get("data", []) def _format_compact_swap_line(swap: dict) -> str: @@ -218,7 +315,7 @@ async def _fetch_quotes_background( network = params.get("network") trading_pair = params.get("trading_pair") amount = params.get("amount") - slippage = params.get("slippage", "1.0") + slippage_pct = get_slippage_pct(params) if not all([connector, network, trading_pair, amount]): return @@ -240,50 +337,41 @@ async def fetch_balances_safe(): # Handle $ prefix (quote-denominated amount) amount_str = str(amount) - is_quote_amount = amount_str.startswith("$") numeric_amount = Decimal(amount_str.lstrip("$").strip()) + if amount_str.startswith("$"): + numeric_amount = await resolve_quote_denominated_amount( + client, + numeric_amount, + connector, + network, + trading_pair, + slippage_pct, + ) - # If quote-denominated, first get price to convert - if is_quote_amount: - try: - # Get a quote for 1 unit to determine price - price_quote = await client.gateway_swap.get_swap_quote( - connector=connector, - network=network, - trading_pair=trading_pair, - side="BUY", - amount=Decimal("1"), - slippage_pct=Decimal(slippage), - ) - if price_quote and isinstance(price_quote, dict): - # Price is quote per base (e.g., USDC per SOL) - price = Decimal(str(price_quote.get("price", 1))) - if price > 0: - numeric_amount = numeric_amount / price - except Exception as e: - logger.warning(f"Could not get price for $ conversion: {e}") - # Fall back to using numeric value as-is - - async def get_quote_safe(side: str): - try: - return await client.gateway_swap.get_swap_quote( - connector=connector, - network=network, - trading_pair=trading_pair, - side=side, - amount=numeric_amount, - slippage_pct=Decimal(slippage), - ) - except Exception as e: - logger.warning(f"Background quote failed for {side}: {e}") - return None + async def get_quote(side: str): + return await client.gateway_swap.get_swap_quote( + connector=connector, + network=network, + trading_pair=trading_pair, + side=side, + amount=numeric_amount, + slippage_pct=slippage_pct, + ) - # Fetch quotes and balances in parallel + # Fetch quotes and balances in parallel. return_exceptions keeps one side's failure + # from cancelling the other, and preserves the real cause for the caller. buy_result, sell_result, _ = await asyncio.gather( - get_quote_safe("BUY"), get_quote_safe("SELL"), fetch_balances_safe() + get_quote("BUY"), + get_quote("SELL"), + fetch_balances_safe(), + return_exceptions=True, ) - if buy_result is None and sell_result is None: + errors = _quote_errors(buy_result, sell_result) + for line in errors: + logger.warning(f"Background quote failed — {line}") + + if len(errors) == 2: return quote_data = { @@ -291,6 +379,7 @@ async def get_quote_safe(side: str): "amount": amount, "buy": buy_result if isinstance(buy_result, dict) else None, "sell": sell_result if isinstance(sell_result, dict) else None, + "errors": errors, } # Store quote in cache for display @@ -345,7 +434,7 @@ def _build_swap_keyboard(params: dict) -> list: f"💰 {params.get('amount', '1.0')}", callback_data="dex:swap_set_amount" ), InlineKeyboardButton( - f"📊 {params.get('slippage', '1.0')}%", + _slippage_button_label(params), callback_data="dex:swap_set_slippage", ), InlineKeyboardButton( @@ -455,6 +544,10 @@ def _build_swap_menu_text( spread_str = f"{spread_pct:.2f}%" help_text += f"📊 Spread: `{escape_markdown_v2(spread_str)}`\n" + # A side that failed says why, instead of silently missing from the quote + for line in quote_result.get("errors") or []: + help_text += "⚠️ " + escape_markdown_v2(line) + "\n" + help_text += "\n" # Type directly hint @@ -465,16 +558,15 @@ def _build_swap_menu_text( example_amount = escape_markdown_v2(str(params.get("amount", "1.0"))) help_text += f"*Ex:* `{example_pair} {example_side} {example_amount}`\n\n" - # Fetch and show recent swaps (compact format) - try: - swaps = get_cached(user_data, "recent_swaps", ttl=60) - if swaps: - help_text += r"━━━ Recent ━━━" + "\n" - for swap in swaps[:5]: - line = _format_compact_swap_line(swap) - help_text += line + "\n" - except Exception as e: - logger.warning(f"Could not get cached swaps: {e}") + # Show recent swaps (compact format), or why they could not be loaded + swaps = get_cached(user_data, "recent_swaps", ttl=60) + if swaps: + help_text += r"━━━ Recent ━━━" + "\n" + for swap in swaps[:5]: + help_text += _format_compact_swap_line(swap) + "\n" + elif user_data.get("recent_swaps_error"): + help_text += r"━━━ Recent ━━━" + "\n" + help_text += "⚠️ " + escape_markdown_v2(user_data["recent_swaps_error"]) + "\n" return help_text @@ -505,8 +597,11 @@ async def show_swap_menu( client = await get_client(chat_id, context=context) swaps = await _fetch_recent_swaps(client, limit=5) set_cached(context.user_data, "recent_swaps", swaps) + context.user_data.pop("recent_swaps_error", None) except Exception as e: - logger.warning(f"Could not fetch recent swaps: {e}") + cause = describe_api_error(e) + logger.warning(f"Could not fetch recent swaps: {cause}") + context.user_data["recent_swaps_error"] = cause # Use cached quote if available and no explicit quote_result provided if quote_result is None: @@ -921,7 +1016,8 @@ async def handle_swap_set_slippage( r"Enter slippage %:" + "\n\n" r"*Examples:*" + "\n" r"`1\.0` \- 1%" + "\n" - r"`2\.5` \- 2\.5%" + r"`2\.5` \- 2\.5%" + "\n" + r"`default` \- use the connector's own setting" ) keyboard = [[InlineKeyboardButton("« Back", callback_data="dex:swap")]] @@ -952,7 +1048,7 @@ async def handle_swap_get_quote( network = params.get("network") trading_pair = params.get("trading_pair") amount = params.get("amount") - slippage = params.get("slippage", "1.0") + slippage_pct = get_slippage_pct(params) if not all([connector, network, trading_pair, amount]): raise ValueError("Missing required parameters") @@ -974,50 +1070,45 @@ async def fetch_balances_safe(): # Handle $ prefix (quote-denominated amount) amount_str = str(amount) - is_quote_amount = amount_str.startswith("$") numeric_amount = Decimal(amount_str.lstrip("$").strip()) - - # If quote-denominated, first get price to convert - if is_quote_amount: - try: - # Get a quote for 1 unit to determine price - price_quote = await client.gateway_swap.get_swap_quote( - connector=connector, - network=network, - trading_pair=trading_pair, - side="BUY", - amount=Decimal("1"), - slippage_pct=Decimal(slippage), - ) - if price_quote and isinstance(price_quote, dict): - price = Decimal(str(price_quote.get("price", 1))) - if price > 0: - numeric_amount = numeric_amount / price - except Exception as e: - logger.warning(f"Could not get price for $ conversion: {e}") + if amount_str.startswith("$"): + numeric_amount = await resolve_quote_denominated_amount( + client, + numeric_amount, + connector, + network, + trading_pair, + slippage_pct, + ) # Fetch BUY and SELL quotes in parallel - async def get_quote_safe(side: str): - try: - return await client.gateway_swap.get_swap_quote( - connector=connector, - network=network, - trading_pair=trading_pair, - side=side, - amount=numeric_amount, - slippage_pct=Decimal(slippage), - ) - except Exception as e: - logger.warning(f"Quote failed for {side}: {e}") - return None + async def get_quote(side: str): + return await client.gateway_swap.get_swap_quote( + connector=connector, + network=network, + trading_pair=trading_pair, + side=side, + amount=numeric_amount, + slippage_pct=slippage_pct, + ) - # Fetch quotes and balances in parallel + # Fetch quotes and balances in parallel. return_exceptions keeps one side's failure + # from cancelling the other, and preserves the real cause for the caller. buy_result, sell_result, _ = await asyncio.gather( - get_quote_safe("BUY"), get_quote_safe("SELL"), fetch_balances_safe() + get_quote("BUY"), + get_quote("SELL"), + fetch_balances_safe(), + return_exceptions=True, ) - if buy_result is None and sell_result is None: - raise ValueError("No quotes available for this pair") + errors = _quote_errors(buy_result, sell_result) + if len(errors) == 2: + # Both sides failed — report what Gateway actually said, not "no quotes" + raise ValueError( + f"No quote for {trading_pair} on {connector} — " + "; ".join(errors) + ) + for line in errors: + logger.warning(f"Quote failed — {line}") # Build combined quote result quote_data = { @@ -1025,6 +1116,7 @@ async def get_quote_safe(side: str): "amount": amount, "buy": buy_result if isinstance(buy_result, dict) else None, "sell": sell_result if isinstance(sell_result, dict) else None, + "errors": errors, } # Cache the quote @@ -1033,13 +1125,9 @@ async def get_quote_safe(side: str): # Save params set_dex_last_swap( context.user_data, - { - "connector": connector, - "network": network, - "trading_pair": trading_pair, - "side": params.get("side", "BUY"), - "slippage": slippage, - }, + _last_swap_params( + connector, network, trading_pair, params.get("side", "BUY"), params + ), ) # Show menu with quote result inline (disable auto_quote since we already have it) @@ -1047,7 +1135,7 @@ async def get_quote_safe(side: str): except Exception as e: logger.error(f"Error getting quote: {e}", exc_info=True) - error_message = format_error_message(f"Quote failed: {str(e)}") + error_message = format_error_message(f"Quote failed: {describe_api_error(e)}") await update.callback_query.message.edit_text( error_message, parse_mode="MarkdownV2" ) @@ -1065,7 +1153,7 @@ async def handle_swap_execute_confirm( trading_pair = params.get("trading_pair") side = params.get("side") amount = params.get("amount") - slippage = params.get("slippage", "1.0") + slippage_pct = get_slippage_pct(params) if not all([connector, network, trading_pair, side, amount]): raise ValueError("Missing required parameters") @@ -1079,11 +1167,14 @@ async def handle_swap_execute_confirm( if numeric_amount <= 0: raise ValueError("Amount must be greater than 0") - # Validate slippage > 0 - slippage_str = str(slippage).rstrip("%").strip() - slippage_val = Decimal(slippage_str) - if slippage_val <= 0: - raise ValueError("Slippage must be greater than 0%") + # An unset slippage is legitimate (the connector's own setting applies); an explicit + # 0 is a real 0. Only a negative value is nonsense. + if slippage_pct is not None and slippage_pct < 0: + raise ValueError("Slippage cannot be negative") + + slippage_display = ( + SLIPPAGE_DEFAULT_LABEL if slippage_pct is None else f"{slippage_pct}%" + ) # Show loading state immediately to prevent duplicate actions loading_text = escape_markdown_v2( @@ -1091,7 +1182,7 @@ async def handle_swap_execute_confirm( f"Pair: {trading_pair}\n" f"Side: {side}\n" f"Amount: {amount}\n" - f"Slippage: {slippage}%\n\n" + f"Slippage: {slippage_display}\n\n" f"Please wait..." ) await update.callback_query.message.edit_text( @@ -1106,31 +1197,28 @@ async def handle_swap_execute_confirm( # If quote-denominated, convert to base amount if is_quote_amount: - try: - # Get a quote for 1 unit to determine price - price_quote = await client.gateway_swap.get_swap_quote( - connector=connector, - network=network, - trading_pair=trading_pair, - side="BUY", - amount=Decimal("1"), - slippage_pct=Decimal(slippage_str), - ) - if price_quote and isinstance(price_quote, dict): - price = Decimal(str(price_quote.get("price", 1))) - if price > 0: - numeric_amount = numeric_amount / price - except Exception as e: - logger.warning(f"Could not get price for $ conversion: {e}") + numeric_amount = await resolve_quote_denominated_amount( + client, + numeric_amount, + connector, + network, + trading_pair, + slippage_pct, + ) - result = await client.gateway_swap.execute_swap( - connector=connector, - network=network, - trading_pair=trading_pair, - side=side, - amount=numeric_amount, - slippage_pct=Decimal(slippage_str), - ) + try: + result = await client.gateway_swap.execute_swap( + connector=connector, + network=network, + trading_pair=trading_pair, + side=side, + amount=numeric_amount, + slippage_pct=slippage_pct, + ) + except Exception as e: + raise ValueError( + f"{side} {trading_pair} on {connector} rejected — {describe_api_error(e)}" + ) from e if result is None: raise ValueError("Swap execution failed") @@ -1144,13 +1232,7 @@ async def handle_swap_execute_confirm( # Save params set_dex_last_swap( context.user_data, - { - "connector": connector, - "network": network, - "trading_pair": trading_pair, - "side": side, - "slippage": slippage, - }, + _last_swap_params(connector, network, trading_pair, side, params), ) # Build success message @@ -1160,7 +1242,7 @@ async def handle_swap_execute_confirm( f"Pair: {trading_pair}\n" f"Side: {side}\n" f"Amount: {display_amount}\n" - f"Slippage: {slippage}%" + f"Slippage: {slippage_display}" ) if isinstance(result, dict): @@ -1179,7 +1261,7 @@ async def handle_swap_execute_confirm( except Exception as e: logger.error(f"Error executing swap: {e}", exc_info=True) - error_message = format_error_message(f"Swap failed: {str(e)}") + error_message = format_error_message(f"Swap failed: {describe_api_error(e)}") keyboard = [[InlineKeyboardButton("« Back to Swap", callback_data="dex:swap")]] reply_markup = InlineKeyboardMarkup(keyboard) await update.callback_query.message.edit_text( @@ -1241,7 +1323,9 @@ async def process_swap_status( except Exception as e: logger.error(f"Error getting status: {e}", exc_info=True) - error_message = format_error_message(f"Failed to get status: {str(e)}") + error_message = format_error_message( + f"Failed to get status: {describe_api_error(e)}" + ) await update.message.reply_text(error_message, parse_mode="MarkdownV2") @@ -1447,7 +1531,9 @@ async def handle_swap_history( except Exception as e: logger.error(f"Error fetching history: {e}", exc_info=True) - error_message = format_error_message(f"Failed to fetch history: {str(e)}") + error_message = format_error_message( + f"Failed to fetch history: {describe_api_error(e)}" + ) await update.callback_query.message.edit_text( error_message, parse_mode="MarkdownV2" ) @@ -1574,21 +1660,22 @@ async def process_swap( trading_pair = parts[0] side = parts[1].upper() amount = parts[2] - slippage = parts[3] if len(parts) > 3 else "1.0" # Get connector/network from defaults network = DEFAULT_DEX_NETWORK connector = get_dex_connector(context.user_data, network) - # Update params - context.user_data["swap_params"] = { + # Update params. Slippage stays unset unless typed — the connector's setting applies. + swap_params = { "connector": connector, "network": network, "trading_pair": trading_pair, "side": side, "amount": amount, - "slippage": slippage, } + if len(parts) > 3: + swap_params["slippage"] = parts[3].rstrip("%").strip() + context.user_data["swap_params"] = swap_params context.user_data["dex_state"] = "swap" @@ -1655,17 +1742,18 @@ async def process_swap_set_amount( async def process_swap_set_slippage( update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str ) -> None: - """Process slippage input""" + """Process slippage input. `default` clears the override so the connector's setting applies.""" try: + params = context.user_data.get("swap_params", {}) slippage_str = user_input.strip().rstrip("%").strip() - # Validate - slippage_val = Decimal(slippage_str) - if slippage_val <= 0: - raise ValueError("Slippage must be > 0%") - - params = context.user_data.get("swap_params", {}) - params["slippage"] = slippage_str + if slippage_str.lower() == SLIPPAGE_DEFAULT_LABEL: + params.pop("slippage", None) + else: + # An explicit 0 is a real 0 (no tolerance), not "use the default" + if Decimal(slippage_str) < 0: + raise ValueError("Slippage cannot be negative") + params["slippage"] = slippage_str context.user_data["dex_state"] = "swap" diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index 8c75dfe60..22fb46648 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -1071,6 +1071,110 @@ async def manage_clmm( return format_clmm_result(action, result) +@mcp.tool() +@handle_errors("manage Gateway swaps", GATEWAY_LOG_HINT) +async def manage_gateway_swaps( + action: Literal["quote", "execute", "search", "get_status"], + connector: str | None = None, + network: str | None = None, + trading_pair: str | None = None, + side: Literal["BUY", "SELL"] | None = None, + amount: str | None = None, + slippage_pct: str | None = None, + wallet_address: str | None = None, + transaction_hash: str | None = None, + extra_params: dict[str, Any] | None = None, + search_connector: str | None = None, + search_network: str | None = None, + search_wallet_address: str | None = None, + search_trading_pair: str | None = None, + status: Literal["SUBMITTED", "CONFIRMED", "FAILED"] | None = None, + start_time: int | None = None, + end_time: int | None = None, + limit: int = 50, + offset: int = 0, +) -> str: + """One-shot DEX swaps through Gateway's unified swap route — quote, execute, and track. + + This is the ONLY swap surface. Router aggregators (jupiter, 0x) and pool-scoped AMM/CLMM + swaps all go through it; the pool-scoped `/trading/amm/*-swap` routes no longer exist. + + Actions: + - quote → price, expected output, and price impact BEFORE committing (free, always do this first) + - execute → submit the swap on-chain; returns a transaction hash + - get_status → resolve a submitted swap by transaction_hash + - search → query swap history with filters + + CONNECTOR FORMAT. `connector` is "name/type": + - "jupiter/router", "0x/router" — aggregator routing across every pool it knows + - "meteora/amm", "raydium/amm", "uniswap/amm" — swap against an AMM pool + - "meteora/clmm", "raydium/clmm", "uniswap/clmm" — swap against a CLMM pool + + POOL RESOLUTION (read this before trusting a failure). For the pool-scoped forms + (`/amm`, `/clmm`) Gateway resolves the pool itself from ITS OWN CONFIGURED POOL LIST, + matched BY TOKEN SYMBOL from `trading_pair` — you cannot pass a pool address here. A token + Gateway does not know (a fresh launch mint, a pool created moments ago via + `manage_amm(create_pool)`) will NOT resolve, and creating a pool does not register it. Such a + call fails with "No pool found", which means UNKNOWN, not "bad token" — do not read it as a + honeypot or a failed safety check. Route those through an aggregator ("jupiter/router"), + which quotes off live on-chain routes rather than a config file. + + LP work is elsewhere: `manage_amm` (AMM pools), `manage_clmm` (CLMM positions), + `manage_executors(lp_executor)` (managed LP). + + Args: + action: Swap action to perform. + connector: Connector in "name/type" form (required for quote/execute), e.g. + 'jupiter/router', 'meteora/amm', 'raydium/clmm'. + network: Network ID in 'chain-network' format (required for quote/execute), e.g. + 'solana-mainnet-beta', 'ethereum-mainnet'. + trading_pair: Trading pair as 'BASE-QUOTE' (required for quote/execute). Symbols or + token addresses. Pool-scoped connectors match Gateway's pool list by SYMBOL. + side: 'BUY' (buy base with quote) or 'SELL' (sell base for quote). Required for quote/execute. + amount: Base token amount to buy or sell (required for quote/execute). + slippage_pct: Maximum slippage percentage. OMIT to use the connector's configured + slippage; '0' is a real value, not "use the default". + wallet_address: Wallet for execute (optional, uses the default wallet). + transaction_hash: Transaction hash (required for get_status). + extra_params: Connector-specific params under Gateway's own names. Supported: + 'approximateIfNoExactOut' (bool, jupiter/dflow/okx/titan routers). + search_connector: Filter history by connector (search). + search_network: Filter history by network (search). + search_wallet_address: Filter history by wallet address (search). + search_trading_pair: Filter history by trading pair (search). + status: Filter history by status: SUBMITTED | CONFIRMED | FAILED (search). + start_time: Start timestamp in unix seconds (search). + end_time: End timestamp in unix seconds (search). + limit: Max results for search (default 50, max 1000). + offset: Pagination offset for search (default 0). + """ + request = GatewaySwapRequest( + action=action, + connector=connector, + network=network, + trading_pair=trading_pair, + side=side, + amount=amount, + slippage_pct=slippage_pct, + wallet_address=wallet_address, + transaction_hash=transaction_hash, + extra_params=extra_params, + search_connector=search_connector, + search_network=search_network, + search_wallet_address=search_wallet_address, + search_trading_pair=search_trading_pair, + status=status, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + + client = await hummingbot_client.get_client() + result = await manage_gateway_swaps_impl(client, request) + return format_gateway_swap_result(action, result) + + # GeckoTerminal Tools diff --git a/tests/test_dangerous_gate_names_resolve.py b/tests/test_dangerous_gate_names_resolve.py new file mode 100644 index 000000000..0d3f2ad38 --- /dev/null +++ b/tests/test_dangerous_gate_names_resolve.py @@ -0,0 +1,106 @@ +"""Every safety-gate name must resolve to a real MCP tool and a real action. + +The gates are string sets matched against tool names and action literals. A name +that no longer matches anything does not fail loudly — it silently stops gating, +so the call executes unconfirmed and, in dry-run mode, executes for real. That is +exactly what happened: the lists named ``manage_gateway_clmm`` (registered as +``manage_clmm``) with actions ``open_position``/``close_position`` (registered as +``open``/``close``), and omitted ``manage_amm`` altogether, so every AMM and CLMM +write bypassed both gates. + +These assert the gate strings against the tool registry itself, so a rename on +either side fails here instead of in production. +""" + +import inspect +import typing + +from handlers.agents._shared import ( + DANGEROUS_AMM_ACTIONS, + DANGEROUS_BOT_ACTIONS, + DANGEROUS_CLMM_ACTIONS, + DANGEROUS_EXECUTOR_ACTIONS, + DANGEROUS_SWAP_ACTIONS, + DANGEROUS_TOOLS, + is_dangerous_tool_call, +) +from mcp_servers.hummingbot_api import server as mcp_server + +# Gate names that belong to a different MCP server than hummingbot_api. +_FOREIGN_TOOLS = {"place_order"} + + +def _registered_tools() -> dict: + """Every function the hummingbot_api MCP server registers as a tool.""" + return { + name: obj.fn if hasattr(obj, "fn") else obj + for name, obj in vars(mcp_server).items() + if callable(obj) and not name.startswith("_") + } + + +def _action_literals(tool_name: str) -> set[str]: + """The action literals a registered tool actually accepts.""" + fn = _registered_tools()[tool_name] + annotation = inspect.signature(fn).parameters["action"].annotation + # `action` is either a bare Literal[...] or `Literal[...] | None`. + literals = {str(v) for v in typing.get_args(annotation) if isinstance(v, str)} + for arg in typing.get_args(annotation): + literals.update(str(v) for v in typing.get_args(arg) if isinstance(v, str)) + return literals + + +def test_every_gated_tool_name_is_registered(): + for tool_name in DANGEROUS_TOOLS - _FOREIGN_TOOLS: + assert tool_name in _registered_tools(), ( + f"{tool_name} is gated but not registered — the gate matches nothing" + ) + + +def test_gated_actions_exist_on_their_tools(): + for tool_name, actions in ( + ("manage_clmm", DANGEROUS_CLMM_ACTIONS), + ("manage_amm", DANGEROUS_AMM_ACTIONS), + ("manage_gateway_swaps", DANGEROUS_SWAP_ACTIONS), + ("manage_executors", DANGEROUS_EXECUTOR_ACTIONS), + ("manage_bots", DANGEROUS_BOT_ACTIONS), + ): + available = _action_literals(tool_name) + unknown = actions - available + assert not unknown, f"{tool_name} has no such action(s): {sorted(unknown)}" + + +def test_every_liquidity_moving_action_is_gated(): + """Read-only actions stay ungated; anything that moves funds must be gated.""" + read_only = {"pool_info", "position_info", "positions_owned", "quote_liquidity"} + + for tool_name, gated in ( + ("manage_clmm", DANGEROUS_CLMM_ACTIONS), + ("manage_amm", DANGEROUS_AMM_ACTIONS), + ): + writes = _action_literals(tool_name) - read_only + assert writes <= gated, ( + f"{tool_name} write action(s) ungated: {sorted(writes - gated)}" + ) + + +def test_lp_writes_require_confirmation(): + for tool_name, action in ( + ("manage_clmm", "open"), + ("manage_clmm", "close"), + ("manage_clmm", "remove_liquidity"), + ("manage_amm", "add_liquidity"), + ("manage_amm", "create_pool"), + ): + call = {"tool": tool_name, "input": {"action": action}} + assert is_dangerous_tool_call(call), f"{tool_name}({action}) was not gated" + + +def test_lp_reads_do_not_require_confirmation(): + for tool_name, action in ( + ("manage_clmm", "position_info"), + ("manage_amm", "pool_info"), + ("manage_amm", "quote_liquidity"), + ): + call = {"tool": tool_name, "input": {"action": action}} + assert not is_dangerous_tool_call(call), f"{tool_name}({action}) gated a read" diff --git a/tests/test_dex_swap_slippage_and_errors.py b/tests/test_dex_swap_slippage_and_errors.py new file mode 100644 index 000000000..3f94d5fda --- /dev/null +++ b/tests/test_dex_swap_slippage_and_errors.py @@ -0,0 +1,188 @@ +"""The DEX swap surface must not invent a slippage, and must not hide Gateway's reason. + +Two contracts pinned here: + +1. **Slippage.** Stack-wide, an OMITTED slippage means "the connector's configured slippage + applies"; an explicit 0 is a real value. The Telegram swap UI used to substitute "1.0" for an + unset slippage and forward it unconditionally, silently overriding every connector's own + setting on the money path. + +2. **Error surfacing.** Quote/execute failures used to be caught bare, logged at warning, and + rendered to the user as "No quotes available for this pair" — a 404 "No pool found", a 400 + "Unsupported swap provider", and a genuine no-route were indistinguishable. The SDK raises + aiohttp.ClientResponseError carrying the HTTP status and the API's own detail; that has to + reach the user. + +The repo has no async test setup, so coroutines are driven with asyncio.run(). +""" + +import asyncio +from decimal import Decimal + +import aiohttp +import pytest + +from condor.preferences import get_dex_swap_defaults, set_dex_slippage +from handlers.dex.swap import ( + _last_swap_params, + _quote_errors, + _slippage_button_label, + describe_api_error, + get_slippage_pct, + resolve_quote_denominated_amount, +) + + +def _response_error(status: int, message: str) -> aiohttp.ClientResponseError: + return aiohttp.ClientResponseError( + request_info=None, history=(), status=status, message=message + ) + + +# ---------------------------------------------------------------- slippage + + +def test_unset_slippage_is_omitted_not_defaulted_to_one_percent(): + """No slippage in params -> None, which makes the SDK omit slippage_pct entirely.""" + assert get_slippage_pct({}) is None + assert get_slippage_pct({"connector": "jupiter", "slippage": None}) is None + + +def test_explicit_zero_slippage_is_a_real_zero(): + """'0' means zero tolerance, NOT 'use the default' — it must survive as Decimal(0).""" + assert get_slippage_pct({"slippage": "0"}) == Decimal("0") + + +def test_explicit_slippage_is_parsed_with_or_without_a_percent_sign(): + assert get_slippage_pct({"slippage": "2.5"}) == Decimal("2.5") + assert get_slippage_pct({"slippage": "2.5%"}) == Decimal("2.5") + + +def test_the_button_reads_as_the_connector_default_until_the_user_overrides(): + assert _slippage_button_label({}) == "📊 Slippage: default" + assert _slippage_button_label({"slippage": "0"}) == "📊 0%" + assert _slippage_button_label({"slippage": "2.5"}) == "📊 2.5%" + + +def test_swap_defaults_carry_no_slippage_for_a_fresh_user(): + """A user who never set one must not get a fabricated 1.0 out of preferences.""" + defaults = get_dex_swap_defaults({}) + assert "slippage" not in defaults + assert defaults["connector"] and defaults["network"] and defaults["trading_pair"] + + +def test_swap_defaults_return_a_slippage_the_user_did_set(): + user_data = {} + set_dex_slippage(user_data, "3.0") + assert get_dex_swap_defaults(user_data)["slippage"] == "3.0" + + +def test_last_swap_remembers_a_slippage_only_when_one_was_set(): + """Persisting a fabricated slippage would resurrect the override on the next swap.""" + without = _last_swap_params("jupiter", "solana-mainnet-beta", "SOL-USDC", "BUY", {}) + assert "slippage" not in without + + with_override = _last_swap_params( + "jupiter", "solana-mainnet-beta", "SOL-USDC", "BUY", {"slippage": "0.5"} + ) + assert with_override["slippage"] == "0.5" + + +# ---------------------------------------------------------------- error surfacing + + +def test_an_http_error_keeps_its_status_and_the_apis_own_message(): + """404 'No pool found' must not read the same as 400 'Unsupported swap provider'.""" + assert ( + describe_api_error(_response_error(404, "No pool found")) + == "HTTP 404: No pool found" + ) + assert ( + describe_api_error(_response_error(400, "Unsupported swap provider")) + == "HTTP 400: Unsupported swap provider" + ) + + +def test_a_non_http_error_still_names_its_cause(): + assert describe_api_error(ValueError("bad pair")) == "bad pair" + assert describe_api_error(TimeoutError()) == "TimeoutError" + + +def test_only_the_sides_that_raised_are_reported_as_errors(): + failure = _response_error(404, "No pool found") + assert _quote_errors({"price": "1"}, failure) == ["SELL: HTTP 404: No pool found"] + assert _quote_errors(failure, failure) == [ + "BUY: HTTP 404: No pool found", + "SELL: HTTP 404: No pool found", + ] + assert _quote_errors({"price": "1"}, {"price": "1"}) == [] + + +# ---------------------------------------------------------------- $ amount conversion + + +class _FakeSwapRouter: + def __init__(self, quote=None, error=None): + self.quote = quote + self.error = error + self.calls = [] + + async def get_swap_quote(self, **kwargs): + self.calls.append(kwargs) + if self.error: + raise self.error + return self.quote + + +class _FakeClient: + def __init__(self, router): + self.gateway_swap = router + + +def test_a_dollar_amount_converts_at_the_quoted_price(): + router = _FakeSwapRouter(quote={"price": "200"}) + amount = asyncio.run( + resolve_quote_denominated_amount( + _FakeClient(router), + Decimal("100"), + "jupiter/router", + "solana-mainnet-beta", + "SOL-USDC", + None, + ) + ) + assert amount == Decimal("0.5") + # The pricing call must itself omit slippage_pct when the user set none + assert router.calls[0]["slippage_pct"] is None + + +def test_a_failed_pricing_call_raises_with_the_real_cause_instead_of_using_the_raw_number(): + """Treating '$100' as 100 base tokens would size the trade catastrophically wrong.""" + router = _FakeSwapRouter(error=_response_error(404, "No pool found")) + with pytest.raises(ValueError) as excinfo: + asyncio.run( + resolve_quote_denominated_amount( + _FakeClient(router), + Decimal("100"), + "meteora/amm", + "solana-mainnet-beta", + "NEW-SOL", + None, + ) + ) + assert "HTTP 404: No pool found" in str(excinfo.value) + + +def test_a_priceless_quote_raises_rather_than_defaulting_the_price_to_one(): + router = _FakeSwapRouter(quote={"amount_out": "3"}) + with pytest.raises(ValueError, match="no price"): + asyncio.run( + resolve_quote_denominated_amount( + _FakeClient(router), + Decimal("100"), + "jupiter/router", + "solana-mainnet-beta", + "SOL-USDC", + None, + ) + ) diff --git a/tests/test_mcp_gateway_swap_tool.py b/tests/test_mcp_gateway_swap_tool.py new file mode 100644 index 000000000..112be43ca --- /dev/null +++ b/tests/test_mcp_gateway_swap_tool.py @@ -0,0 +1,86 @@ +"""The swap implementation must be REGISTERED as an MCP tool, not just importable. + +`tools/gateway_swap.py`, `schemas.GatewaySwapRequest`, `formatters.format_gateway_swap_result`, +the agent danger-gate entry (`DANGEROUS_TOOLS`) and the ACP preload list all referenced +`manage_gateway_swaps` — but `server.py` imported the implementation and never wrapped it in +`@mcp.tool()`. Nothing on the wire exposed it, so agent playbooks routed swaps through +`manage_amm(quote_swap / execute_swap)` instead — actions that raise ToolError. + +Gateway folded pool-scoped AMM swaps into the unified swap route, so this is now the ONLY swap +surface: its docstring has to carry the "name/type" connector form and the pool-resolution +caveat that makes "No pool found" readable as "unknown token", not "honeypot". + +The repo has no async test setup, so coroutines are driven with asyncio.run(). +""" + +import asyncio +import inspect + +import pytest + +from mcp_servers.hummingbot_api.exceptions import ToolError +from mcp_servers.hummingbot_api.schemas import GatewaySwapRequest +from mcp_servers.hummingbot_api.tools.gateway_amm import manage_amm_impl + + +def test_the_swap_tool_is_registered_on_the_mcp_server(): + import mcp_servers.hummingbot_api.server as server + + assert hasattr(server, "manage_gateway_swaps"), ( + "the swap implementation exists but no @mcp.tool() exposes it — " + "agents have no swap surface at all" + ) + + +def test_the_registered_tool_covers_every_action_the_implementation_supports(): + import mcp_servers.hummingbot_api.server as server + + params = inspect.signature(server.manage_gateway_swaps).parameters + action = params["action"].annotation + assert set(action.__args__) == {"quote", "execute", "search", "get_status"} + + +def test_the_tool_documents_the_name_slash_type_connector_form(): + """A bare 'meteora' is not routable — the unified route takes 'meteora/amm'.""" + import mcp_servers.hummingbot_api.server as server + + doc = server.manage_gateway_swaps.__doc__ + assert "meteora/amm" in doc + assert "jupiter/router" in doc + + +def test_the_tool_warns_that_an_unresolvable_pool_is_not_a_bad_token(): + """Gateway matches its configured pool list by SYMBOL; a fresh mint is simply unknown.""" + import mcp_servers.hummingbot_api.server as server + + doc = server.manage_gateway_swaps.__doc__ + assert "No pool found" in doc + assert "honeypot" in doc + + +def test_omitting_slippage_is_documented_as_the_connectors_own_setting(): + import mcp_servers.hummingbot_api.server as server + + doc = " ".join(server.manage_gateway_swaps.__doc__.split()) + assert "OMIT to use the connector's configured slippage" in doc + assert "'0' is a real value" in doc + + +def test_manage_amm_has_no_swap_actions_left(): + """Gateway deleted /trading/amm/{quote,execute}-swap; manage_amm is LP-only now.""" + from mcp_servers.hummingbot_api.schemas import AMMRequest + + with pytest.raises(Exception): + AMMRequest(action="quote_swap") + + request = AMMRequest.model_construct( + action="execute_swap", connector="meteora", network="solana-mainnet-beta" + ) + with pytest.raises(ToolError, match="[Uu]nknown action"): + asyncio.run(manage_amm_impl(None, request)) + + +def test_an_unset_slippage_stays_unset_on_the_request_model(): + """None is the wire signal for 'omit slippage_pct'; '0' is a real value.""" + assert GatewaySwapRequest(action="quote").slippage_pct is None + assert GatewaySwapRequest(action="quote", slippage_pct="0").slippage_pct == "0" From 7d194eca1720794daf4fecdb60eec7c506e5fc7c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 23:30:02 -0700 Subject: [PATCH 21/65] fix(dex): read the fields hapi actually sends; resolve networks dynamically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LP screens read keys that do not exist in hapi's responses, so they degraded silently: pool-info was read for ~14 fields it never carries (the card rendered 'Unknown Pair' with no fee or price), close results were read under the wrong names and fell back to pre-close amounts so a rebalance re-opened with stale numbers, the zero-liquidity filter read absent keys and therefore never filtered, and AMM writes printed 'Tx: None' because the response carries signature, not transaction_hash. pnl_summary arrives as null rather than absent whenever a position lacks price or initial amounts, so .get(..., {}) returned None and the whole /lp screen died on the next attribute access. Guarded at all six sites. get_positions_owned no longer takes a pool filter, so the two callers still passing one raised TypeError into a bare except — LP holdings rendered empty. They now query per venue and filter rows by their own pool address, and the test double matches the real signature. Network ids are resolved from Gateway's own chain/network listing rather than a hardcoded map that was wrong for five of six EVM chains, and the pool detail paths thread the pool's own network instead of assuming Solana. A pending open is reported as pending everywhere rather than as an open with a null address, and the rebalance re-opens only after the close confirms. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- handlers/dex/_shared.py | 62 ++- handlers/dex/liquidity.py | 53 +- handlers/dex/lp_monitor_handlers.py | 81 ++- handlers/dex/menu.py | 21 +- handlers/dex/pools.py | 357 ++++++------- handlers/dex/swap.py | 14 +- .../hummingbot_api/formatters/gateway.py | 13 +- .../hummingbot_api/guides/gateway_clmm.md | 14 +- mcp_servers/hummingbot_api/schemas.py | 22 +- mcp_servers/hummingbot_api/server.py | 13 +- .../hummingbot_api/tools/gateway_clmm.py | 46 +- mcp_servers/hummingbot_api/tools/portfolio.py | 60 +-- tests/test_hummingbot_mcp_tools.py | 69 ++- tests/test_lp_contract_shape_fixes.py | 475 ++++++++++++++++++ 14 files changed, 908 insertions(+), 392 deletions(-) create mode 100644 tests/test_lp_contract_shape_fixes.py diff --git a/handlers/dex/_shared.py b/handlers/dex/_shared.py index 34ce6fba3..70ac77247 100644 --- a/handlers/dex/_shared.py +++ b/handlers/dex/_shared.py @@ -129,6 +129,47 @@ def invalidates(*groups: str): } +async def resolve_network_id(client, user_input: str) -> str: + """Resolve a user-typed network name to Gateway's '{chain}-{network}' id. + + Gateway builds its network ids from the yml basenames under + conf/chains//, so 'base' is 'ethereum-base' and 'bsc' is + 'ethereum-bsc' — nothing a static table can be trusted to keep in sync. + The API is the source of truth: GET /gateway/networks returns every + network id together with its chain and bare network name. + + Accepts either the full id ('ethereum-base') or the bare network name + ('base'). Raises ValueError for unknown or ambiguous input, listing what + Gateway actually offers. + """ + text = user_input.strip().lower() + result = await client.gateway.list_networks() + networks = (result or {}).get("networks") or [] + if not networks: + raise ValueError("Gateway returned no networks") + + for entry in networks: + if str(entry.get("network_id", "")).lower() == text: + return entry["network_id"] + + matches = [ + entry["network_id"] + for entry in networks + if str(entry.get("network", "")).lower() == text + ] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise ValueError( + f"Ambiguous network '{user_input}'. Use one of: {', '.join(sorted(matches))}" + ) + + available = sorted(str(entry.get("network_id")) for entry in networks) + raise ValueError( + f"Unknown network '{user_input}'. Available: {', '.join(available)}" + ) + + def get_explorer_url(tx_hash: str, network: str) -> Optional[str]: """Generate explorer URL for a transaction @@ -485,7 +526,10 @@ class HistoryFilters: network: Optional[str] = None # None = All offset: int = 0 limit: int = DEFAULT_PAGE_SIZE - total_count: int = 0 + # The API reports total_count only on the LAST page; while more rows remain + # it sends null and only has_more is meaningful. + total_count: Optional[int] = None + has_more: bool = False def to_dict(self) -> Dict[str, Any]: return { @@ -497,6 +541,7 @@ def to_dict(self) -> Dict[str, Any]: "offset": self.offset, "limit": self.limit, "total_count": self.total_count, + "has_more": self.has_more, } @classmethod @@ -509,7 +554,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "HistoryFilters": network=data.get("network"), offset=data.get("offset", 0), limit=data.get("limit", DEFAULT_PAGE_SIZE), - total_count=data.get("total_count", 0), + total_count=data.get("total_count"), + has_more=data.get("has_more", False), ) def reset_pagination(self) -> None: @@ -522,13 +568,16 @@ def current_page(self) -> int: @property def total_pages(self) -> int: + if self.total_count is None: + # Unknown total: at least this page, plus one more if there is one. + return self.current_page + (1 if self.has_more else 0) if self.total_count == 0: return 1 return (self.total_count + self.limit - 1) // self.limit @property def has_next(self) -> bool: - return self.offset + self.limit < self.total_count + return self.has_more @property def has_prev(self) -> bool: @@ -618,8 +667,11 @@ def build_pagination_buttons( else: buttons.append(InlineKeyboardButton(" ", callback_data="dex:noop")) - # Page indicator - page_text = f"{filters.current_page}/{filters.total_pages}" + # Page indicator — the total is unknown until the last page + if filters.total_count is None: + page_text = f"Page {filters.current_page}" + else: + page_text = f"{filters.current_page}/{filters.total_pages}" buttons.append(InlineKeyboardButton(page_text, callback_data="dex:noop")) # Next button diff --git a/handlers/dex/liquidity.py b/handlers/dex/liquidity.py index 2ab85ee8a..42d69be94 100644 --- a/handlers/dex/liquidity.py +++ b/handlers/dex/liquidity.py @@ -172,21 +172,11 @@ def is_active_with_liquidity(pos): # Must not be closed if pos.get("status") == "CLOSED": return False - # Check liquidity - liq = pos.get("liquidity") or pos.get("current_liquidity") - if liq is not None: - try: - return float(liq) > 0 - except (ValueError, TypeError): - pass - base = pos.get("base_token_amount") or pos.get("amount_base") - quote = pos.get("quote_token_amount") or pos.get("amount_quote") - if base is not None or quote is not None: - try: - return float(base or 0) > 0 or float(quote or 0) > 0 - except (ValueError, TypeError): - pass - return True + # hapi's position rows carry the amounts as base_token_amount / + # quote_token_amount; there is no 'liquidity' field to read. + base = float(pos.get("base_token_amount") or 0) + quote = float(pos.get("quote_token_amount") or 0) + return base > 0 or quote > 0 positions = [p for p in positions if is_active_with_liquidity(p)] @@ -309,7 +299,7 @@ def _format_compact_position_line( ) # Get position value from pnl_summary - pnl_summary = pos.get("pnl_summary", {}) + pnl_summary = pos.get("pnl_summary") or {} position_value_quote = pnl_summary.get("current_total_value_quote") # Get values from pnl_summary (all values are in quote token units) @@ -439,7 +429,7 @@ def _format_closed_position_line( pass # Get PnL data - use pre-calculated total_pnl_quote - pnl_summary = pos.get("pnl_summary", {}) + pnl_summary = pos.get("pnl_summary") or {} total_pnl_quote = pnl_summary.get("total_pnl_quote", 0) or 0 total_fees_value = pnl_summary.get("total_fees_value_quote", 0) or 0 @@ -895,15 +885,15 @@ def _format_detailed_position_line(pos: dict, token_cache: dict = None) -> str: pass # Get PnL summary - pnl = pos.get("pnl_summary", {}) + pnl = pos.get("pnl_summary") or {} initial_base = pnl.get("initial_base", pos.get("initial_base_token_amount", 0)) or 0 initial_quote = ( pnl.get("initial_quote", pos.get("initial_quote_token_amount", 0)) or 0 ) - final_base = pnl.get("current_base_total", pos.get("base_token_amount", 0)) or 0 - final_quote = pnl.get("current_quote_total", pos.get("quote_token_amount", 0)) or 0 - base_pnl = pnl.get("base_pnl", 0) or 0 - quote_pnl = pnl.get("quote_pnl", 0) or 0 + # hapi's pnl_summary names the live amounts current_base / current_quote; + # it reports no per-token PnL, so derive it from initial vs final. + final_base = pnl.get("current_base", pos.get("base_token_amount", 0)) or 0 + final_quote = pnl.get("current_quote", pos.get("quote_token_amount", 0)) or 0 # Fees collected base_fee = pos.get("base_fee_collected", 0) or 0 @@ -960,8 +950,8 @@ def _format_detailed_position_line(pos: dict, token_cache: dict = None) -> str: try: final_base_f = float(final_base) final_quote_f = float(final_quote) - base_pnl_f = float(base_pnl) - quote_pnl_f = float(quote_pnl) + base_pnl_f = final_base_f - float(initial_base) + quote_pnl_f = final_quote_f - float(initial_quote) # Format PnL with +/- signs base_pnl_str = ( @@ -1048,10 +1038,14 @@ async def handle_lp_history( positions = result.get("data", []) if result else [] pagination = result.get("pagination", {}) if result else {} - total_count = pagination.get("total_count", len(positions)) + # total_count is null while more rows remain; has_more is what tells us + # whether there is a next page. + total_count = pagination.get("total_count") + has_more = bool(pagination.get("has_more")) - # Update filters with total count + # Update filters with pagination state filters.total_count = total_count + filters.has_more = has_more set_history_filters(context.user_data, filters) if not positions and filters.offset == 0: @@ -1113,7 +1107,10 @@ def get_closed_time(pos): filter_summary = "" message = rf"📜 *Position History*{filter_summary}" + "\n" - message += rf"_Showing {len(positions)} of {total_count}_" + "\n\n" + if total_count is None: + message += rf"_Showing {len(positions)}, more available_" + "\n\n" + else: + message += rf"_Showing {len(positions)} of {total_count}_" + "\n\n" for pos in positions: line = _format_detailed_position_line(pos, token_cache) @@ -1123,7 +1120,7 @@ def get_closed_time(pos): keyboard = build_filter_buttons(filters, "dex:lp_hist") # Pagination row - if total_count > filters.limit: + if filters.has_next or filters.has_prev: keyboard.append(build_pagination_buttons(filters, "dex:lp_hist")) # Action buttons - use lp_refresh to ensure fresh data when going back diff --git a/handlers/dex/lp_monitor_handlers.py b/handlers/dex/lp_monitor_handlers.py index 32c213aa8..2bc01c60e 100644 --- a/handlers/dex/lp_monitor_handlers.py +++ b/handlers/dex/lp_monitor_handlers.py @@ -88,7 +88,7 @@ def format_position_detail_view( quote_price = _get_price(quote_sym, token_prices, 1.0) base_price = _get_price(base_sym, token_prices, 0) - pnl_summary = pos.get("pnl_summary", {}) + pnl_summary = pos.get("pnl_summary") or {} pnl_usd = float(pnl_summary.get("total_pnl_quote", 0) or 0) * quote_price value_usd = float(pnl_summary.get("current_lp_value_quote", 0) or 0) * quote_price @@ -230,7 +230,7 @@ async def handle_lpm_navigation( current_str = f"Current: {current}" # Format value - pnl_summary = pos.get("pnl_summary", {}) + pnl_summary = pos.get("pnl_summary") or {} value = pnl_summary.get("current_lp_value_quote", 0) value_str = "" if value: @@ -609,14 +609,27 @@ async def handle_lpm_rebalance_execute( logger.info(f"Close position result: {close_result}") - # hapi returns transaction_hash (None while pending) - close_tx = None - if isinstance(close_result, dict): - close_tx = close_result.get("transaction_hash") + close_tx = close_result.get("transaction_hash") close_tx_display = ( f"`{escape_markdown_v2(close_tx[:20])}...`" if close_tx else "_pending_" ) + # Re-opening spends the tokens the close returned, so it may only run + # once the close is CONFIRMED on-chain. hapi reports the Gateway + # transaction status as 'confirmed' | 'submitted' | 'failed'; on the + # latter two the tokens are not in the wallet yet (or never will be). + close_status = close_result.get("status") + if close_status != "confirmed": + await query.message.edit_text( + f"⚠️ *Rebalance stopped*\n\n" + f"The close is `{escape_markdown_v2(str(close_status))}`, not confirmed\\.\n" + f"Close Tx: {close_tx_display}\n\n" + f"No new position was opened — the withdrawn tokens are not in " + f"your wallet yet\\. Check the position and retry\\.", + parse_mode="MarkdownV2", + ) + return + # Update progress await query.message.edit_text( f"🔄 *Rebalancing\\.\\.\\.*\n\n" @@ -626,19 +639,21 @@ async def handle_lpm_rebalance_execute( parse_mode="MarkdownV2", ) - # Get the withdrawn amounts from the close result - base_withdrawn = close_result.get( - "base_amount", close_result.get("amount_base", 0) - ) - quote_withdrawn = close_result.get( - "quote_amount", close_result.get("amount_quote", 0) - ) + # What the close actually returned on-chain (hapi's + # CLMMClosePositionResponse). Never substitute the pre-close cached + # amounts: those are off by fees, rent and slippage. + base_withdrawn = close_result.get("base_token_amount_removed") + quote_withdrawn = close_result.get("quote_token_amount_removed") - # Fallback to original position amounts if not in close result - if not base_withdrawn: - base_withdrawn = pos.get("base_token_amount", pos.get("amount_a", 0)) - if not quote_withdrawn: - quote_withdrawn = pos.get("quote_token_amount", pos.get("amount_b", 0)) + if not base_withdrawn and not quote_withdrawn: + await query.message.edit_text( + f"⚠️ *Rebalance stopped*\n\n" + f"✅ Position closed \\(Tx: {close_tx_display}\\)\n" + f"❌ The close reported no withdrawn amounts\n\n" + f"No new position was opened\\. Your funds are in your wallet\\.", + parse_mode="MarkdownV2", + ) + return # Update progress await query.message.edit_text( @@ -653,14 +668,21 @@ async def handle_lpm_rebalance_execute( # strategyType is Meteora-only; hapi rejects it for other connectors. extra_params = {"strategyType": 2} if connector == "meteora" else None + # Decimal, not float: the SDK str()s whatever it is given, and a float + # puts "1e-08" / "0.30000000000000004" on the wire. An empty side is + # None (omitted), not 0. open_result = await client.gateway_clmm.open_position( connector=connector, network=network, pool_address=pool_address, lower_price=Decimal(str(lower_price)), upper_price=Decimal(str(upper_price)), - base_token_amount=float(base_withdrawn) if base_withdrawn else 0, - quote_token_amount=float(quote_withdrawn) if quote_withdrawn else 0, + base_token_amount=( + Decimal(str(base_withdrawn)) if base_withdrawn else None + ), + quote_token_amount=( + Decimal(str(quote_withdrawn)) if quote_withdrawn else None + ), extra_params=extra_params, ) @@ -677,12 +699,13 @@ async def handle_lpm_rebalance_execute( logger.info(f"Open position result: {open_result}") # hapi returns transaction_hash (None while pending) - open_tx = None - if isinstance(open_result, dict): - open_tx = open_result.get("transaction_hash") + open_tx = open_result.get("transaction_hash") open_tx_display = ( f"`{escape_markdown_v2(open_tx[:20])}...`" if open_tx else "_pending_" ) + # position_address is null with status 'submitted' when the open tx has + # not landed yet — hapi's poller records the address once it does. + open_position_address = open_result.get("position_address") # Get token symbols for display _, quote_symbol, pair = _get_position_tokens(pos, token_cache) @@ -699,13 +722,23 @@ async def handle_lpm_rebalance_execute( range_display = f"{escape_markdown_v2(str(lower_price))} \\- {escape_markdown_v2(str(upper_price))}" # Build success message + if open_position_address: + opened_line = ( + f"✅ New position opened: " + f"`{escape_markdown_v2(open_position_address[:20])}...`" + ) + else: + opened_line = ( + "⏳ New position submitted — address known once the tx confirms" + ) + lines = [ f"✅ *Rebalance Complete*", f"━━━━━━━━━━━━━━━━━━━━━", f"*{escape_markdown_v2(pair)}*", "", f"✅ Old position closed", - f"✅ New position opened", + opened_line, "", f"Range: {range_display}", f"Strategy: Bid\\-Ask", diff --git a/handlers/dex/menu.py b/handlers/dex/menu.py index a89f7f8b5..fd92615b4 100644 --- a/handlers/dex/menu.py +++ b/handlers/dex/menu.py @@ -303,22 +303,13 @@ async def _fetch_lp_positions(client) -> dict: open_positions = [p for p in positions if p.get("status") == "OPEN"] logger.info(f"After OPEN filter: {len(open_positions)} positions") - # Filter out positions with 0 liquidity + # Filter out positions with 0 liquidity. hapi's position rows carry the + # amounts as base_token_amount / quote_token_amount (always present, + # always floats) — there is no 'liquidity' field to read. def has_liquidity(pos): - liq = pos.get("liquidity") or pos.get("current_liquidity") - if liq is not None: - try: - return float(liq) > 0 - except (ValueError, TypeError): - pass - base = pos.get("base_amount") or pos.get("amount_base") - quote = pos.get("quote_amount") or pos.get("amount_quote") - if base is not None and quote is not None: - try: - return float(base) > 0 or float(quote) > 0 - except (ValueError, TypeError): - pass - return True + base = float(pos.get("base_token_amount") or 0) + quote = float(pos.get("quote_token_amount") or 0) + return base > 0 or quote > 0 active_positions = [p for p in open_positions if has_liquidity(p)] if len(active_positions) < len(open_positions): diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index e34519021..39b909fe6 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -31,7 +31,13 @@ resolve_token_symbol, ) -from ._shared import DEFAULT_CACHE_TTL, get_cached, invalidate_cache, set_cached +from ._shared import ( + DEFAULT_CACHE_TTL, + get_cached, + invalidate_cache, + resolve_network_id, + set_cached, +) from .visualizations import ( generate_aggregated_liquidity_chart, generate_combined_chart, @@ -41,6 +47,27 @@ logger = logging.getLogger(__name__) +# hapi's CLMM pool LISTING (/gateway/clmm/pools) is Solana-only — it accepts a +# bare Solana network name and rejects every non-Solana connector — so pools +# discovered through it live on this network. Anything reached by address +# carries its own network instead. +SOLANA_CLMM_NETWORK = "solana-mainnet-beta" + + +def _require_network(record: dict, what: str) -> str: + """Read the network a pool/position lives on, failing loudly if it is absent. + + Everything that reaches these screens is stamped with its own network — a + missing one means a stale cached record, and guessing 'solana-mainnet-beta' + is how EVM pools silently became unreachable. + """ + network = record.get("network") + if not network: + raise ValueError( + f"{what} has no network recorded — reopen it from the menu to refresh it" + ) + return network + # ============================================ # TOKEN CACHE HELPERS @@ -186,76 +213,6 @@ async def handle_pool_info(update: Update, context: ContextTypes.DEFAULT_TYPE) - ) -def _format_pool_info(pool: dict) -> str: - """Format detailed pool information - - Args: - pool: Pool data dictionary - - Returns: - Formatted pool info string (not escaped) - """ - lines = [] - - pair = pool.get("trading_pair", pool.get("name", "N/A")) - lines.append(f"🏊 Pool: {pair}") - lines.append("") - - # Basic info - show full address for copy/paste - if pool.get("pool_address") or pool.get("address"): - addr = pool.get("pool_address") or pool.get("address") - lines.append(f"📍 Address:") - lines.append(f"`{addr}`") - - if pool.get("bin_step"): - lines.append(f"📊 Bin Step: {pool.get('bin_step')}") - - if pool.get("fee") is not None: - fee_pct = ( - float(pool.get("fee", 0)) * 100 - if float(pool.get("fee", 0)) < 1 - else pool.get("fee") - ) - lines.append(f"💸 Fee: {fee_pct:.2f}%") - - lines.append("") - - # TVL and volume - tvl = pool.get("liquidity") or pool.get("tvl") - if tvl is not None: - lines.append(f"💰 TVL: ${format_compact_number(tvl)}") - - vol_24h = pool.get("volume_24h") - if vol_24h is not None: - lines.append(f"📈 Volume 24h: ${format_compact_number(vol_24h)}") - - # APR/Fees - apr = pool.get("apr") - if apr is not None: - lines.append(f"📊 APR: {_format_percent(apr)}") - - fee_tvl = pool.get("fee_tvl_ratio", {}) - if isinstance(fee_tvl, dict) and fee_tvl.get("hour_24"): - lines.append(f"💵 Fee/TVL 24h: {_format_percent(fee_tvl.get('hour_24'))}") - - lines.append("") - - # Prices - current_price = pool.get("current_price") or pool.get("price") - if current_price is not None: - lines.append(f"💱 Current Price: {current_price}") - - # Token info - base_token = pool.get("base_token") or pool.get("token_a") - quote_token = pool.get("quote_token") or pool.get("token_b") - if base_token: - lines.append(f"🪙 Base: {base_token}") - if quote_token: - lines.append(f"💵 Quote: {quote_token}") - - return "\n".join(lines) - - async def process_pool_info( update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str ) -> None: @@ -280,9 +237,15 @@ async def process_pool_info( f"Unsupported connector '{connector}'. Use: {', '.join(supported_connectors)}" ) + chat_id = update.effective_chat.id + client = await get_client(chat_id, context=context) + + if not hasattr(client, "gateway_clmm"): + raise ValueError("Gateway CLMM not available") + # Determine network based on connector type if connector in solana_connectors: - network = "solana-mainnet-beta" + network = SOLANA_CLMM_NETWORK else: # EVM connectors require network specification if len(parts) < 3: @@ -290,25 +253,8 @@ async def process_pool_info( f"EVM connector '{connector}' requires network.\n\n" f"Example: {connector} {pool_address} ethereum" ) - network = parts[2].lower() - # Normalize network names - network_mapping = { - "ethereum": "ethereum-mainnet", - "eth": "ethereum-mainnet", - "arbitrum": "arbitrum-one", - "arb": "arbitrum-one", - "base": "base-mainnet", - "bsc": "binance-smart-chain", - "polygon": "polygon-mainnet", - "optimism": "optimism-mainnet", - } - network = network_mapping.get(network, network) - - chat_id = update.effective_chat.id - client = await get_client(chat_id, context=context) - - if not hasattr(client, "gateway_clmm"): - raise ValueError("Gateway CLMM not available") + # Gateway owns the network ids; ask it rather than guessing. + network = await resolve_network_id(client, parts[2]) # Send loading message loading_msg = await update.message.reply_text("🔄 Loading pool details...") @@ -335,29 +281,18 @@ async def process_pool_info( ) return - # Construct pool dict with connector info for _show_pool_detail + # Construct pool dict for _show_pool_detail. Only the fields hapi's + # CLMMPoolInfoResponse actually carries: TVL/volume/APR are pool-LIST + # fields and simply do not exist on pool-info. pool = { "pool_address": pool_address, "address": pool_address, "connector": connector, - "trading_pair": result.get("trading_pair", result.get("name", "N/A")), - # Copy over any available data from result - "liquidity": result.get("liquidity") or result.get("tvl"), - "volume_24h": result.get("volume_24h"), - "fees_24h": result.get("fees_24h"), - "base_fee_percentage": result.get("base_fee_percentage") - or result.get("fee"), - "max_fee_percentage": result.get("max_fee_percentage"), - "apr": result.get("apr"), - "apy": result.get("apy"), + "network": network, "bin_step": result.get("bin_step"), - "current_price": result.get("current_price") or result.get("price"), - "mint_x": result.get("mint_x") - or result.get("base_token") - or result.get("token_a"), - "mint_y": result.get("mint_y") - or result.get("quote_token") - or result.get("token_b"), + "current_price": result.get("price"), + "mint_x": result.get("base_token_address"), + "mint_y": result.get("quote_token_address"), } # Use the rich pool detail display with chart and add liquidity button @@ -748,9 +683,10 @@ async def process_pool_list( pools = result.get("pools", []) - # Add connector to each pool for later use + # Add connector/network to each pool for later use for pool in pools: pool["connector"] = connector + pool["network"] = SOLANA_CLMM_NETWORK if not pools: message = escape_markdown_v2("📋 No pools found") @@ -896,7 +832,12 @@ async def fetch_pool_with_info(pool): connector = pool.get("connector", "meteora") try: pool_info = await asyncio.wait_for( - _fetch_pool_info(client, pool_address, connector), + _fetch_pool_info( + client, + pool_address, + connector, + _require_network(pool, "This pool"), + ), timeout=POOL_FETCH_TIMEOUT, ) bins = pool_info.get("bins", []) @@ -1039,14 +980,15 @@ async def fetch_pool_with_info(pool): async def _fetch_pool_info( - client, pool_address: str, connector: str = "meteora" + client, pool_address: str, connector: str, network: str ) -> dict: """Fetch detailed pool info including bins Args: client: Gateway client pool_address: Pool address to fetch - connector: Connector name (meteora, raydium) + connector: Connector name (meteora, raydium, uniswap, ...) + network: Network ID in '{chain}-{network}' form the pool lives on Returns: Pool info dict with bins data @@ -1055,7 +997,7 @@ async def _fetch_pool_info( if hasattr(client, "gateway_clmm"): result = await client.gateway_clmm.get_pool_info( connector=connector, - network="solana-mainnet-beta", + network=network, pool_address=pool_address, ) return result or {} @@ -1111,7 +1053,9 @@ async def _show_pool_detail( pool_address = pool.get("pool_address", pool.get("address", "N/A")) connector = pool.get("connector", "meteora") - network = "solana-mainnet-beta" + # Every pool dict reaching here is stamped with the network it was found on + # (pool list, pool-info lookup, position, or GeckoTerminal). + network = _require_network(pool, "This pool") chat_id = update.effective_chat.id # Parallel fetch: pool_info, token_cache, and OHLCV data @@ -1125,7 +1069,7 @@ async def fetch_pool_info_task(): if pool_info is not None: return pool_info client = await get_client(chat_id, context=context) - return await _fetch_pool_info(client, pool_address, connector) + return await _fetch_pool_info(client, pool_address, connector, network) async def fetch_token_cache_task(): if token_cache is not None: @@ -1160,22 +1104,14 @@ async def fetch_ohlcv_task(): if token_cache: context.user_data["token_cache"] = token_cache - # Try to get trading pair name from multiple sources + # Pair name comes from the pool LIST (CLMMPoolListItem.trading_pair/name); + # pool-info carries only token ADDRESSES, so resolve symbols from those. pair = pool.get("trading_pair") or pool.get("name") - mint_x = ( - pool.get("mint_x") or pool_info.get("mint_x") or pool_info.get("token_x_mint") - ) - mint_y = ( - pool.get("mint_y") or pool_info.get("mint_y") or pool_info.get("token_y_mint") - ) + mint_x = pool.get("mint_x") or pool_info.get("base_token_address") + mint_y = pool.get("mint_y") or pool_info.get("quote_token_address") if not pair or pair == "N/A": - # Try to construct from pool_info token symbols - token_x = pool_info.get("token_x_symbol") or pool_info.get("base_symbol") - token_y = pool_info.get("token_y_symbol") or pool_info.get("quote_symbol") - if token_x and token_y: - pair = f"{token_x}/{token_y}" - elif mint_x and mint_y: + if mint_x and mint_y: base_symbol = resolve_token_symbol(mint_x, token_cache) quote_symbol = resolve_token_symbol(mint_y, token_cache) pair = f"{base_symbol}/{quote_symbol}" @@ -1205,9 +1141,7 @@ async def fetch_ohlcv_task(): quote_symbol = resolve_token_symbol(mint_y, token_cache) if mint_y else "QUOTE" # Get current price and bin step - current_price = ( - pool_info.get("price") or pool.get("current_price") or pool.get("price") - ) + current_price = pool_info.get("price") or pool.get("current_price") bin_step = pool.get("bin_step") or pool_info.get("bin_step") bins = pool_info.get("bins", []) active_bin = pool_info.get("active_bin_id") @@ -1280,17 +1214,15 @@ async def fetch_ohlcv_task(): quote_in_gateway = mint_y and mint_y in token_cache tokens_in_gateway = base_in_gateway and quote_in_gateway - # Get pool metrics - tvl = ( - pool.get("liquidity") - or pool.get("tvl") - or pool_info.get("liquidity") - or pool_info.get("tvl") - ) - vol_24h = pool.get("volume_24h") or pool_info.get("volume_24h") - fees_24h = pool.get("fees_24h") or pool_info.get("fees_24h") - base_fee = pool.get("base_fee_percentage") or pool_info.get("base_fee_percentage") - apr = pool.get("apr") or pool_info.get("apr") + # Pool metrics. TVL/volume/fees/APR exist only on the pool LIST item (or a + # GeckoTerminal pool); pool-info has none of them, so a pool reached by + # address simply shows no metrics row. The fee percentage does come from + # pool-info, as fee_pct. + tvl = pool.get("liquidity") + vol_24h = pool.get("volume_24h") + fees_24h = pool.get("fees_24h") + base_fee = pool.get("base_fee_percentage") or pool_info.get("fee_pct") + apr = pool.get("apr") # Build message - matching show_add_position_menu format message = r"➕ *Add CLMM Position*" + "\n\n" @@ -1784,24 +1716,16 @@ async def handle_add_to_gateway( await query.answer("No pool selected") return - # Get token addresses - mint_x = ( - selected_pool.get("mint_x") - or pool_info.get("mint_x") - or pool_info.get("token_x_mint") - ) - mint_y = ( - selected_pool.get("mint_y") - or pool_info.get("mint_y") - or pool_info.get("token_y_mint") - ) + # Get token addresses (pool list: mint_x/mint_y; pool-info: *_token_address) + mint_x = selected_pool.get("mint_x") or pool_info.get("base_token_address") + mint_y = selected_pool.get("mint_y") or pool_info.get("quote_token_address") if not mint_x or not mint_y: await query.answer("Token addresses not available", show_alert=True) return - network_id = "solana-mainnet-beta" - gecko_network = "solana" + network_id = _require_network(selected_pool, "This pool") + gecko_network = get_gecko_network(network_id) # Show loading await query.answer("Adding tokens to Gateway...") @@ -2419,9 +2343,9 @@ def _format_position_detail( ) # Get PNL data - pnl_summary = pos.get("pnl_summary", {}) - base_pnl = pnl_summary.get("base_pnl") - quote_pnl = pnl_summary.get("quote_pnl") + # hapi emits pnl_summary: null when entry/current price or initial amounts + # are missing, so a {} default on a present key never applies. + pnl_summary = pos.get("pnl_summary") or {} # Get pending fees base_fee = pos.get( @@ -2634,14 +2558,11 @@ def get_price(symbol, default=0): except (ValueError, TypeError): lines.append(f" {range_emoji} Range: {range_str}") - # Compact PNL - if base_pnl is not None or quote_pnl is not None: - pnl_parts = [] - if quote_pnl is not None: - sign = "+" if quote_pnl >= 0 else "" - pnl_parts.append(f"{sign}{format_amount(quote_pnl)} {quote_symbol}") - if pnl_parts: - lines.append(f" 📊 {' '.join(pnl_parts)}") + # Compact PNL (quote-denominated, as hapi calculates it) + compact_pnl = pnl_summary.get("total_pnl_quote") + if compact_pnl is not None: + sign = "+" if compact_pnl >= 0 else "" + lines.append(f" 📊 {sign}{format_amount(compact_pnl)} {quote_symbol}") # Compact Fees - show if any fees are pending try: @@ -2890,12 +2811,12 @@ async def handle_pos_view( chat_id = update.effective_chat.id connector = pos.get("connector", "meteora") pool_address = pos.get("pool_address", "") - network = "solana-mainnet-beta" + network = _require_network(pos, "This position") # Get position price data lower_price = pos.get("lower_price", pos.get("price_lower")) upper_price = pos.get("upper_price", pos.get("price_upper")) - pnl_summary = pos.get("pnl_summary", {}) + pnl_summary = pos.get("pnl_summary") or {} entry_price = pnl_summary.get("entry_price") current_price = pnl_summary.get("current_price") @@ -2916,7 +2837,7 @@ async def fetch_pool_info_task(): if cached: return cached client = await get_client(chat_id, context=context) - info = await _fetch_pool_info(client, pool_address, connector) + info = await _fetch_pool_info(client, pool_address, connector, network) if info: set_cached(context.user_data, cache_key, info) return info or {} @@ -3139,6 +3060,7 @@ async def handle_pos_view_pool( "pool_address": pool_address, "address": pool_address, "connector": connector, + "network": _require_network(pos, "This position"), "trading_pair": pos.get("trading_pair"), "mint_x": pos.get("base_token"), "mint_y": pos.get("quote_token"), @@ -3251,15 +3173,22 @@ async def handle_pos_collect_fees( success_msg, parse_mode="MarkdownV2", reply_markup=back_keyboard ) else: + # A successful collect always returns a populated + # CLMMCollectFeesResponse, so an empty result is an API failure — + # not a zero-fee collect. + failure_msg = ( + f"❌ No response from the API collecting fees on " + f"{escape_markdown_v2(pair)}" + ) try: await query.message.edit_text( - f"ℹ️ No fees to collect from {escape_markdown_v2(pair)}", + failure_msg, parse_mode="MarkdownV2", reply_markup=back_keyboard, ) except Exception: await query.message.reply_text( - f"ℹ️ No fees to collect from {escape_markdown_v2(pair)}", + failure_msg, parse_mode="MarkdownV2", reply_markup=back_keyboard, ) @@ -3490,9 +3419,14 @@ async def process_position_list( if not hasattr(client, "gateway_clmm"): raise ValueError("Gateway CLMM not available") - positions = await client.gateway_clmm.get_positions_owned( - connector=connector, network=network, pool_address=pool_address + # positions-owned has no pool filter: it lists every CLMM position the + # wallet owns on the connector, each row carrying its own pool_address. + all_positions = await client.gateway_clmm.get_positions_owned( + connector=connector, network=network ) + positions = [ + p for p in (all_positions or []) if p.get("pool_address") == pool_address + ] # Save params set_dex_last_pool( @@ -3933,35 +3867,25 @@ async def show_add_position_menu( # Get current price and bin step for range calculation # Prefer pool_info (fetched data) over selected_pool (list data) - current_price = ( - pool_info.get("price") - or selected_pool.get("current_price") - or selected_pool.get("price") - ) + current_price = pool_info.get("price") or selected_pool.get("current_price") bin_step = pool_info.get("bin_step") or selected_pool.get("bin_step") bins = pool_info.get("bins", []) - network = params.get("network", "solana-mainnet-beta") + network = params.get("network") or _require_network(selected_pool, "This pool") - # Extract token addresses/symbols from pool info - base_token = selected_pool.get("base_token") or pool_info.get("base_token") - quote_token = selected_pool.get("quote_token") or pool_info.get("quote_token") + # Extract token addresses. pool-info names them base_token_address / + # quote_token_address; the pool list names them mint_x / mint_y. + base_token = selected_pool.get("base_token") + quote_token = selected_pool.get("quote_token") - # Try mint addresses if token not set if not base_token: - base_token = ( - selected_pool.get("mint_x") - or pool_info.get("mint_x") - or pool_info.get("token_x_mint") - ) + base_token = selected_pool.get("mint_x") or pool_info.get("base_token_address") if not quote_token: - quote_token = ( - selected_pool.get("mint_y") - or pool_info.get("mint_y") - or pool_info.get("token_y_mint") + quote_token = selected_pool.get("mint_y") or pool_info.get( + "quote_token_address" ) - # Get pair name, with fallback to resolving from token addresses - pair = selected_pool.get("trading_pair") or pool_info.get("trading_pair") + # Pair name is a pool-LIST field; pool-info has none. + pair = selected_pool.get("trading_pair") if not pair or pair in ("Pool", "N/A", "Unknown Pair"): # Try to resolve from token addresses using token_cache if base_token and quote_token: @@ -4137,10 +4061,8 @@ async def show_add_position_menu( ) if bin_step: help_text += f"📊 *Bin Step:* `{escape_markdown_v2(str(bin_step))}` _\\(default 20 bins each side\\)_\n" - # Add fee if available - base_fee = pool_info.get("base_fee_percentage") or selected_pool.get( - "base_fee_percentage" - ) + # Add fee if available (pool-info: fee_pct; pool list: base_fee_percentage) + base_fee = pool_info.get("fee_pct") or selected_pool.get("base_fee_percentage") if base_fee: help_text += f"💸 *Fee:* `{escape_markdown_v2(str(base_fee))}%`\n" @@ -4555,7 +4477,7 @@ async def handle_pos_refresh( pool_address = params.get("pool_address") or selected_pool.get("pool_address", "") connector = params.get("connector") or selected_pool.get("connector", "meteora") - network = params.get("network", "solana-mainnet-beta") + network = params.get("network") or _require_network(selected_pool, "This pool") base_token = selected_pool.get("base_token", "") quote_token = selected_pool.get("quote_token", "") @@ -4575,7 +4497,7 @@ async def handle_pos_refresh( if pool_address: chat_id = update.effective_chat.id client = await get_client(chat_id, context=context) - pool_info = await _fetch_pool_info(client, pool_address, connector) + pool_info = await _fetch_pool_info(client, pool_address, connector, network) set_cached(context.user_data, pool_cache_key, pool_info) context.user_data["selected_pool_info"] = pool_info @@ -4979,7 +4901,9 @@ async def handle_pos_add_confirm( ) # strategyType is Meteora-only; hapi rejects it for other connectors - extra_params = {"strategyType": strategy_type} if connector == "meteora" else None + extra_params = ( + {"strategyType": strategy_type} if connector == "meteora" else None + ) result = await client.gateway_clmm.open_position( connector=connector, @@ -5277,7 +5201,8 @@ async def process_add_position( # Now execute connector = params.get("connector", "meteora") - network = params.get("network", "solana-mainnet-beta") + network = params.get("network") or SOLANA_CLMM_NETWORK + strategy_type = int(params.get("strategy_type", "0")) chat_id = update.effective_chat.id client = await get_client(chat_id, context=context) @@ -5285,6 +5210,11 @@ async def process_add_position( if not hasattr(client, "gateway_clmm"): raise ValueError("Gateway CLMM not available") + # strategyType is Meteora-only; hapi rejects it for other connectors + extra_params = ( + {"strategyType": strategy_type} if connector == "meteora" else None + ) + result = await client.gateway_clmm.open_position( connector=connector, network=network, @@ -5297,6 +5227,7 @@ async def process_add_position( quote_token_amount=( Decimal(params["amount_quote"]) if params["amount_quote"] else None ), + extra_params=extra_params, ) if result is None: @@ -5319,10 +5250,22 @@ async def process_add_position( f"Quote: {params['amount_quote']}\n" ) - if isinstance(result, dict) and result.get("transaction_hash"): - pos_info += escape_markdown_v2( - f"\nTx: {result['transaction_hash'][:16]}..." - ) + if isinstance(result, dict): + # transaction_hash is None while the open tx is still pending + if result.get("transaction_hash"): + pos_info += escape_markdown_v2( + f"\nTx: {result['transaction_hash'][:16]}..." + ) + # position_address is null when the tx was submitted but not yet + # confirmed — hapi's poller records the position once it lands + if result.get("position_address"): + pos_info += escape_markdown_v2( + f"\nPosition: {result['position_address'][:16]}..." + ) + elif result.get("status") == "submitted": + pos_info += escape_markdown_v2( + "\nPosition: pending confirmation (address known once the tx lands)" + ) keyboard = [ [InlineKeyboardButton("« Back to Liquidity", callback_data="dex:liquidity")] diff --git a/handlers/dex/swap.py b/handlers/dex/swap.py index 476b7dcfd..0ba5bfdfb 100644 --- a/handlers/dex/swap.py +++ b/handlers/dex/swap.py @@ -1437,10 +1437,13 @@ async def handle_swap_history( result = await client.gateway_swap.search_swaps(**search_params) swaps = result.get("data", []) pagination = result.get("pagination", {}) - total_count = pagination.get("total_count", len(swaps)) + # total_count is null while more rows remain; has_more is what tells us + # whether there is a next page. + total_count = pagination.get("total_count") - # Update filters with total count + # Update filters with pagination state filters.total_count = total_count + filters.has_more = bool(pagination.get("has_more")) set_history_filters(context.user_data, filters) if not swaps and filters.offset == 0: @@ -1484,7 +1487,10 @@ async def handle_swap_history( filter_summary = "" message = rf"🔍 *Swap History*{filter_summary}" + "\n" - message += rf"_Showing {len(swaps)} of {total_count}_" + "\n\n" + if total_count is None: + message += rf"_Showing {len(swaps)}, more available_" + "\n\n" + else: + message += rf"_Showing {len(swaps)} of {total_count}_" + "\n\n" for swap in swaps: line = _format_detailed_swap_line(swap) @@ -1507,7 +1513,7 @@ async def handle_swap_history( keyboard = build_filter_buttons(filters, "dex:swap_hist") # Pagination row - if total_count > filters.limit: + if filters.has_next or filters.has_prev: keyboard.append(build_pagination_buttons(filters, "dex:swap_hist")) # Action buttons - use swap_refresh to ensure fresh data when going back diff --git a/mcp_servers/hummingbot_api/formatters/gateway.py b/mcp_servers/hummingbot_api/formatters/gateway.py index 0c6c016c2..b8c949fbb 100644 --- a/mcp_servers/hummingbot_api/formatters/gateway.py +++ b/mcp_servers/hummingbot_api/formatters/gateway.py @@ -204,7 +204,10 @@ def format_amm_result(action: str, result: dict[str, Any]) -> str: "remove_liquidity", "create_pool", ) and isinstance(payload, dict): - tx = payload.get("transaction_hash") + # AMM write responses are chain-neutral: the tx identifier is + # `signature` (AMMTransactionResponse / AMMCreatePoolResponse), not + # `transaction_hash` as on the CLMM surface. + tx = payload.get("signature") extra = "" if action == "create_pool": extra = f"\nPool: {payload.get('pool_address')} Seed price: {payload.get('price')}" @@ -245,9 +248,15 @@ def format_clmm_result(action: str, result: dict[str, Any]) -> str: return "\n".join(lines) if action == "open" and isinstance(payload, dict): + # A submitted-not-confirmed open has position_address=None: the address + # is unknowable until the tx lands, so say that instead of "None". + position = ( + payload.get("position_address") + or "pending — known once the transaction confirms" + ) return ( f"{header}\n" - f"Position: {payload.get('position_address')}\n" + f"Position: {position}\n" f"Tx: {payload.get('transaction_hash')} Status: {payload.get('status')}\n" f"Range: {payload.get('lower_price')}–{payload.get('upper_price')}\n" "This position is NOT tracked by any executor — it will not be range-monitored, " diff --git a/mcp_servers/hummingbot_api/guides/gateway_clmm.md b/mcp_servers/hummingbot_api/guides/gateway_clmm.md index 000bdf918..7a4c8956b 100644 --- a/mcp_servers/hummingbot_api/guides/gateway_clmm.md +++ b/mcp_servers/hummingbot_api/guides/gateway_clmm.md @@ -20,6 +20,7 @@ no executor to do that for you: | `meteora` | Solana | DLMM | | `raydium` | Solana | CLMM | | `orca` | Solana | Whirlpools | +| `pancakeswap-sol` | Solana | CLMM | | `uniswap` | EVM | V3 | | `pancakeswap` | EVM | V3 | @@ -55,13 +56,10 @@ Example: `manage_clmm(action="create_pool", connector="meteora", network="solana ## pool_address on close and collect_fees -The API reads a position's pool from its own database. Positions opened by an `lp_executor` are -never in it — the bot opens those straight against Gateway — so for those you must pass -`pool_address` explicitly or the call fails with a 400 naming exactly this. The orphan listing -reports the pool for each orphan, so pass it through. - -Gateway itself needs only `position_address` to close; `pool_address` is used to snapshot pending -fees before the close so they can be reported. +`pool_address` is optional and informational on both. Gateway needs only `position_address` to +close or collect, and the API's pre-close fee snapshot (positions-owned, which takes no pool +filter) does not use it either — so a position the API never recorded, such as every +`lp_executor` position, closes fine without one. ## Recovering an orphaned position @@ -72,7 +70,7 @@ fees before the close so they can be reported. `position_address` / `pool_address`): `manage_clmm(action="position_info", connector=, network=)` 3. Close it: - `manage_clmm(action="close", connector=, network=, position_address=, pool_address=)` + `manage_clmm(action="close", connector=, network=, position_address=)` 4. `manage_executors(action="resolve_orphan", executor_id="...")` so it stops being reported. If an entry has `needs_onchain_reconciliation: true` its position address was never persisted (an diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index 0a51e9f21..d4c079a1f 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -635,7 +635,8 @@ class AMMRequest(BaseModel): ) network: str | None = Field( default=None, - description="Network ID in 'chain-network' format. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'base-mainnet'", + description="Network ID in 'chain-network' format, built from Gateway's chain/network " + "config names. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'ethereum-base'", ) wallet_address: str | None = Field( default=None, @@ -718,13 +719,16 @@ class CLMMRequest(BaseModel): connector: str | None = Field( default=None, - description="CLMM connector (required for any action): 'meteora', 'raydium', 'orca' (Solana), " - "'uniswap', 'pancakeswap' (EVM). A '/clmm' form is accepted, so an orphan record's " - "lp_provider (e.g. 'orca/clmm') can be passed through unchanged.", + description="CLMM connector (required for any action): 'meteora', 'raydium', 'orca', " + "'pancakeswap-sol' (Solana), 'uniswap', 'pancakeswap' (EVM). A '/clmm' form is " + "accepted, so an orphan record's lp_provider (e.g. 'orca/clmm') can be passed through " + "unchanged.", ) network: str | None = Field( default=None, - description="Network ID in 'chain-network' format. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'bsc-mainnet'", + description="Network ID in 'chain-network' format, built from Gateway's chain/network " + "config names. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'ethereum-base', " + "'ethereum-bsc'", ) wallet_address: str | None = Field( default=None, @@ -732,10 +736,10 @@ class CLMMRequest(BaseModel): ) pool_address: str | None = Field( default=None, - description="Pool contract address. Required for open. Also required to close or " - "collect fees on a position the API never recorded — every lp_executor position, since the " - "bot opens those straight against Gateway. Not used by position_info, which lists every " - "position the wallet owns.", + description="Pool contract address. Required for open. Optional and informational " + "everywhere else: the API never reads it on close or collect_fees (Gateway needs only " + "position_address, and the pre-close fee snapshot does not use it), and position_info " + "lists every position the wallet owns without a pool filter.", ) position_address: str | None = Field( default=None, diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index 22fb46648..fe8483c75 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -1017,20 +1017,21 @@ async def manage_clmm( recover an orphan, use `close`. Positions opened by an lp_executor are not in the API database (the bot opens them straight - against Gateway), so close/collect_fees on those REQUIRE `pool_address` — the orphan listing - reports it. Without it the call fails with a 400 saying so. + against Gateway), and they close fine anyway: the API never reads `pool_address` on + close/collect_fees. Pool discovery lives in `explore_dex_pools`. Args: action: CLMM action. Leave empty to load the CLMM guide. - connector: CLMM connector: meteora | raydium | orca (Solana), uniswap | pancakeswap (EVM). - A '/clmm' form is accepted, so an orphan's lp_provider passes through unchanged. + connector: CLMM connector: meteora | raydium | orca | pancakeswap-sol (Solana), + uniswap | pancakeswap (EVM). A '/clmm' form is accepted, so an orphan's + lp_provider passes through unchanged. network: Network ID in 'chain-network' format (e.g. 'solana-mainnet-beta', 'ethereum-mainnet'). For an orphan record this is the `connector_name` field. wallet_address: Wallet address (optional, uses default if not provided). - pool_address: Pool contract address. Required for open and position_info, and for - close/collect_fees on a position the API never recorded. + pool_address: Pool contract address. Required for open; informational elsewhere — + position_info takes no pool filter and close/collect_fees never read it. position_address: Position NFT address (add_liquidity, remove_liquidity, close, collect_fees). lower_price: Lower price bound of the range (open). upper_price: Upper price bound of the range (open). diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index 1beca9c1a..9eba197db 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -67,42 +67,32 @@ def format_pools_as_table(pools: list[dict[str, Any]]) -> str: def format_pools_as_detailed_table(pools: list[dict[str, Any]]) -> str: """ - Format pool data as a detailed table string with exploded volume and fee_tvl_ratio fields. + Format pool data as a detailed table string. - Columns: address | trading_pair | mint_x | mint_y | bin_step | current_price | liquidity | - base_fee_percentage | max_fee_percentage | protocol_fee_percentage | apr | apy | - volume_hour_1 | volume_hour_12 | volume_hour_24 | - fee_tvl_ratio_hour_1 | fee_tvl_ratio_hour_12 | fee_tvl_ratio_hour_24 + Every column here is a field of hapi's CLMMPoolListItem. The old exploded + volume / fee_tvl_ratio columns, plus max_fee_percentage and + protocol_fee_percentage, were dropped: the API never sends them, so they + rendered as a permanent wall of N/A. + + Columns: address | name | trading_pair | mint_x | mint_y | bin_step | current_price | + liquidity | base_fee_percentage | apr | apy | volume_24h | fees_24h """ if not pools: return "No pools found." # Header - detailed columns header = ( - "address | trading_pair | mint_x | mint_y | bin_step | current_price | liquidity | " - "base_fee_percentage | max_fee_percentage | protocol_fee_percentage | apr | apy | " - "volume_hour_1 | volume_hour_12 | volume_hour_24 | " - "fee_tvl_ratio_hour_1 | fee_tvl_ratio_hour_12 | fee_tvl_ratio_hour_24" + "address | name | trading_pair | mint_x | mint_y | bin_step | current_price | " + "liquidity | base_fee_percentage | apr | apy | volume_24h | fees_24h" ) separator = "-" * 300 # Format each pool as a row rows = [] for pool in pools: - # Extract nested volume fields - volume = pool.get("volume", {}) - volume_hour_1 = volume.get("hour_1", "N/A") - volume_hour_12 = volume.get("hour_12", "N/A") - volume_hour_24 = volume.get("hour_24", "N/A") - - # Extract nested fee_tvl_ratio fields - fee_tvl_ratio = pool.get("fee_tvl_ratio", {}) - fee_tvl_ratio_hour_1 = fee_tvl_ratio.get("hour_1", "N/A") - fee_tvl_ratio_hour_12 = fee_tvl_ratio.get("hour_12", "N/A") - fee_tvl_ratio_hour_24 = fee_tvl_ratio.get("hour_24", "N/A") - row = ( f"{get_field(pool, 'address', default='N/A')} | " + f"{get_field(pool, 'name', default='N/A')} | " f"{get_field(pool, 'trading_pair', default='N/A')} | " f"{get_field(pool, 'mint_x', default='N/A')} | " f"{get_field(pool, 'mint_y', default='N/A')} | " @@ -110,16 +100,10 @@ def format_pools_as_detailed_table(pools: list[dict[str, Any]]) -> str: f"{format_number(get_field(pool, 'current_price', default=None))} | " f"{format_number(get_field(pool, 'liquidity', default=None))} | " f"{format_number(get_field(pool, 'base_fee_percentage', default=None))} | " - f"{format_number(get_field(pool, 'max_fee_percentage', default=None))} | " - f"{format_number(get_field(pool, 'protocol_fee_percentage', default=None))} | " f"{format_number(get_field(pool, 'apr', default=None))} | " f"{format_number(get_field(pool, 'apy', default=None))} | " - f"{format_number(volume_hour_1)} | " - f"{format_number(volume_hour_12)} | " - f"{format_number(volume_hour_24)} | " - f"{format_number(fee_tvl_ratio_hour_1)} | " - f"{format_number(fee_tvl_ratio_hour_12)} | " - f"{format_number(fee_tvl_ratio_hour_24)}" + f"{format_number(get_field(pool, 'volume_24h', default=None))} | " + f"{format_number(get_field(pool, 'fees_24h', default=None))}" ) rows.append(row) @@ -142,6 +126,7 @@ async def explore_gateway_clmm_pools( - orca (Solana): Whirlpools - uniswap (Ethereum/EVM): V3 pools - pancakeswap (BSC/EVM): V3 pools + - pancakeswap-sol (Solana): CLMM pools """ # ============================================ # LIST POOLS - Browse available pools @@ -258,7 +243,8 @@ async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: if not request.connector: raise ToolError( - "connector is required (meteora | raydium | orca | uniswap | pancakeswap). " + "connector is required (meteora | raydium | orca | pancakeswap-sol | uniswap | " + "pancakeswap). " "Call manage_clmm with no action to load the guide." ) connector = _normalize_connector(request.connector) diff --git a/mcp_servers/hummingbot_api/tools/portfolio.py b/mcp_servers/hummingbot_api/tools/portfolio.py index 88ff5778f..b981feb3f 100644 --- a/mcp_servers/hummingbot_api/tools/portfolio.py +++ b/mcp_servers/hummingbot_api/tools/portfolio.py @@ -18,12 +18,12 @@ logger = logging.getLogger("hummingbot-mcp") -# Upper bound on concurrent real-time CLMM pool reads through Gateway. These are -# on-chain queries — the slowest calls in the overview — so they are fanned out -# instead of walked one at a time, but bounded so an LP-heavy account never +# Upper bound on concurrent real-time CLMM position reads through Gateway. These +# are on-chain queries — the slowest calls in the overview — so they are fanned +# out instead of walked one at a time, but bounded so an LP-heavy account never # bursts unlimited simultaneous Gateway/RPC requests (same bound the other # fan-outs in this repo use). -MAX_CONCURRENT_POOL_FETCHES = 10 +MAX_CONCURRENT_POSITION_FETCHES = 10 async def get_portfolio_overview( @@ -43,8 +43,8 @@ async def get_portfolio_overview( 1. Token Balances - Real-time holdings across CEX/DEX exchanges 2. Perpetual Positions - Active perp futures positions from CEX 3. LP Positions (CLMM) - Real-time concentrated liquidity positions from blockchain DEXs - - Queries database to find all pools user has interacted with - - Calls get_positions() for each pool to fetch real-time blockchain data + - Queries database to find the connector/network venues the user has used + - Calls positions-owned once per venue to fetch real-time blockchain data - Includes real-time fees and token amounts 4. Active Orders - Currently open orders across all exchanges @@ -123,48 +123,48 @@ async def get_lp_positions(): if not db_positions: return [] - # Step 2: Get unique pool addresses and their networks/connectors - pools_map = {} # {(connector, network, pool_address): True} + # Step 2: Get the unique connector/network venues to query. + # Gateway's positions-owned takes no pool filter — one call + # per venue returns every position the wallet owns there, + # each row carrying its own pool_address — so querying per + # pool would just repeat the same list N times. + venues = [] for pos in db_positions: connector = pos.get("connector") network = pos.get("network") - pool_address = pos.get("pool_address") - if connector and network and pool_address: - pools_map[(connector, network, pool_address)] = True - - # Step 3: Fetch real-time data for each pool, concurrently. - # Total latency is one pool's round-trip (per semaphore slot) - # instead of the sum over every pool. Each call keeps its own - # try/except so one bad pool is logged and skipped exactly as - # in the serial version, and results stay positionally aligned - # with `pools` so every position is stamped with its own pool's - # connector/network. - pools = list(pools_map.keys()) - semaphore = asyncio.Semaphore(MAX_CONCURRENT_POOL_FETCHES) - - async def fetch_pool(connector, network, pool_address): + if connector and network and (connector, network) not in venues: + venues.append((connector, network)) + + # Step 3: Fetch real-time data for each venue, concurrently. + # Total latency is one venue's round-trip (per semaphore slot) + # instead of the sum over every venue. Each call keeps its own + # try/except so one bad venue is logged and skipped, and + # results stay positionally aligned with `venues` so every + # position is stamped with its own venue's connector/network. + semaphore = asyncio.Semaphore(MAX_CONCURRENT_POSITION_FETCHES) + + async def fetch_venue(connector, network): async with semaphore: try: return await client.gateway_clmm.get_positions_owned( connector=connector, network=network, - pool_address=pool_address, wallet_address=None, # Uses default wallet ) except Exception as e: logger.warning( - f"Failed to get positions for pool {pool_address}: {str(e)}" + f"Failed to get positions for {connector} on " + f"{network}: {str(e)}" ) return None - pool_results = await asyncio.gather( - *(fetch_pool(*pool) for pool in pools), return_exceptions=True + venue_results = await asyncio.gather( + *(fetch_venue(*venue) for venue in venues), + return_exceptions=True, ) real_time_positions = [] - for (connector, network, _pool_address), positions in zip( - pools, pool_results - ): + for (connector, network), positions in zip(venues, venue_results): if isinstance(positions, BaseException): continue if positions and isinstance(positions, list): diff --git a/tests/test_hummingbot_mcp_tools.py b/tests/test_hummingbot_mcp_tools.py index 07b963a1b..0e9ac3b6c 100644 --- a/tests/test_hummingbot_mcp_tools.py +++ b/tests/test_hummingbot_mcp_tools.py @@ -22,7 +22,7 @@ from mcp_servers.hummingbot_api.tools.controllers import modify_controllers from mcp_servers.hummingbot_api.tools.portfolio import ( - MAX_CONCURRENT_POOL_FETCHES, + MAX_CONCURRENT_POSITION_FETCHES, get_portfolio_overview, ) @@ -81,15 +81,20 @@ def test_the_backtesting_tools_are_gone(): class FakeGatewayClmm: - """CLMM surface where each pool read costs POOL_LATENCY, as an on-chain read does. + """CLMM surface where each venue read costs POOL_LATENCY, as an on-chain read does. - ``failing_pool`` raises the way a dead RPC does, so the skip-and-continue - behaviour of the serial loop stays pinned. + ``get_positions_owned`` mirrors the real SDK signature exactly — connector, + network, wallet_address, and NO pool_address. Gateway's positions-owned has + no pool filter; every returned row carries its own ``pool_address``. A fake + that accepted one hid a live TypeError at two call sites. + + ``failing_venue`` raises the way a dead RPC does, so the skip-and-continue + behaviour stays pinned. """ - def __init__(self, pools, failing_pool=None): + def __init__(self, pools, failing_venue=None): self.pools = pools - self.failing_pool = failing_pool + self.failing_venue = failing_venue self.max_in_flight = 0 self._in_flight = 0 @@ -101,21 +106,22 @@ async def search_positions(self, limit, offset, status): ] } - async def get_positions_owned( - self, connector, network, pool_address, wallet_address=None - ): + async def get_positions_owned(self, connector, network, wallet_address=None): self._in_flight += 1 self.max_in_flight = max(self.max_in_flight, self._in_flight) try: await asyncio.sleep(POOL_LATENCY) - if pool_address == self.failing_pool: + if (connector, network) == self.failing_venue: raise RuntimeError("gateway RPC down") - # No connector/network of its own: the tool stamps them per pool. + # No connector/network of its own: the tool stamps them per venue. return [ { "trading_pair": f"PAIR-{pool_address}", "position_address": pool_address, + "pool_address": pool_address, } + for (c, n, pool_address) in self.pools + if (c, n) == (connector, network) ] finally: self._in_flight -= 1 @@ -138,9 +144,9 @@ def _lp_overview(client): ) -def test_clmm_pools_are_fetched_concurrently_not_one_at_a_time(): - """K on-chain pool reads must cost ~1 round-trip, not K of them stacked.""" - pools = [("meteora", "mainnet-beta", f"pool{i}") for i in range(8)] +def test_clmm_venues_are_fetched_concurrently_not_one_at_a_time(): + """K on-chain venue reads must cost ~1 round-trip, not K of them stacked.""" + pools = [("meteora", f"net{i}", f"pool{i}") for i in range(8)] clmm = FakeGatewayClmm(pools) start = time.monotonic() @@ -150,16 +156,16 @@ def test_clmm_pools_are_fetched_concurrently_not_one_at_a_time(): serial = len(pools) * POOL_LATENCY assert ( elapsed < serial / 2 - ), f"{len(pools)} pools took {elapsed:.3f}s; serial would be ~{serial:.3f}s" + ), f"{len(pools)} venues took {elapsed:.3f}s; serial would be ~{serial:.3f}s" section = next(s for s in result["sections"] if s["title"] == "LP Positions (CLMM)") assert section["total_positions"] == len(pools) -def test_one_failing_pool_is_skipped_and_the_rest_still_return(): - """A dead pool degrades exactly as it did serially: logged, skipped, no propagation.""" - pools = [("meteora", "mainnet-beta", f"pool{i}") for i in range(4)] - clmm = FakeGatewayClmm(pools, failing_pool="pool2") +def test_one_failing_venue_is_skipped_and_the_rest_still_return(): + """A dead venue degrades gracefully: logged, skipped, no propagation.""" + pools = [("meteora", f"net{i}", f"pool{i}") for i in range(4)] + clmm = FakeGatewayClmm(pools, failing_venue=("meteora", "net2")) result = _lp_overview(FakeGatewayClient(clmm)) @@ -168,6 +174,21 @@ def test_one_failing_pool_is_skipped_and_the_rest_still_return(): assert "pool2" not in section["content"] +def test_one_venue_with_many_pools_is_read_once_and_not_duplicated(): + """positions-owned takes no pool filter: querying per pool would repeat the + same list once per pool. One call per venue, each position exactly once.""" + pools = [("meteora", "mainnet-beta", f"pool{i}") for i in range(5)] + clmm = FakeGatewayClmm(pools) + + result = _lp_overview(FakeGatewayClient(clmm)) + + assert clmm.max_in_flight == 1, "five pools on one venue is one Gateway read" + section = next(s for s in result["sections"] if s["title"] == "LP Positions (CLMM)") + assert section["total_positions"] == len(pools) + for _, _, pool_address in pools: + assert section["content"].count(f"PAIR-{pool_address}") == 1 + + def test_each_position_keeps_its_own_pools_connector_and_network(): """The fan-out must not cross-stamp: position N carries pool N's connector.""" pools = [ @@ -189,17 +210,17 @@ def test_each_position_keeps_its_own_pools_connector_and_network(): ), f"{pool_address} was stamped with the wrong connector" -def test_pool_fan_out_is_bounded_by_an_explicit_constant(): - """A many-pool account must not burst unlimited simultaneous Gateway requests.""" +def test_position_fan_out_is_bounded_by_an_explicit_constant(): + """A many-venue account must not burst unlimited simultaneous Gateway requests.""" pools = [ - ("meteora", "mainnet-beta", f"pool{i}") - for i in range(MAX_CONCURRENT_POOL_FETCHES * 3) + ("meteora", f"net{i}", f"pool{i}") + for i in range(MAX_CONCURRENT_POSITION_FETCHES * 3) ] clmm = FakeGatewayClmm(pools) _lp_overview(FakeGatewayClient(clmm)) - assert clmm.max_in_flight <= MAX_CONCURRENT_POOL_FETCHES + assert clmm.max_in_flight <= MAX_CONCURRENT_POSITION_FETCHES assert clmm.max_in_flight > 1, "the reads should still overlap" diff --git a/tests/test_lp_contract_shape_fixes.py b/tests/test_lp_contract_shape_fixes.py new file mode 100644 index 000000000..c3ab43b3d --- /dev/null +++ b/tests/test_lp_contract_shape_fixes.py @@ -0,0 +1,475 @@ +"""Regression tests for condor reading shapes the Hummingbot API never sends. + +Every case here failed silently or crashed a whole screen because condor read a +key the API does not have — the kind of bug that survives manual use because the +field simply renders blank: + +- ``pnl_summary`` is emitted as ``null`` (not omitted), so ``.get(k, {})`` hands + back None and the next ``.get`` raises inside a per-position loop. +- the zero-liquidity filters read ``liquidity`` / ``base_amount``; the API sends + ``base_token_amount`` / ``quote_token_amount``, so the filters were no-ops. +- a close returns ``base_token_amount_removed``; condor read ``base_amount``, + missed, and re-opened with PRE-CLOSE amounts. +- an open that was submitted but not confirmed has ``position_address: None``; + paths that did not branch on it printed a literal "None". +""" + +import asyncio +from decimal import Decimal +from types import SimpleNamespace + +from handlers.dex import liquidity as liquidity_mod +from handlers.dex import lp_monitor_handlers as lpm +from handlers.dex import menu as menu_mod +from handlers.dex import pools as pools_mod +from handlers.dex._shared import resolve_network_id +from mcp_servers.hummingbot_api.formatters.gateway import ( + format_amm_result, + format_clmm_result, +) + + +def _position(**overrides): + """A position row shaped like hapi's GatewayCLMMRepository.position_to_dict.""" + row = { + "position_address": "Pos1", + "pool_address": "Pool1", + "network": "solana-mainnet-beta", + "connector": "meteora", + "trading_pair": "SOL-USDC", + "base_token": "SOL", + "quote_token": "USDC", + "created_at": "2026-08-01T00:00:00", + "closed_at": None, + "status": "OPEN", + "lower_price": 100.0, + "upper_price": 140.0, + "lower_bin_id": None, + "upper_bin_id": None, + "entry_price": None, + "current_price": None, + "percentage": None, + "initial_base_token_amount": None, + "initial_quote_token_amount": None, + "position_rent": None, + "base_token_amount": 1.5, + "quote_token_amount": 200.0, + "in_range": True, + "base_fee_collected": 0.0, + "quote_fee_collected": 0.0, + "base_fee_pending": 0.0, + "quote_fee_pending": 0.0, + # The bug: the key is always PRESENT and null when hapi cannot compute + # PnL (no entry/current price, or no initial amounts). + "pnl_summary": None, + "last_updated": "2026-08-01T00:00:00", + } + row.update(overrides) + return row + + +# ============================================ +# pnl_summary: null, not {} +# ============================================ + + +def test_every_position_formatter_survives_a_null_pnl_summary(): + """One AttributeError here took down the whole /lp screen.""" + pos = _position() + + assert pools_mod._format_position_detail(pos, detailed=True) + assert pools_mod._format_position_detail(pos, detailed=False) + assert liquidity_mod._format_compact_position_line(pos) + assert liquidity_mod._format_closed_position_line(pos) + assert liquidity_mod._format_detailed_position_line(pos) + text, _keyboard = lpm.format_position_detail_view( + pos, token_cache={}, token_prices={}, index=0, total=1, instance_id="i1" + ) + assert text + + +def test_a_populated_pnl_summary_still_renders_its_real_keys(): + """The real key names, straight from position_to_dict's pnl_summary.""" + pos = _position( + entry_price=120.0, + current_price=130.0, + pnl_summary={ + "entry_price": 120.0, + "current_price": 130.0, + "price_change_pct": 8.33, + "initial_base": 2.0, + "initial_quote": 100.0, + "initial_value_quote": 340.0, + "current_base": 1.5, + "current_quote": 200.0, + "current_lp_value_quote": 395.0, + "total_fees_base": 0.0, + "total_fees_quote": 3.0, + "total_fees_value_quote": 3.0, + "hodl_value_quote": 360.0, + "impermanent_loss_quote": 35.0, + "current_total_value_quote": 398.0, + "total_pnl_quote": 58.0, + "total_pnl_pct": 17.06, + "duration_hours": 12.0, + "fee_apr_estimate": 6.4, + }, + ) + + detailed = liquidity_mod._format_detailed_position_line(pos) + # current_base / current_quote, not the invented current_base_total. + assert "1.5" in detailed and "200" in detailed + # Per-token PnL is derived (1.5 - 2.0 = -0.5), since hapi reports none. + assert "-0.5" in detailed + + compact = pools_mod._format_position_detail(pos, detailed=False) + assert "58" in compact, "the compact line must show total_pnl_quote" + + +# ============================================ +# zero-liquidity filters +# ============================================ + + +class _FakeClmmSearch: + def __init__(self, positions): + self.positions = positions + + async def search_positions(self, **kwargs): + return {"data": self.positions} + + +class _FakeClient: + """No `gateway` attribute: the token top-up branch is skipped.""" + + def __init__(self, positions): + self.gateway_clmm = _FakeClmmSearch(positions) + + +def test_the_dex_menu_drops_positions_whose_liquidity_is_gone(): + positions = [ + _position(position_address="Live", base_token_amount=1.5), + _position( + position_address="Empty", base_token_amount=0.0, quote_token_amount=0.0 + ), + ] + + data = asyncio.run(menu_mod._fetch_lp_positions(_FakeClient(positions))) + + addresses = [p["position_address"] for p in data["lp_positions"]] + assert addresses == ["Live"], "the drained position must be filtered out" + + +def test_the_lp_screen_drops_positions_whose_liquidity_is_gone(): + positions = [ + _position(position_address="Live", quote_token_amount=200.0), + _position( + position_address="Empty", base_token_amount=0.0, quote_token_amount=0.0 + ), + ] + + data = asyncio.run(liquidity_mod._fetch_lp_positions(_FakeClient(positions))) + + addresses = [p["position_address"] for p in data["positions"]] + assert addresses == ["Live"] + + +# ============================================ +# rebalance: close result keys + confirmation gate +# ============================================ + + +class _FakeMessage: + def __init__(self): + self.texts = [] + + async def edit_text(self, text, **kwargs): + self.texts.append(text) + + async def reply_text(self, text, **kwargs): + self.texts.append(text) + + +class _FakeQuery: + def __init__(self): + self.message = _FakeMessage() + + async def answer(self, *a, **kw): + return None + + +class _FakeRebalanceClmm: + def __init__(self, close_result): + self.close_result = close_result + self.open_calls = [] + + async def close_position(self, **kwargs): + return self.close_result + + async def open_position(self, **kwargs): + self.open_calls.append(kwargs) + return { + "transaction_hash": "OpenTx", + "position_address": None, + "status": "submitted", + } + + +def _run_rebalance(monkeypatch, close_result): + clmm = _FakeRebalanceClmm(close_result) + client = SimpleNamespace(gateway_clmm=clmm) + + async def _fake_get_client(*a, **kw): + return client + + import config_manager + + monkeypatch.setattr(config_manager, "get_client", _fake_get_client) + + query = _FakeQuery() + update = SimpleNamespace(callback_query=query, effective_chat=SimpleNamespace(id=1)) + pos = _position(base_token_amount=1.5, quote_token_amount=200.0) + context = SimpleNamespace( + user_data={"positions_cache": {"k": pos}, "token_cache": {}} + ) + + asyncio.run(lpm.handle_lpm_rebalance_execute(update, context, "k")) + return clmm, query + + +def test_rebalance_reopens_with_the_amounts_the_close_actually_returned(monkeypatch): + """hapi sends base_token_amount_removed; the old keys always missed and the + re-open silently used stale pre-close amounts.""" + clmm, _ = _run_rebalance( + monkeypatch, + { + "transaction_hash": "CloseTx", + "position_address": "Pos1", + "status": "confirmed", + "base_token_amount_removed": "1.4321", + "quote_token_amount_removed": "198.77", + }, + ) + + assert len(clmm.open_calls) == 1 + call = clmm.open_calls[0] + # Decimal, not float: a float puts "0.30000000000000004"/"1e-08" on the wire. + assert call["base_token_amount"] == Decimal("1.4321") + assert call["quote_token_amount"] == Decimal("198.77") + assert isinstance(call["base_token_amount"], Decimal) + # Never the pre-close cached amounts. + assert call["base_token_amount"] != Decimal("1.5") + + +def test_rebalance_passes_none_for_a_side_the_close_returned_nothing_for(monkeypatch): + clmm, _ = _run_rebalance( + monkeypatch, + { + "transaction_hash": "CloseTx", + "position_address": "Pos1", + "status": "confirmed", + "base_token_amount_removed": "1.4321", + "quote_token_amount_removed": None, + }, + ) + + assert clmm.open_calls[0]["quote_token_amount"] is None + + +def test_rebalance_refuses_to_reopen_before_the_close_confirms(monkeypatch): + """A submitted-not-confirmed close means the tokens are not in the wallet.""" + clmm, query = _run_rebalance( + monkeypatch, + { + "transaction_hash": "CloseTx", + "position_address": "Pos1", + "status": "submitted", + "base_token_amount_removed": None, + "quote_token_amount_removed": None, + }, + ) + + assert clmm.open_calls == [], "no re-open until the close is confirmed" + assert any("submitted" in t for t in query.message.texts) + + +def test_rebalance_reports_a_pending_open_instead_of_claiming_an_address(monkeypatch): + _, query = _run_rebalance( + monkeypatch, + { + "transaction_hash": "CloseTx", + "position_address": "Pos1", + "status": "confirmed", + "base_token_amount_removed": "1.4321", + "quote_token_amount_removed": "198.77", + }, + ) + + final = query.message.texts[-1] + assert "None" not in final + assert "submitted" in final or "confirm" in final + + +# ============================================ +# network ids come from Gateway, not a static table +# ============================================ + + +class _FakeNetworksGateway: + """Gateway's real network ids: '{chain}-{network}' from conf/chains/.""" + + async def list_networks(self): + return { + "networks": [ + { + "network_id": "solana-mainnet-beta", + "chain": "solana", + "network": "mainnet-beta", + }, + {"network_id": "solana-devnet", "chain": "solana", "network": "devnet"}, + { + "network_id": "ethereum-mainnet", + "chain": "ethereum", + "network": "mainnet", + }, + {"network_id": "ethereum-base", "chain": "ethereum", "network": "base"}, + {"network_id": "ethereum-bsc", "chain": "ethereum", "network": "bsc"}, + { + "network_id": "ethereum-arbitrum", + "chain": "ethereum", + "network": "arbitrum", + }, + ], + "count": 6, + } + + +def _resolve(text): + client = SimpleNamespace(gateway=_FakeNetworksGateway()) + return asyncio.run(resolve_network_id(client, text)) + + +def test_evm_network_names_resolve_to_gatewaysown_ids(): + """'base' is ethereum-base, NOT base-mainnet: Gateway builds the id from the + chain directory plus the network yml basename.""" + assert _resolve("base") == "ethereum-base" + assert _resolve("bsc") == "ethereum-bsc" + assert _resolve("arbitrum") == "ethereum-arbitrum" + assert _resolve("mainnet") == "ethereum-mainnet" + + +def test_a_full_network_id_passes_through(): + assert _resolve("ethereum-base") == "ethereum-base" + assert _resolve("solana-mainnet-beta") == "solana-mainnet-beta" + + +def test_an_unknown_network_fails_loudly_listing_what_gateway_offers(): + import pytest + + with pytest.raises(ValueError) as excinfo: + _resolve("base-mainnet") + assert "ethereum-base" in str(excinfo.value) + + +# ============================================ +# formatters: never print a literal "None" +# ============================================ + + +def test_a_pending_open_never_renders_the_word_none(): + out = format_clmm_result( + "open", + { + "action": "open", + "connector": "meteora", + "network": "solana-mainnet-beta", + "result": { + "transaction_hash": "Tx1", + "position_address": None, + "trading_pair": "SOL-USDC", + "pool_address": "Pool1", + "lower_price": 100.0, + "upper_price": 140.0, + "status": "submitted", + }, + }, + ) + assert "None" not in out + assert "submitted" in out + + +def test_amm_write_results_read_the_signature_the_api_sends(): + """AMM write responses carry `signature`, not CLMM's `transaction_hash`.""" + out = format_amm_result( + "add_liquidity", + { + "action": "add_liquidity", + "connector": "meteora", + "network": "solana-mainnet-beta", + "result": {"signature": "Sig123", "status": 1, "data": None}, + }, + ) + assert "Sig123" in out + assert "None" not in out + + +def test_amm_create_pool_result_reads_the_signature_too(): + out = format_amm_result( + "create_pool", + { + "action": "create_pool", + "connector": "meteora", + "network": "solana-mainnet-beta", + "result": { + "signature": "Sig456", + "status": 1, + "pool_address": "Pool9", + "price": 1.25, + }, + }, + ) + assert "Sig456" in out and "Pool9" in out + assert "None" not in out + + +# ============================================ +# pool-info field names +# ============================================ + + +def test_the_mcp_detailed_pool_table_only_has_columns_the_api_sends(): + """volume / fee_tvl_ratio / max_fee_percentage / protocol_fee_percentage are + not CLMMPoolListItem fields — they rendered as a permanent wall of N/A.""" + from mcp_servers.hummingbot_api.tools.gateway_clmm import ( + format_pools_as_detailed_table, + ) + + table = format_pools_as_detailed_table( + [ + { + "address": "Pool1", + "name": "SOL-USDC", + "trading_pair": "SOL-USDC", + "mint_x": "MintX", + "mint_y": "MintY", + "bin_step": 20, + "current_price": 130.0, + "liquidity": "1000000", + "base_fee_percentage": "0.2", + "apr": 12.0, + "apy": 12.7, + "volume_24h": 500000.0, + "fees_24h": 1000.0, + } + ] + ) + + header = table.splitlines()[0] + for gone in ( + "volume_hour_1", + "fee_tvl_ratio", + "max_fee_percentage", + "protocol_fee_percentage", + ): + assert gone not in header, f"{gone} is not a field the API sends" + assert "N/A" not in table.splitlines()[2] From fb38b814a0dfd33289fdf697ff91a9a437af9acb Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 23:32:57 -0700 Subject: [PATCH 22/65] fix(agents): describe LP writes in the confirmation prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary renderer branched on manage_gateway_clmm, the same nonexistent name the gates carried. With the gates fixed, a gated LP write reached the generic fallback and asked the user to approve the bare string 'manage_clmm' — a confirmation showing nothing to confirm. It now names the action, connector, pool or position, and amounts, for both LP tools, and a test pins that a gated call never renders as its bare tool name. Drops the _format_tool_summary compat alias per the no-backwards-compat convention; its only callers were tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- handlers/agents/confirmation.py | 38 ++++++++++++++++------ tests/test_confirmation.py | 8 ++--- tests/test_dangerous_gate_names_resolve.py | 36 ++++++++++++++++---- 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/handlers/agents/confirmation.py b/handlers/agents/confirmation.py index 9ef63f4fb..2a16c8aad 100644 --- a/handlers/agents/confirmation.py +++ b/handlers/agents/confirmation.py @@ -84,22 +84,40 @@ def format_tool_summary(tool_call: dict[str, Any]) -> str: amount = input_data.get("amount", "?") return f"Swap {side} {amount} {pair}" - if tool_name == "manage_gateway_clmm": + if tool_name in ("manage_clmm", "manage_amm"): action = input_data.get("action", "?") - if action == "open_position": - return "Open LP position" - if action == "close_position": - return "Close LP position" - return f"CLMM: {action}" + kind = "CLMM" if tool_name == "manage_clmm" else "AMM" + connector = input_data.get("connector", "?") + pool = ( + input_data.get("pool_address") or input_data.get("position_address") or "?" + ) + if action == "open": + lower = input_data.get("lower_price", "?") + upper = input_data.get("upper_price", "?") + return ( + f"Open {kind} position on {connector} pool {pool} over {lower}-{upper}" + ) + if action == "close": + return f"Close {kind} position {pool} on {connector}" + if action == "add_liquidity": + base = input_data.get("base_token_amount", "?") + quote = input_data.get("quote_token_amount", "?") + return f"Add {base} base / {quote} quote to {kind} {pool} on {connector}" + if action == "remove_liquidity": + pct = input_data.get("percentage_to_remove", "?") + return f"Remove {pct}% from {kind} position {pool} on {connector}" + if action == "collect_fees": + return f"Collect fees from {kind} position {pool} on {connector}" + if action == "create_pool": + base = input_data.get("base_token", "?") + quote = input_data.get("quote_token", "?") + return f"Create {kind} pool {base}-{quote} on {connector}" + return f"{kind}: {action}" # Generic fallback return tool_name -# Backwards-compatible alias for the pre-registry name. -_format_tool_summary = format_tool_summary - - class TelegramChannel: """Renders a pending confirmation as an inline keyboard in a chat.""" diff --git a/tests/test_confirmation.py b/tests/test_confirmation.py index 3285fef52..e149cb15e 100644 --- a/tests/test_confirmation.py +++ b/tests/test_confirmation.py @@ -1,6 +1,6 @@ """Unit tests for the human-readable confirmation-prompt formatter.""" -from handlers.agents.confirmation import _format_tool_summary +from handlers.agents.confirmation import format_tool_summary def _bot_call(action: str, **extra) -> dict: @@ -8,7 +8,7 @@ def _bot_call(action: str, **extra) -> dict: def test_format_manage_bots_deploy(): - summary = _format_tool_summary( + summary = format_tool_summary( _bot_call("deploy", bot_name="mm-bot", controllers_config=["cfg_a"]) ) assert "mm-bot" in summary @@ -16,7 +16,7 @@ def test_format_manage_bots_deploy(): def test_format_manage_bots_update_config(): - summary = _format_tool_summary( + summary = format_tool_summary( _bot_call("update_config", bot_name="mm-bot", config_name="cfg_a") ) assert "mm-bot" in summary @@ -24,6 +24,6 @@ def test_format_manage_bots_update_config(): def test_format_manage_bots_stop_bot(): - summary = _format_tool_summary(_bot_call("stop_bot", bot_name="mm-bot")) + summary = format_tool_summary(_bot_call("stop_bot", bot_name="mm-bot")) assert "mm-bot" in summary assert "stop_bot" in summary diff --git a/tests/test_dangerous_gate_names_resolve.py b/tests/test_dangerous_gate_names_resolve.py index 0d3f2ad38..1a32b972a 100644 --- a/tests/test_dangerous_gate_names_resolve.py +++ b/tests/test_dangerous_gate_names_resolve.py @@ -52,9 +52,9 @@ def _action_literals(tool_name: str) -> set[str]: def test_every_gated_tool_name_is_registered(): for tool_name in DANGEROUS_TOOLS - _FOREIGN_TOOLS: - assert tool_name in _registered_tools(), ( - f"{tool_name} is gated but not registered — the gate matches nothing" - ) + assert ( + tool_name in _registered_tools() + ), f"{tool_name} is gated but not registered — the gate matches nothing" def test_gated_actions_exist_on_their_tools(): @@ -79,9 +79,9 @@ def test_every_liquidity_moving_action_is_gated(): ("manage_amm", DANGEROUS_AMM_ACTIONS), ): writes = _action_literals(tool_name) - read_only - assert writes <= gated, ( - f"{tool_name} write action(s) ungated: {sorted(writes - gated)}" - ) + assert ( + writes <= gated + ), f"{tool_name} write action(s) ungated: {sorted(writes - gated)}" def test_lp_writes_require_confirmation(): @@ -104,3 +104,27 @@ def test_lp_reads_do_not_require_confirmation(): ): call = {"tool": tool_name, "input": {"action": action}} assert not is_dangerous_tool_call(call), f"{tool_name}({action}) gated a read" + + +def test_gated_calls_render_a_specific_confirmation_summary(): + """A gated call must describe itself in the approval prompt. + + The summary renderer branched on ``manage_gateway_clmm`` too, so once the + gate started matching, the prompt fell through to the generic branch and + asked the user to approve the bare string "manage_clmm" — a confirmation + that shows nothing to confirm. + """ + from handlers.agents.confirmation import format_tool_summary + + for tool_name, action, expected in ( + ("manage_clmm", "open", "Open CLMM position"), + ("manage_clmm", "close", "Close CLMM position"), + ("manage_clmm", "remove_liquidity", "Remove"), + ("manage_amm", "add_liquidity", "Add"), + ("manage_amm", "create_pool", "Create AMM pool"), + ): + summary = format_tool_summary( + {"tool": tool_name, "input": {"action": action, "connector": "meteora"}} + ) + assert expected in summary, f"{tool_name}({action}) rendered {summary!r}" + assert summary != tool_name From 8e5a22f7a3260c3db01d9b48ffa38fd811559eb6 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 23:35:48 -0700 Subject: [PATCH 23/65] chore(deps): lock the client to the dead-pool-method removal Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index e163b13dd..529e709d3 100644 --- a/uv.lock +++ b/uv.lock @@ -1496,7 +1496,7 @@ wheels = [ [[package]] name = "hummingbot-api-client" version = "1.5.8" -source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#4cea3f4927b530fca073c2f2d8f444f3d8d9388b" } +source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#eb6ab50ff2839d366c9f8ecbe1d9606c769a5f45" } dependencies = [ { name = "aiohttp" }, ] From 0b22d961e3e3ccaf88a425dbd63507337c17f19a Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 18:29:23 -0700 Subject: [PATCH 24/65] test(lp): cover the last read action, swap get_status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read script exercised quote and search but never get_status, which left the only read path that resolves a submitted swap unchecked — the one a caller reaches for after an execute returns a hash. It now reads a hash back out of the search rather than hard-coding a swap that ages out of the table, and asserts that a hash never submitted 404s instead of reading as a successful lookup. With this, every read action on all three tools is covered: swap quote/search/get_status, amm pool_info/position_info/positions_owned/quote_liquidity, clmm position_info. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- scripts_lp_test/test_lp_read.py | 118 ++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 scripts_lp_test/test_lp_read.py diff --git a/scripts_lp_test/test_lp_read.py b/scripts_lp_test/test_lp_read.py new file mode 100644 index 000000000..826a76472 --- /dev/null +++ b/scripts_lp_test/test_lp_read.py @@ -0,0 +1,118 @@ +"""Drive the swap / AMM / CLMM read surface through condor's own MCP tools. + +Same entry points an agent session hits — manage_gateway_swaps, manage_amm, +manage_clmm — so this exercises condor's argument handling and formatting, not +just hapi's HTTP surface. Nothing here moves funds; see test_lp_write.py for that. + + uv run python scripts_lp_test/test_lp_read.py +""" + +import asyncio +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from mcp_servers.hummingbot_api import server # noqa: E402 + +def tool(name): + """The registered MCP tool as a callable (FastMCP may hand back a wrapper).""" + obj = getattr(server, name) + return getattr(obj, "fn", obj) + + +manage_gateway_swaps = tool("manage_gateway_swaps") +manage_amm = tool("manage_amm") +manage_clmm = tool("manage_clmm") + +NET = "solana-mainnet-beta" +WALLET = "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" + +MET_CLMM = "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" # SOL-USDC +ORCA_CLMM = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE" # SOL-USDC +RAY_AMM = "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" # SOL-USDC +MET_DAMM = "Bv65dPQKpUo7vRELhEGBkkm5wq9J3MvKGyUj8WxYtunM" # unlisted mint, no symbol + + +async def step(title, coro): + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + try: + print((await coro).strip()[:1200]) + except Exception as e: + print(f"!! {type(e).__name__}: {e}") + + +async def main(): + # ---- swaps ------------------------------------------------------------- + for connector in ["jupiter", "meteora/clmm", "raydium/amm"]: + await step( + f"swap quote — {connector}", + manage_gateway_swaps(action="quote", connector=connector, network=NET, + trading_pair="SOL-USDC", side="SELL", amount="0.01")) + + await step( + "swap quote — bare 'raydium' resolves to a type on its own", + manage_gateway_swaps(action="quote", connector="raydium", network=NET, + trading_pair="SOL-USDC", side="SELL", amount="0.01")) + + await step( + "swap quote — unknown extra_params key must be rejected", + manage_gateway_swaps(action="quote", connector="meteora", network=NET, + trading_pair="SOL-USDC", side="SELL", amount="0.01", + extra_params={"bogusKey": 1})) + + await step("swap search — recent swaps", manage_gateway_swaps(action="search", limit=5)) + + # get_status needs a hash that exists, so it reads one back out of the search above + # rather than hard-coding a swap that will age out of the table. + recent = await manage_gateway_swaps(action="search", limit=1) + match = re.search(r"'transaction_hash': '([^']+)'", recent) + if match: + await step("swap get_status — resolve a recorded swap", + manage_gateway_swaps(action="get_status", transaction_hash=match.group(1))) + else: + print("\n(no recorded swaps yet — skipping get_status)") + + await step( + "swap get_status — an unsubmitted hash must 404, not read as success", + manage_gateway_swaps(action="get_status", transaction_hash="1" * 88)) + + # ---- CLMM -------------------------------------------------------------- + await step("clmm guide (no action = progressive disclosure)", manage_clmm()) + + await step( + "clmm position_info — the wallet's live orca position, symbols resolved", + manage_clmm(action="position_info", connector="orca", network=NET, + position_address="4G5GyCPi9U75GQ9NwLAov7psKngofiHThPAJQJ48cvUR")) + + # ---- AMM --------------------------------------------------------------- + await step("amm guide (no action)", manage_amm()) + + for connector, pool in [("raydium", RAY_AMM), ("meteora", MET_DAMM)]: + await step(f"amm pool_info — {connector}", + manage_amm(action="pool_info", connector=connector, network=NET, + pool_address=pool)) + + await step( + "amm quote_liquidity — raydium SOL-USDC", + manage_amm(action="quote_liquidity", connector="raydium", network=NET, + pool_address=RAY_AMM, base_token_amount="0.01", quote_token_amount="1")) + + await step( + "amm positions_owned — meteora DAMM v2", + manage_amm(action="positions_owned", connector="meteora", network=NET, + wallet_address=WALLET)) + + await step( + "amm positions_owned — raydium must be rejected (fungible LP)", + manage_amm(action="positions_owned", connector="raydium", network=NET, + wallet_address=WALLET)) + + await step( + "amm position_info — raydium LP balance in pool", + manage_amm(action="position_info", connector="raydium", network=NET, + pool_address=RAY_AMM, wallet_address=WALLET)) + + +asyncio.run(main()) From ae410a0da0b2fe4da52727b994ec198ffcb8ecc0 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 18:49:33 -0700 Subject: [PATCH 25/65] docs(gateway): record GW-17, the add/remove sign split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first write ever to reach hummingbot-api's AMM event table recorded an ADD_LIQUIDITY with base_token_amount -0.01. The sign is Gateway's: raydium's AMM addLiquidity returns the raw wallet balance change under a field named baseTokenAmountAdded, while its removeLiquidity returns Math.abs of the same quantity. Across all 20 add sites the convention is split three ways; the nine removed sites all normalize. Summing the event history for a net position therefore gets one leg backwards, and gets a different answer depending on which connector was used. Also refreshes the two "nothing has run against mainnet" notes, which are no longer true — the three swap types and the full Meteora CLMM lifecycle were confirmed live today. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- GATEWAY_ISSUES.md | 621 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 621 insertions(+) create mode 100644 GATEWAY_ISSUES.md diff --git a/GATEWAY_ISSUES.md b/GATEWAY_ISSUES.md new file mode 100644 index 000000000..58c9cc85d --- /dev/null +++ b/GATEWAY_ISSUES.md @@ -0,0 +1,621 @@ +# Gateway issues + +Found against gateway `fdad604aa`, Solana mainnet-beta, wallet `82Sgg…yHx5`. +Now merged with the route-unification work on `feat/unified-trading-routes`, which +changed every path these issues live on. + +**Status at a glance** + +| | Issue | State | +|---|---|---| +| GW-1 | Meteora `quote-liquidity` priced the range midpoint | fixed (re-implemented) | +| GW-2 | Raydium AMM `feePct` reported as a fraction | fixed | +| GW-3 | OKX router has no credentials | **needs a decision** — error made legible | +| GW-4 | Native-SOL amounts inflated by the transaction fee | fixed, plus 3 follow-on sites | +| GW-5 | Execute response carried no `poolAddress` | fixed | +| GW-6 | AMM add never returned the DAMM v2 position it created | fixed | +| GW-7 | CLMM/AMM double-counted the fee after GW-4 | fixed (found by GW-4) | +| GW-0 | `poolAddress` pin survived the refactor | no action | +| GW-8 | Nested `data` schemas named but never registered | fixed | +| GW-9 | Request bodies declared inline, so ungeneratable | fixed | +| GW-10 | The reads published a stale shape under the right name | fixed | +| GW-11 | The chain half of `chainNetwork` was decorative on the liquidity routes | fixed | +| GW-12 | Three ways a caller's intent is dropped without an error | **open** | +| GW-13 | The spec guard passes on regressions it claims to catch | **open** | +| GW-14 | No `operationId` and no error responses in the spec | **open** | +| GW-15 | Response component names were never unified | **open** | +| GW-16 | Leftovers the unification did not sweep | **open** | +| GW-17 | `baseTokenAmountAdded` is signed on some connectors, a magnitude on others | **open** | + +GW-1 through GW-11 are code-complete and green: 1126 tests, 138 suites, clean typecheck, +lint 0 errors, and `openapi.json` regenerates identical to the committed copy. + +Mainnet verification began 2026-08-19 against a container built from `c0bb10253`: the +three swap types and the full Meteora CLMM lifecycle all behave, and the Raydium AMM add +surfaced GW-17. See "What is not done" for what remains unrun. + +GW-12 through GW-16 came out of an adversarial audit of the unification and of GW-8/9/10 +themselves, run on 2026-08-19. Each is reproduced below rather than asserted; none is +fixed. GW-13 matters most for the others, because it is the reason a regression in any of +them can land green. + +--- + +## Route changes that affect every issue below + +The trading type is now a path segment and the connector a parameter. The spec went +from 182 paths to 56, and `openapi.json` is generated from the route table by +`pnpm generate:openapi` without a running server. + +| Was | Is | +|---|---| +| `/connectors/{dex}/{type}/*` (128 paths) | removed | +| `/trading/swap/{quote,execute}` | `/trading/router/{quote-swap,execute-quote,execute-swap}` | +| `/connectors/{dex}/{amm,clmm}/quote-swap` | `/trading/{amm,clmm}/quote-swap` | +| `/connectors/{meteora,orca}/clmm/fetch-pools` | `/trading/clmm/fetch-pools` | +| `/trading/clmm/quote-position` | `/trading/clmm/quote-liquidity` | +| `/trading/amm/{add,remove}-liquidity` | `/trading/amm/{add,remove}` | +| — | `/trading/amm/{open,close}` (new) | +| `/chains/{solana,ethereum}/*` (14 paths) | `/chains/{chain}/*` (6) + 2 EVM-only | + +`connector` is now a bare, enum-constrained name (`meteora`, not `meteora/clmm`). +Both callers have since been updated: hummingbot-api vendors this spec, generates its +models from it, and builds every `/trading` request from them; the hummingbot wheel does +the same through `gateway_http_client`. Neither still speaks the old routes. + +--- + +## GW-1 — Meteora `quote-liquidity` priced the range midpoint, not the active bin +**Status: fixed. Re-implemented after the first fix was lost.** + +The paired amount came from the arithmetic midpoint of the requested range, with no +reference to where the pool's active bin sits: + +```ts +const avgPrice = (lowerPrice + upperPrice) / 2; +quoteAmount = baseAmount * avgPrice; +``` + +For a range of [150, 250] with spot at 82.29 — entirely above the current price, so the +position is 100% base — Meteora quoted **2 USDC**. Orca returned 0 on the identical +request. Meteora was the only connector doing this. + +Worse than a bad estimate: `openPosition.ts` *rejects* a nonzero quote amount when spot +sits below the range, so following the quote produced a 400 from the open it fed. + +**Note on history:** this was fixed once in an uncommitted working tree, and that fix was +destroyed by a `git checkout -- src/connectors` during the route refactor's recovery from +a bad codemod. It was not recoverable from stashes or dangling blobs. What follows is a +re-implementation, so review it as new code rather than as a re-application. + +**Fix** in `src/connectors/meteora/clmm-routes/quotePosition.ts`: + +- Uses the DLMM SDK's `autoFillYByStrategy` / `autoFillXByStrategy`, which take the active + bin, bin step and bin range and return what the strategy actually requires on the other + side (0 for the unused side on a one-sided range). +- Bin IDs now derive exactly as `openPosition` does — `toPricePerLamport` first, and the + rounding flags that way round. The old code passed raw prices with the flags swapped, so + the quote described a different bin range than the open would occupy. +- The both-amounts branch asks the strategy what the offered base needs on the quote side + and picks the binding side from that, instead of comparing a ratio to spot. +- Dropped the `liquidity` field — a geometric mean that collapses to 0 for any one-sided + position. It is optional in the schema and Orca already omits it. + +**Verify after the next build:** quote a range that does *not* straddle spot, confirm the +unused side is 0, then confirm that same request opens without a 400. + +--- + +## GW-2 — Raydium's AMM reported `feePct` as a fraction; every other connector reports a percent +**Status: fixed.** + +`raydium.ts:326` divided and stopped: 25/10000 = `0.0025` — a fraction — for a pool +charging 0.25%. + +| Connector | Pool | feePct returned | Actual fee | +|---|---|---|---| +| **raydium/amm** | `58oQ…LYQo2` | **0.0025** | **0.25%** | +| meteora/amm | `Bv65…tunM` | 0.25 | 0.25% | +| raydium/clmm | `3ucN…sUxv` | 0.04 | 0.04% | +| orca/clmm | `Czfq…44zE` | 0.04 | 0.04% | +| meteora/clmm | `2sf5…nQT3` | 0.04 | 0.04% | +| pancakeswap-sol/clmm | `4QU2…D9qN` | 0.03 | 0.03% | + +One outlier out of six, off by 100×, rendered literally downstream — hummingbot-api showed +`Fee: 0.0025%` for a pool charging 0.25%. + +**Fix:** ×100 on the v4 branch. The CPMM branch was a *third* unit in the same file — +`configInfo.tradeFeeRate` in millionths, which would have emitted `2500` for the same +0.25% — now /10000. Two test expectations that asserted the old values were corrected. + +**Verify:** read `pool-info` for a Raydium v4 pool and a CPMM pool; both should report +percent, matching the table above. The CPMM branch was never confirmed live — no CPMM pool +was configured. + +--- + +## GW-3 — OKX router has no API credentials +**Status: needs a decision. The error is now legible; the credentials are not populated.** + +`conf/connectors/okx.yml` still has `apiKey`, `secretKey` and `passphrase` empty, so every +quote through OKX fails. It was the one failure in an otherwise clean 36/37 read sweep; +jupiter, dflow and titan all quoted fine. + +**Changed:** the guard in `okx.ts:56` threw a plain `Error`, which reached callers as a +**500** — "Gateway broke, try again" for a condition no retry can fix. It now throws +`httpErrors.badRequest` with the same message. OKX is advertised in `/config/connectors`, +so anything enumerating providers hits this. + +**Still open, and yours to decide:** populate the three keys, or drop OKX from the +advertised connector list. Leaving it advertised-but-unconfigured is the one state that +misleads a caller enumerating providers. + +### What creating a key involves (researched 2026-08-20) + +Self-serve and free. Create an OKX account, verify email and phone, create a project (max 3 +per account), then create an API key inside it (max 3 per project) choosing a passphrase. +The secret key is shown only at creation and the passphrase is unrecoverable. Full steps +are in `src/connectors/okx/README.md`. + +Three things make it less attractive than "free and self-serve" suggests: + +- **The trial ceiling is 1 request/second**, raisable to 5 on review, for 60 days. That is + the binding constraint, not the expiry — a bot sweeping quotes across connectors exceeds + 1 RPS by itself. +- **Continuing past 60 days requires KYC** in the developer portal, which upgrades the + account to the Start-up tier. No per-call charge there, but a partner taking a fee on + swaps enters a revenue share where OKX keeps 20% of it. +- **A fifth header may be missing.** OKX's own client library sends `OK-ACCESS-PROJECT` + with the project ID; `okx.ts:107` sends only the four signed headers and `okx.config.ts` + has no field for a project ID. That library targets v5 while this connector calls v6, + whose quote reference lists only four — so this may be fine, but it cannot be tested + without credentials. If signed requests 401 once keys are populated, add the project ID + before investigating anything else. + +**Recommendation:** drop OKX from the advertised list unless its routing is specifically +wanted. Jupiter, dflow and titan all quoted cleanly in the read sweep, and a connector +capped at 1 RPS for 60 days and gated on KYC after that is not something to build on. +Creating a key to try it costs little; depending on it does. + +--- + +## GW-4 — native-SOL amounts inflated by the transaction fee +**Status: fixed, plus three follow-on sites it exposed (see GW-7).** + +When the traded token was native SOL, the wallet's balance delta was the swap amount +**plus** the transaction fee, and `amountIn` reported the whole thing. The comment said so +outright, and `extractBalanceChangesAndFee` computed that exact fee ~25 lines earlier. + +Measured, three swaps of 0.01 SOL each: + +| Connector | Recorded `input_amount` | `gas_fee` | True input | Recorded price | True price | Error | +|---|---|---|---|---|---|---| +| jupiter | 0.010027173 | 0.000027173 | 0.01 | 82.1780 | 82.4013 | −0.27% | +| meteora/clmm | 0.010010560 | 0.000010560 | 0.01 | 82.1680 | 82.2548 | −0.11% | +| raydium/amm | 0.010009974 | 0.000009974 | 0.01 | 82.0883 | 82.1702 | −0.10% | + +**Fix:** `extractBalanceChangesAndFee` nets the fee out of the native-SOL change for the +fee payer (account 0), so `amountIn`/`amountOut` describe the trade and `fee` alone +describes the cost. `extractClmmBalanceChanges` no longer subtracts `txFee` from rent — it +would have understated rent by exactly the fee — and its now-unused `txFee` parameter is +gone, along with its four call sites. + +**Verify:** repeat the three swaps; `input_amount` should equal 0.01 exactly, with the fee +appearing only in `gas_fee`. + +--- + +## GW-5 — the execute response carried no `poolAddress` +**Status: fixed.** + +A CLMM or AMM fill could not be reconciled to a venue without re-fetching the transaction, +even though Gateway had resolved a specific pool to execute against. hummingbot-api stored +`pool_address: null` for every swap — correctly. + +**Fix:** optional `poolAddress` on the confirmed `data` block of the execute response, set +by the pool-scoped routes, which resolve exactly one pool. The router leaves it unset, +which is right: a router has no single pool. hummingbot-api's column is waiting. + +--- + +## GW-6 — AMM add never returned the DAMM v2 position it just created +**Status: fixed.** + +Gateway generated the position NFT keypair, logged the address, and discarded it. The +caller who had just paid to open a position was never told which one, and could only +recover the address by re-listing `positions-owned` and diffing — which races any +concurrent write and cannot attribute an address to a transaction. + +**Fix, in three parts:** + +1. Opening moved into its own `openPosition` function, since it is its own on-chain call + (`createPositionAndAddLiquidity` — it mints the NFT and locks rent), and is reachable + directly as `POST /trading/amm/open`. +2. `AddLiquidityResponse.data` gained optional `positionAddress` and `positionRent`. + `positionAddress` is **always** the position the write touched: the one just opened when + no address was given, or the one named. `positionRent` appears only when the call + actually opened a position. +3. Rent is read from the landed transaction — the position account's post-balance at open, + its pre-balance at close — through a shared `accountLamports` helper that resolves the + index against the transaction's *full* account list including lookup-table addresses. + Indexing into the static keys alone reads a different account's lamports on a versioned + transaction, since `preBalances`/`postBalances` span the combined list. The helper + returns `null` when the account took no part, so "no rent moved" stays distinguishable + from a real zero. + +`/trading/amm/close` was added alongside, and is genuinely distinct from remove-at-100%: +withdrawing everything leaves the position NFT holding its rent, so close uses the SDK's +`removeAllLiquidityAndClosePosition` and reports `positionRentRefunded`. + +Both open and close work on **every** AMM. The fungible-LP AMMs (raydium, uniswap, +pancakeswap) route through their existing add and remove; they have no position account, so +`positionAddress` is absent and the rent figures are 0 — a fact about the AMM, not a +placeholder for something unread. + +**hummingbot-api can now delete its `positions-owned` reconciliation workaround.** + +**Verify:** open a DAMM v2 position via `/trading/amm/add` with no `positionAddress`; +the response should name the position and its rent. Close it; the rent should come back. + +--- + +## GW-7 — three sites double-counted the fee once GW-4 landed +**Status: fixed. Found while integrating GW-4.** + +GW-4 changed the contract of `extractBalanceChangesAndFee`: the native-SOL change is now +already net of the transaction fee. Three call sites still applied their own correction: + +| Site | Was | Effect | +|---|---|---| +| `meteora/clmm-routes/closePosition.ts:120,124` | `− rent + totalFee` | overstated SOL-side liquidity removed by one fee | +| `meteora/clmm-routes/openPosition.ts:208,213` | `− rent − txFee` | understated SOL-side liquidity added by one fee | +| `meteora/amm-routes/closePosition.ts` (new) | `− rent + fee` | same, caught before it shipped | + +All three now subtract rent only. The AMM close test encodes the contract in its fixture, +so a future change to the fee convention fails loudly rather than drifting amounts. + +This is the class of bug worth watching for: any site combining a balance change with a fee +is now coupled to that one function's convention. + +--- + +## GW-0 — the `poolAddress` pin survived the route refactor (no action) + +Recorded because it briefly looked lost: `src/trading/swap/quote.ts` and `execute.ts` were +deleted wholesale along with the pin test. Both landed on their feet — the rejection is now +in `src/trading/common.ts`, and the test moved to +`test/trading/pool-swap/pool-address-pin.test.ts`, extended to cover the router surface +having no `poolAddress` parameter at all. + +--- + +## GW-8 — nested `data` schemas were named but never registered +**Status: fixed (uncommitted in `~/gateway`).** + +Giving the shared schemas `$id`s put them in `components.schemas`, which is what lets both +Python clients generate their models instead of transcribing them. The collector only +walked module-level exports, though, so the 16 confirmed-transaction `data` objects — which +exist only nested inside their parent response — were rewritten to `$ref`s pointing at +components nobody defined. The spec resolved nowhere for exactly the fields that describe a +settled trade: `baseTokenBalanceChange`, `fee`, `positionRent`. + +**Fix:** collect `$id`s recursively, continuing *through* an already-collected schema. +84 components, no dangling refs; the generated models go from 128 classes with 26 numbered +collisions to 86 with none. + +--- + +## GW-9 — request bodies are declared inline, so they cannot be generated +**Status: fixed.** + +24 of Gateway's 28 request bodies are anonymous objects declared in the route file +(`const UnifiedAmmAddLiquidityRequest = Type.Object({...})` in +`src/trading/trading-amm-routes/add.ts`, and its 21 siblings). The `$id`'d schemas in +`src/schemas/` are *base* types the routes extend — `AmmAddLiquidityRequest` has no +`connector` or `chainNetwork`, the two fields every call sends — so a client generated from +them would be wrong in the same way for every route. + +**Fix:** each route's request const carries the `$id`, the route barrels re-export them, +and `identifiedSchemas()` in `src/app.ts` collects those barrels alongside the schema +modules. Three stale bases (`AmmExecuteSwapRequest`, `ClmmExecuteSwapRequest`, +`ClmmCreatePoolRequest`) were squatting the names the real wire bodies want while being +referenced by nothing; they keep their shape as composition sources but lose their `$id`. + +23 of the 28 request bodies are components now, up from 4 — all 15 trading POSTs, the four +parameterized chain POSTs, ethereum's allowances/approve, and wallet remove/add-hardware. +The two pool-swap bodies come from a factory, so they are built once per pool type and +shared with the route, since Fastify rejects a duplicate `$id`. + +This reached the POSTs only. Five POST bodies are also still inline — `/config/update`, +`/pools/`, `/tokens/`, `/wallet/add`, `/wallet/setDefault` — because they are raw object +literals in the route rather than named consts; extracting them is a separate change. + +**Superseded:** this section originally recorded that the GETs could never be components, +their fields reaching the spec only as `parameters`. That turned out to be wrong, and +GW-10 below covers them. + +`test/spec/request-components.test.ts` pins it: every `/trading` request body resolves to a +component, no `$ref` dangles, and each published request carries `connector` and +`chainNetwork` and not `network` — the last being what fails if a base type is ever +published in a route body's place. + +--- + +## GW-10 — the reads published the wrong shape under the right name +**Status: fixed.** + +GW-9 left 32 of 100 components referenced by nothing, read at the time as dead classes a +generator would emit and nobody would use. They were worse than dead. Every one was a +pre-refactor base, and the GET routes had no component of their own, so the obvious names +were held by shapes no route serves: + +| Route | Its query | The component holding the name | +|---|---|---| +| `GET /trading/clmm/quote-swap` | `chainNetwork`, `connector`, … | `ClmmQuoteSwapRequest`: `network`, no `connector` | +| `GET /trading/clmm/fetch-pools` | 9 fields incl. `connector` | `FetchPoolsRequest`: 4, incl. `network` | +| `GET /trading/clmm/position-info` | `chainNetwork`, `connector`, `positionAddress` | `GetPositionInfoRequest`: `network`, `positionAddress`, `walletAddress` | + +`walletAddress` there is not a parameter of that route at all. The same held on the +response side: `ClmmQuoteSwapResponse` and `AmmQuoteSwapResponse` were published while both +routes answer with `ChainQuoteSwapResponse`. This is exactly the trap GW-9 fixed for the +POSTs, still live for the reads — and the reads are most of this API. + +**The premise that blocked it was false.** Both GW-9 and the earlier analysis here assumed +a GET's fields could not become a component because @fastify/swagger expands a querystring +into `parameters`. Registering a schema and referencing it are independent: `addSchema` +puts it in `components.schemas`, and the operation still expands its parameters normally. +Verified by adding one `$id` and regenerating — the component appeared with all 9 fields +and the route's 9 parameters were unchanged. + +**Fix:** the `$id` moved off each stale base and onto the route's own querystring, the same +move GW-9 made for the bodies. All 12 trading GETs now publish a component matching their +query exactly; 28 stale bases and 3 orphaned `data` shapes lost their `$id`s, keeping their +shape as composition sources. `src/trading/clmm` gained a barrel — its four read routes +live outside `trading-clmm-routes`, so nothing was collecting them. `TokenSchema` is now +published as `Token`, the name the stale `TokensResponse` was holding. + +100 components down to 80. The 14 still unreferenced are all GET request models, which +cannot be referenced by construction. Both clients regenerated; hapi 20 checks and +hummingbot 115 pass, gateway 1118 tests across 137 suites. + +`test/spec/request-components.test.ts` grew two assertions: every `/trading` GET publishes +a component matching its query (`connector` and `chainNetwork`, never `network`), and no +component is both unreferenced and not some GET's query shape — which is what fails if a +stale base is ever republished. Mutation-checked both ways: removing one GET's `$id` fails +the first, and adding a stale `$id` fails the second. + +--- + +## GW-11 — the chain half of `chainNetwork` was decorative on the liquidity routes +**Status: fixed.** + +Fifteen routes — every `/trading/amm/*` and every `/trading/clmm/*` write plus the AMM +reads — did `const { network } = parseChainNetwork(chainNetwork)` and never looked at +`chain`. They then dispatched on `connector` alone, so the chain the caller named had no +effect on anything: + +| sent | connector called with | +|---|---| +| `chainNetwork: "ethereum-mainnet"`, `connector: "meteora"` | meteora ← `"mainnet"` | +| `chainNetwork: "banana-mainnet-beta"`, `connector: "meteora"` | meteora ← `"mainnet-beta"`, **succeeded** | +| `chainNetwork: "solana-"` | meteora ← `""` | + +On `/add` or `/open` that submits a transaction for a request Gateway could have proved +wrong before signing. The pool-scoped swap routes never had this — they fetch their ops +through the registry's `lookup`, which compares connector and chain — and the +`src/trading/clmm/*` reads switch on chain and reject an unknown one. Only the liquidity +routes, which call their connector module directly and so have no ops to fetch, were +unguarded. Three dispatch idioms, one of them with no check in it. + +**Fix, in two parts, because neither implies the other.** `chainNetworkField()` now +carries an `enum` read from `ConfigManagerV2.getSupportedChainNetworks()` — the 14 +configured `chain-network` strings, read at import rather than listed — which rejects rows +2 and 3 at the schema. Row 1 is a *valid* chain-network with the wrong connector, so the +enum cannot see it; `resolveChainNetwork(chainNetwork, connector, type)` replaces the bare +parse and calls the registry's `assertConnectorOnChain`, giving the same rejection the +swap routes already gave: `Connector 'meteora' runs on solana, not ethereum. Use a +ethereum clmm connector: uniswap, pancakeswap`. All three rows now 400 with the connector +never invoked. + +The registry's `CLMM_SWAP_CONNECTORS`/`AMM_SWAP_CONNECTORS` became `CLMM_CONNECTORS`/ +`AMM_CONNECTORS` and `common.ts` re-exports them instead of listing the same names again — +the hardcoded copies were a second roster that could drift from the table that dispatches. + +`test/trading/chain-network-guard.test.ts` pins it: the three malformed selectors, the +mismatched pair on both surfaces, the matching pair still dispatching, and a structural +case asserting no route under `trading-amm-routes/` or `trading-clmm-routes/` parses its +own `chainNetwork` — that last one is what stops a sixteenth route reopening the hole. +Mutation-checked separately in both directions: removing the enum fails exactly the three +schema cases, removing the pair check fails exactly the two pairing cases. + +Two test suites had been passing incoherent pairs and relying on a downstream error — +`positions-owned` asked for uniswap on `solana-mainnet-beta` — and now name each connector +with the chain it runs on. + +--- + +## GW-12 — three ways a caller's intent is dropped without an error +**Status: open.** None of these returns a wrong answer; each accepts a request it could +have rejected, and acts on something other than what was asked. + +- **No component sets `additionalProperties: false`** — 0 of 80. `slippagePc: 5` on an + execute-swap is dropped and the trade goes out at the connector's configured slippage. + This is the exact failure hummingbot-api wrote `test_every_kwarg_is_a_field_of_the_model_it_names` + to catch on its own side, because pydantic drops unknown keywords the same way. +- **`x-connectors` is documentation, not validation.** `AmmCreatePoolRequest` carries both + `configAddress` (meteora) and `ammConfigIndex` (raydium); passing the wrong one creates a + pool with connector defaults rather than erroring. 21 fields across the spec are marked + this way and none is enforced. +- **Writes default their connector.** `POST /trading/clmm/add` with only `positionAddress` + gets `meteora`, `solana-mainnet-beta` and the config wallet injected by AJV `useDefaults` + and runs. The first connector in the registry is a strange venue to pick for a position + whose venue the caller did not name. + +**Fix, if wanted:** `additionalProperties: false` on the trading request schemas is the +one with real blast radius — it turns today's silently-ignored field into a 400, which is +the point, but any caller sending an extra key starts failing. Enforcing `x-connectors` is +contained: one check against the field's own extension in `resolveChainNetwork`'s +neighbourhood. Dropping the `connector` default from the write routes costs the Swagger +prefill, which is what it was for. + +--- + +## GW-13 — the spec guard passes on regressions it claims to catch +**Status: open.** Both of these were demonstrated by mutation, not inferred. + +`test/spec/request-components.test.ts` identifies a GET's component by *shape* — the set of +its property names — because a GET has no `$ref` to follow. Three pairs share a shape +exactly: `Amm`/`ClmmQuoteSwapRequest`, `Amm`/`ClmmPositionsOwnedRequest`, and +`EstimateGasRequest`/`StatusRequest`. **Removing `AmmQuoteSwapRequest` from the spec +entirely leaves all 30 tests passing**, because its twin still matches the AMM route's +shape and the orphan check excuses it for the same reason. (hummingbot-api catches this +one, since `assert model is not None` fires when the class disappears — by accident, not +by design.) + +Second, the file's own comment says reading the committed spec means these "also catch a +spec that was not regenerated after a route changed." **They do not.** Adding a field to +`ClmmAddRequest` without running `pnpm generate:openapi` leaves all 30 passing. Reading the +committed spec catches nothing about drift; only regenerating and comparing does, and no +CI job does that — `.github/workflows/` never runs the generator. + +**Fix:** a CI step that regenerates and fails on a diff is the whole of the second half, +and it subsumes the first — a twin that lost its `$id` is a diff. Without it every other +open item here can regress green. + +--- + +## GW-14 — the spec names its types but not its operations or its errors +**Status: open.** The half of the generated-client contract GW-8/9/10 did not reach. + +**No operation has an `operationId`** — 0 of 58. That is the method name in a generated +client, so every generator synthesizes one from path and method, and every path change +renames the method. It is the same instability GW-9 fixed for models, still live for calls. + +**Three of 58 operations declare any non-2xx response** (`POST /pools/`, +`DELETE /pools/{address}`, `GET /pools/{tradingPair}`). A generated client therefore has no +error model at all, though `{statusCode, error, message, code?}` is the envelope every +route actually returns and the `code` is what callers branch on. + +Also undocumented: `GET /` and `POST /restart` are registered routes that `hideUntagged` +keeps out of the spec, and the ethereum-only routes are tagged `/chain/ethereum` where +everything else is `/chains`, which puts them in a separate class in a generated client. + +--- + +## GW-15 — response component names were never unified +**Status: open.** + +The request side got `Amm`/`Clmm` prefixes uniformly. The response side did not, so the +unprefixed name is the CLMM one and a reader has to know that: + +| CLMM route answers with | its AMM twin answers with | +|---|---| +| `PoolInfo` | `AmmPoolInfo` | +| `PositionInfo` | `AmmPositionInfo` | +| `AddLiquidityResponse` | `AmmAddLiquidityResponse` | +| `OpenPositionResponse` | `AmmOpenPositionResponse` | +| `QuotePositionResponse` | `QuoteLiquidityResponse` | + +The last row is also a stale name: the route was renamed `quote-position` → `quote-liquidity` +in the refactor and its response component kept the old word. `/chains/ethereum/allowances` +and `/approve` answer with inline objects where every other chain route has a component. + +--- + +## GW-16 — leftovers the unification did not sweep +**Status: open.** Cosmetic individually; together they are the difference between a +unified API and one that mostly looks unified. + +- **36 dead schema exports** survive the deleted per-connector routes — + `MeteoraClmmQuoteSwapRequest`, `PancakeswapSolClmm*` (11 of them), the jupiter/okx/dflow/ + titan request trio each. None is referenced by any route. `24795ffbb` swept some of these; + these are what it missed. **30 tests across four files assert their shape**, which is a + contract test for an API that no longer exists. +- **Three addressing conventions**, two of them inside one router: `/pools/` takes + `chain`+`network`, `/pools/find` takes `chainNetwork`, `/chains/{chain}/*` takes a path + `chain` plus a query `network`. Trading is uniformly `chainNetwork`. +- **Three `parseChainNetwork` implementations** with three validation levels: the one in + `src/trading/common.ts` rejects a value with no hyphen, `ConfigManagerV2`'s accepts + anything, and `src/pools/routes/findPools.ts:110` hand-rolls the split inline. +- **The registry covers swaps only.** Its header says a connector is one entry and the + routes are pure dispatch; that holds for `quoteSwap`/`executeSwap`/`fetchPools`, while 20 + route files still carry their own `switch (connector)` for every liquidity operation. + Adding a connector means editing all of them. +- **`/trading/router/execute-quote` has no caller.** `RouterExecuteQuoteRequest` is the one + generated request model hummingbot-api never constructs, so the quote-then-execute flow + that `quoteId` exists for is unused and every execute re-quotes. + +--- + +## GW-17 — `baseTokenAmountAdded` is a signed wallet delta on some connectors, a magnitude on others +**Status: open.** Found live 2026-08-19, the first time anything wrote to the AMM event +table. + +`amm-add` on Raydium's CPMM deposited 0.01 SOL and 0.849 USDC. hummingbot-api recorded it as: + +```json +"event_type": "ADD_LIQUIDITY", +"base_token_amount": -0.01, +"quote_token_amount": -0.848971 +``` + +Negative amounts on an add. The sign is Gateway's — hummingbot-api stores +`data.baseTokenAmountAdded` verbatim (`routers/gateway_amm.py:223`). + +Within one connector and one route family, the two directions disagree: + +```ts +// raydium/amm-routes/addLiquidity.ts:205 +baseTokenAmountAdded: baseTokenBalanceChange, // balanceChanges[0], raw → negative +// raydium/amm-routes/removeLiquidity.ts:215 +baseTokenAmountRemoved: Math.abs(baseTokenBalanceChange), // magnitude → positive +``` + +Across all 20 add sites the convention is split. Three pass a raw signed balance change: + +| Site | Value | Sign on a deposit | +|---|---|---| +| **raydium/amm/addLiquidity.ts:205** | `baseTokenBalanceChange` | **negative** (confirmed live) | +| **raydium/clmm/addLiquidity.ts:126** | `baseTokenBalanceChange` | **negative** | +| **raydium/clmm/openPosition.ts:137** | `baseTokenChange` | **negative** | +| meteora/amm/{createPool,openPosition} | `Math.abs(balanceChanges[0])` | positive | +| orca, pancakeswap-sol (all) | `Math.abs(...)` | positive | +| uniswap, pancakeswap (all) | `quote.baseTokenAmount`, `actualBaseAmount` | positive | + +The removed side has no such split: all nine sites normalize, either through `Math.abs` +or — in `meteora/amm/closePosition.ts:116` — an `adjust()` that takes the absolute value +and then nets off the position rent refund. + +**Why it matters.** `/gateway/amm/events/search` is the AMM history, and summing +`base_token_amount` over it is the obvious way to derive a net position. With adds +negative and removes positive on Raydium, that sum has one leg backwards; and because the +convention varies by connector, the same arithmetic is right for a Meteora pool and wrong +for a Raydium one. Nothing raises — the number is simply the wrong sign. + +**Fix:** `Math.abs()` on the three raydium sites, matching what every other connector and +the entire removed side already do. A field named `…Added` reporting a negative value is +wrong at the source, so this belongs in Gateway rather than in a consumer-side sign flip. + +**Verify:** `amm-add` then `amm-remove` on `58oQ…LYQo2` via +`condor/scripts_lp_test/test_lp_write.py`, then read +`POST /gateway/amm/events/search`. Both rows should carry positive amounts. The rows +already in the table were written under the old behavior and keep their signs. + +--- + +## What is not done + +- **GW-3's credentials** — the only item needing a decision. +- **Mainnet coverage as of 2026-08-19.** Run and confirmed: all three swap types + (router/clmm/amm), the full CLMM lifecycle on Meteora (open → add → remove → close), and + the Raydium AMM add. That last one is what surfaced GW-17. Still unrun: AMM remove, + fee collection, pool creation, and `manage_amm(create_pool)`, which no test step + exercises at all. The "verify" notes on the fixed issues above remain outstanding except + where a section says otherwise. +- **GW-12 through GW-16**, none of which is a wrong answer — they are ways a wrong + request is accepted quietly, or a right one is described badly. **GW-17 is not in that + category**: it is a stored number with the wrong sign. +- **Connector-specific response fields are gone from HTTP** — orca's `sqrtPrice`/`tvlUsdc`, + 0x's `gasEstimate`, jupiter's `quoteResponse`. Deleting `/connectors/*` removed the only + surface that exposed them. If any are wanted back, the fix is extending the unified + response schemas, not restoring the routes. From 42c5786622761bf156f60ebf544bfbf7968b7daf Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 18:51:03 -0700 Subject: [PATCH 26/65] docs(gateway): confirm GW-17 with the completed round trip amm-remove recorded +0.009985521, against the add's -0.01, so the mismatch is now two rows of one table rather than a prediction. Sharpens the diagnosis: raydium's Math.abs on the remove is ambiguous (for an inflow, magnitude and signed delta coincide), so raydium is internally coherent under signed-wallet-deltas while every other connector is coherent under magnitudes. Neither is wrong alone; what is broken is that no single arithmetic serves both and a stored row does not say which convention produced it. Also notes that the two existing rows keep their signs, making a migration or a documented cutover part of the fix rather than a separate concern. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- GATEWAY_ISSUES.md | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/GATEWAY_ISSUES.md b/GATEWAY_ISSUES.md index 58c9cc85d..6dd5090f4 100644 --- a/GATEWAY_ISSUES.md +++ b/GATEWAY_ISSUES.md @@ -586,20 +586,35 @@ The removed side has no such split: all nine sites normalize, either through `Ma or — in `meteora/amm/closePosition.ts:116` — an `adjust()` that takes the absolute value and then nets off the position rent refund. -**Why it matters.** `/gateway/amm/events/search` is the AMM history, and summing -`base_token_amount` over it is the obvious way to derive a net position. With adds -negative and removes positive on Raydium, that sum has one leg backwards; and because the -convention varies by connector, the same arithmetic is right for a Meteora pool and wrong -for a Raydium one. Nothing raises — the number is simply the wrong sign. +**The round trip, run live.** `amm-add` then `amm-remove` on the same Raydium pool: + +``` +event_type base quote status +REMOVE_LIQUIDITY +0.009985521 +0.850208000 CONFIRMED +ADD_LIQUIDITY -0.010000000 -0.848971000 CONFIRMED +``` + +**Why it matters.** Raydium's `Math.abs` on the remove is ambiguous — for an inflow the +magnitude and the signed delta are the same number — so Raydium ends up coherent end to +end under one convention (**signed wallet deltas**, net = `add + remove`) while every +other connector is coherent under the other (**magnitudes**, net = `remove - add`). Both +are defensible alone. What is broken is that no single arithmetic is right for both, and +nothing in a stored row says which convention produced it. + +`/gateway/amm/events/search` is the AMM history, so summing `base_token_amount` over it is +the obvious way to derive a net position. On the rows above that sum is meaningful — +`-0.0000145` SOL and `+0.001237` USDC, the real result of four minutes in the pool. Run +the identical arithmetic over Meteora rows and it adds two positives, double-counting the +round trip instead of netting it. Nothing raises either way. **Fix:** `Math.abs()` on the three raydium sites, matching what every other connector and the entire removed side already do. A field named `…Added` reporting a negative value is wrong at the source, so this belongs in Gateway rather than in a consumer-side sign flip. -**Verify:** `amm-add` then `amm-remove` on `58oQ…LYQo2` via -`condor/scripts_lp_test/test_lp_write.py`, then read -`POST /gateway/amm/events/search`. Both rows should carry positive amounts. The rows -already in the table were written under the old behavior and keep their signs. +**Verify:** repeat the round trip above and read `POST /gateway/amm/events/search`. After +the fix both rows carry positive amounts and the net is `remove - add`. The two rows +already in the table were written under the old behavior and keep their signs, so a +migration — or a documented cutover timestamp — is part of the fix, not separate from it. --- @@ -608,7 +623,7 @@ already in the table were written under the old behavior and keep their signs. - **GW-3's credentials** — the only item needing a decision. - **Mainnet coverage as of 2026-08-19.** Run and confirmed: all three swap types (router/clmm/amm), the full CLMM lifecycle on Meteora (open → add → remove → close), and - the Raydium AMM add. That last one is what surfaced GW-17. Still unrun: AMM remove, + the Raydium AMM round trip (add → remove), which is what surfaced GW-17. Still unrun: fee collection, pool creation, and `manage_amm(create_pool)`, which no test step exercises at all. The "verify" notes on the fixed issues above remain outstanding except where a section says otherwise. From 8df9d525755292c62477b94c5d10454834bfe1c9 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 20:44:40 -0700 Subject: [PATCH 27/65] docs(gateway): record GW-18, pancakeswap-sol's flattened close accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while confirming that clmm-collect-orca's 0/0 was a real zero. It was — orca reports baseFeeAmountCollected from the actual balance change, and the position had nothing pending. But surveying the connectors for that check turned up pancakeswap-sol/closePosition.ts, which hardcodes fees and rent to 0 and folds the fees into baseTokenAmountRemoved. Every other connector separates them. hummingbot-api stores the fields separately, so on that connector fee income records as zero forever and principal returned reads high, silently. Read from source, not observed live — the test wallet holds no pancakeswap-sol positions. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- GATEWAY_ISSUES.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/GATEWAY_ISSUES.md b/GATEWAY_ISSUES.md index 6dd5090f4..e1179e8c2 100644 --- a/GATEWAY_ISSUES.md +++ b/GATEWAY_ISSUES.md @@ -26,8 +26,9 @@ changed every path these issues live on. | GW-15 | Response component names were never unified | **open** | | GW-16 | Leftovers the unification did not sweep | **open** | | GW-17 | `baseTokenAmountAdded` is signed on some connectors, a magnitude on others | **open** | +| GW-18 | pancakeswap-sol's close reports fees and rent as a hardcoded 0 | **open** | -GW-1 through GW-11 are code-complete and green: 1126 tests, 138 suites, clean typecheck, +GW-1 through GW-11 are code-complete and green: 1144 tests, 141 suites, clean typecheck, lint 0 errors, and `openapi.json` regenerates identical to the committed copy. Mainnet verification began 2026-08-19 against a container built from `c0bb10253`: the @@ -618,6 +619,45 @@ migration — or a documented cutover timestamp — is part of the fix, not sepa --- +## GW-18 — pancakeswap-sol's close reports fees and rent as a hardcoded 0 +**Status: open.** One connector, one route. Found 2026-08-19 while confirming that a +`collect_fees` result of 0/0 was a real zero rather than a missing field. + +`pancakeswap-sol/clmm-routes/closePosition.ts:127`: + +```ts +baseTokenAmountRemoved: Math.abs(baseTokenChange), // the whole wallet delta +quoteTokenAmountRemoved: Math.abs(quoteTokenChange), +baseFeeAmountCollected: 0, // Included in balance changes +quoteFeeAmountCollected: 0, // Included in balance changes +positionRentRefunded: 0, // Position rent refund (simplified) +``` + +The comment is accurate about the mechanism and that is the problem: the fees really are +inside the balance change, so closing a position with pending fees reports fees of 0 and a +principal inflated by exactly those fees, plus rent of 0. Every other connector separates +them: + +| Connector | fees on close | rent on close | +|---|---|---| +| meteora, orca, raydium | real amounts | real amount | +| uniswap, pancakeswap | real amounts | n/a (no rent on EVM) | +| **pancakeswap-sol** | **always 0** | **always 0** | + +hummingbot-api stores the two fields separately in its CLOSE event and position +accounting, so on this connector fee income is recorded as zero forever and principal +returned reads high — with no error anywhere. + +**Fix:** extract the fee transfers from the balance changes the way `orca/closePosition.ts` +does (it groups the transfers and takes principal from the first group, fees from the +second), and report the real rent instead of 0. + +**Not yet observed live** — the test wallet holds no pancakeswap-sol positions, so this is +read from the source rather than from a response. The related `collect_fees` route on the +same connector does report real amounts; it is only `closePosition` that flattens them. + +--- + ## What is not done - **GW-3's credentials** — the only item needing a decision. From 528f1eeb438abc60a6d64550164ecc2ee023f22c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 20:56:25 -0700 Subject: [PATCH 28/65] test(lp): exercise orca CLMM and meteora DAMM v2; record GW-19 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_lp_write.py covers exactly one connector per surface — meteora for CLMM, raydium for AMM — so the other kind of each went unexercised. Two scripts in the same one-step-per-invocation shape: - test_orca_clmm.py: orca's SOL-USDC pool. Two opens, because the ranges ask different questions: one straddles spot and should hold both tokens, one sits entirely above it and should need no quote side at all (the case GW-1 was about). - test_meteora_amm.py: DAMM v2, where positions are NFTs — add_liquidity opens a new one when no position_address is given, and remove requires one, none of which the raydium path can reach. No meteora AMM pool is configured, so it uses a memecoin/SOL pool the wallet has history with, and swap-in has to run first because DAMM v2 needs both sides. The `lists` step exists because Gateway now learns tokens and pools from the chain as they are used: it shows the mint and the pool before and after. Confirmed working — one pool_info read took the lists from 14 tokens/10 pools to 15/11, recording the base token as DOGE-1 Satellite with its decimals read via Metaplex. That is also how GW-19 turned up. The learned symbol contains a hyphen, so Gateway filed the pool under "DOGE-1-SOL", and gateway_swap.py's bare `base, quote = pair.split("-")` raises ValueError — surfacing as 400 'too many values to unpack (expected 2)'. Gateway is right about the pair; the consumer assumes symbols have no hyphens. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- GATEWAY_ISSUES.md | 47 ++++++++ scripts_lp_test/test_meteora_amm.py | 181 ++++++++++++++++++++++++++++ scripts_lp_test/test_orca_clmm.py | 115 ++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 scripts_lp_test/test_meteora_amm.py create mode 100644 scripts_lp_test/test_orca_clmm.py diff --git a/GATEWAY_ISSUES.md b/GATEWAY_ISSUES.md index e1179e8c2..b22ba439e 100644 --- a/GATEWAY_ISSUES.md +++ b/GATEWAY_ISSUES.md @@ -27,6 +27,7 @@ changed every path these issues live on. | GW-16 | Leftovers the unification did not sweep | **open** | | GW-17 | `baseTokenAmountAdded` is signed on some connectors, a magnitude on others | **open** | | GW-18 | pancakeswap-sol's close reports fees and rent as a hardcoded 0 | **open** | +| GW-19 | A hyphen in a token symbol makes its pair unquotable (hummingbot-api) | **open** | GW-1 through GW-11 are code-complete and green: 1144 tests, 141 suites, clean typecheck, lint 0 errors, and `openapi.json` regenerates identical to the committed copy. @@ -658,6 +659,52 @@ same connector does report real amounts; it is only `closePosition` that flatten --- +## GW-19 — a token whose symbol contains a hyphen makes its pair unquotable +**Status: open. Lives in hummingbot-api, not Gateway** — but Gateway's chain-learning +feature is what makes it reachable, so it belongs with that work. Found live 2026-08-19, +minutes after that feature first recorded a token. + +`routers/gateway_swap.py:62` and `:148`: + +```python +base, quote = request.trading_pair.split("-") +``` + +No `maxsplit`, no guard. Any symbol containing a hyphen raises `ValueError`, which escapes +as the HTTP message: + +``` +400 'too many values to unpack (expected 2)' +``` + +Two things are wrong there. The pair is unquotable at all, and the caller is handed a +Python internal instead of an explanation — there is no way to read that message and learn +that the symbol is the problem. + +**Why it is newly reachable.** Gateway now reads a token's name, symbol and decimals off +the chain the first time it is used. Symbols are then whatever the mint says, not what a +curated list allows. The first pool this was exercised against recorded its base token as +`DOGE-1`, so Gateway filed the pool under `trading_pair: "DOGE-1-SOL"` — a string +hummingbot-api cannot parse back into two tokens. Gateway is right; the pair *is* +`DOGE-1` over `SOL`. The consumer's split is what assumes a symbol has no hyphen. + +``` +"DOGE-1-SOL" → 400, cannot quote +"DpBzjtgG…vfhm-SOL" (the mint, spelled out) → quotes fine +``` + +**Fix:** `rsplit("-", 1)` at both sites, and raise something that names the pair. The same +bare unpack appears at `routers/market_data.py:382,406` and +`services/orders_recorder.py:226,238`; `routers/executors.py:438,517` and two more in +`services/` take `parts = ...split("-")` and index, which mangles rather than raises. + +**Related, smaller:** `GET /gateway/networks/{id}/tokens?search=` filters on symbol and +name only, so searching a freshly-learned token by its address returns `{"tokens":[]}` +while the token is in the list. The pool search on the same router *does* match an address, +which is what makes it a trap rather than merely a limitation. + +--- + ## What is not done - **GW-3's credentials** — the only item needing a decision. diff --git a/scripts_lp_test/test_meteora_amm.py b/scripts_lp_test/test_meteora_amm.py new file mode 100644 index 000000000..d06f03f59 --- /dev/null +++ b/scripts_lp_test/test_meteora_amm.py @@ -0,0 +1,181 @@ +"""Meteora DAMM v2, driven through condor's manage_amm tool. + +test_lp_write.py exercises the AMM surface on Raydium only, which is a fungible-LP CPMM. +Meteora DAMM v2 is the other kind: positions are NFTs, so a wallet can hold several in one +pool, add_liquidity opens a new one when no position_address is given, and remove_liquidity +requires one. None of that is reachable through the Raydium path. + +The pool here is a memecoin/SOL pool the wallet already has history with, because no +Meteora AMM pool is in Gateway's configured list — only Meteora CLMM. That has a +consequence the script is built around: DAMM v2 needs both sides of the pair, and the +wallet holds none of the base token, so `swap-in` has to run first. + +That first swap does something else worth watching. Gateway learns tokens and pools from +the chain as they are used, so before `swap-in` this mint has no symbol and this pool is +in no list; afterwards both should be recorded, and `lists` shows it. + +EVERY STEP EXCEPT `reads` AND `lists` SIGNS AND SUBMITS A MAINNET TRANSACTION: + + ./.venv/bin/python scripts_lp_test/test_meteora_amm.py # list the steps + ./.venv/bin/python scripts_lp_test/test_meteora_amm.py lists # before + ./.venv/bin/python scripts_lp_test/test_meteora_amm.py swap-in + ./.venv/bin/python scripts_lp_test/test_meteora_amm.py lists # after — token learned + ./.venv/bin/python scripts_lp_test/test_meteora_amm.py add + ./.venv/bin/python scripts_lp_test/test_meteora_amm.py remove + +Sized at ~0.01 SOL per leg. The pool charges 4%, so a round trip through it is expected to +lose a few percent of the amount committed — that is the pool's fee, not a defect. +""" + +import asyncio +import json +import os +import sys +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from mcp_servers.hummingbot_api import server # noqa: E402 + + +def tool(name): + obj = getattr(server, name) + return getattr(obj, "fn", obj) + + +manage_amm = tool("manage_amm") +manage_gateway_swaps = tool("manage_gateway_swaps") + +NET = "solana-mainnet-beta" +WALLET = "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" + +# Meteora DAMM v2, 4% fee. Base is an unlisted memecoin mint; quote is wrapped SOL, which +# is why the pair below can be written against SOL. +POOL = "2fckuYXUrwbA9LHKMP1az5DTFstFrRjqqbBstz7yvVXL" +MINT = "DpBzjtgGLF7QA9Ug3eUVGbnqa6j3jvYBn1XuQuktvfhm" +PAIR = f"{MINT}-SOL" + +# How much of the base token to buy and then commit. At ~0.0000017 SOL each, 5000 is +# roughly 0.0087 SOL — the same order as every other step in this suite. +BASE_UNITS = "5000" + +API = os.environ.get("HUMMINGBOT_API_URL", "http://localhost:8000") +AUTH = os.environ.get("HUMMINGBOT_API_AUTH", "admin:admin") + + +def _get(path): + request = urllib.request.Request(f"{API}{path}") + import base64 + request.add_header("Authorization", "Basic " + base64.b64encode(AUTH.encode()).decode()) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + + +async def lists(): + """What Gateway currently knows about this token and this pool. Signs nothing. + + Run before and after swap-in: Gateway records a token and a pool the first time one is + used, reading name, symbol and decimals off the chain rather than from a third party. + """ + # Scan the full list rather than passing ?search=

: that filter matches on + # symbol and name only, so it reports a token absent when it is present. The pool + # filter on the same router does match an address, which is what makes it a trap. + all_tokens = _get(f"/gateway/networks/{NET}/tokens")["tokens"] + token = [t for t in all_tokens if t.get("address") == MINT] + pool = _get(f"/gateway/networks/{NET}/pools?search={POOL}")["pools"] + totals = (len(all_tokens), _get(f"/gateway/networks/{NET}/pools")["count"]) + return ( + f"token {MINT[:8]}…: {json.dumps(token) if token else 'NOT IN LIST'}\n" + f"pool {POOL[:8]}…: {json.dumps(pool) if pool else 'NOT IN LIST'}\n" + f"totals: {totals[0]} tokens, {totals[1]} pools" + ) + + +async def reads(): + """Every Meteora AMM read. Signs nothing.""" + out = [] + for title, coro in [ + ("pool_info", manage_amm(action="pool_info", connector="meteora", + network=NET, pool_address=POOL)), + ("quote_liquidity", manage_amm(action="quote_liquidity", connector="meteora", + network=NET, pool_address=POOL, + base_token_amount=BASE_UNITS, + quote_token_amount="0.01")), + ("positions_owned", manage_amm(action="positions_owned", connector="meteora", + network=NET, wallet_address=WALLET)), + ("position_info", manage_amm(action="position_info", connector="meteora", + network=NET, pool_address=POOL, + wallet_address=WALLET)), + ]: + out.append(f"--- {title}") + try: + out.append(str(await coro).strip()) + except Exception as e: + out.append(f" !! {type(e).__name__}: {e}") + return "\n".join(out) + + +def steps(arg): + """Each step is a no-arg lambda so nothing runs until one is selected.""" + return { + "lists": lists, + "reads": reads, + + # Buys the base token so there is something to pair with. Also the call that + # teaches Gateway the mint — a router swap learns the two token addresses it was + # given, even though it has no pool to record. + "swap-in": lambda: manage_gateway_swaps( + action="execute", connector="jupiter", network=NET, + trading_pair=PAIR, side="BUY", amount=BASE_UNITS), + + # No position_address: DAMM v2 opens a new NFT position. + "add": lambda: manage_amm( + action="add_liquidity", connector="meteora", network=NET, pool_address=POOL, + base_token_amount=BASE_UNITS, quote_token_amount="0.01"), + # With one: adds to the position that already exists, rather than opening another. + "add-more": lambda: manage_amm( + action="add_liquidity", connector="meteora", network=NET, pool_address=POOL, + position_address=arg, base_token_amount="2000", quote_token_amount="0.005"), + + "remove": lambda: manage_amm( + action="remove_liquidity", connector="meteora", network=NET, + pool_address=POOL, position_address=arg, percentage_to_remove="50"), + "close": lambda: manage_amm( + action="remove_liquidity", connector="meteora", network=NET, + pool_address=POOL, position_address=arg, percentage_to_remove="100"), + + # Sells the base token back, leaving the wallet as it started. + "swap-out": lambda: manage_gateway_swaps( + action="execute", connector="jupiter", network=NET, + trading_pair=PAIR, side="SELL", amount=BASE_UNITS), + } + + +NEEDS_POSITION = ("add-more", "remove", "close") + + +async def main(): + name = sys.argv[1] if len(sys.argv) > 1 else None + arg = sys.argv[2] if len(sys.argv) > 2 else None + table = steps(arg) + + if name not in table: + print(__doc__) + print("Steps:") + for key in table: + print(f" {key}") + return + + if name in NEEDS_POSITION and not arg: + print(f"{name} needs a position address: ... {name} ") + return + + print(f"--- {name} ---") + try: + print(str(await table[name]()).strip()) + except Exception as e: + print(f"!! {type(e).__name__}: {e}") + + +asyncio.run(main()) diff --git a/scripts_lp_test/test_orca_clmm.py b/scripts_lp_test/test_orca_clmm.py new file mode 100644 index 000000000..9e2cc45c4 --- /dev/null +++ b/scripts_lp_test/test_orca_clmm.py @@ -0,0 +1,115 @@ +"""Orca CLMM, driven through condor's manage_clmm tool. + +test_lp_write.py exercises the CLMM lifecycle on Meteora only. Orca is a different SDK +underneath — v8 builds the whole close flow in one call, and its close is the one that +reports collected fees and rent refunded separately — so the same sequence is worth +running against it independently. + +EVERY STEP EXCEPT `reads` SIGNS AND SUBMITS A MAINNET TRANSACTION. Run one at a time and +read the result before continuing: + + ./.venv/bin/python scripts_lp_test/test_orca_clmm.py # list the steps + ./.venv/bin/python scripts_lp_test/test_orca_clmm.py reads + ./.venv/bin/python scripts_lp_test/test_orca_clmm.py open + ./.venv/bin/python scripts_lp_test/test_orca_clmm.py add + +Amounts are ~0.01 SOL / ~1 USDC per step. + +Two opens, because the ranges answer different questions. `open` straddles spot, so the +position should hold both tokens. `open-above` sits entirely above spot, where a CLMM +position is 100% base — it should take the SOL and require no USDC at all. That second +case is the one GW-1 was about: Meteora used to quote a nonzero paired amount for it and +then reject its own quote. +""" + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from mcp_servers.hummingbot_api import server # noqa: E402 + + +def tool(name): + obj = getattr(server, name) + return getattr(obj, "fn", obj) + + +manage_clmm = tool("manage_clmm") + +NET = "solana-mainnet-beta" +WALLET = "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" +POOL = "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE" # orca SOL-USDC, 0.04% + + +async def reads(): + """Every orca read, in one go. Signs nothing.""" + out = [] + out.append("--- positions owned by this wallet on orca") + out.append(await manage_clmm(action="position_info", connector="orca", + network=NET, wallet_address=WALLET)) + return "\n".join(str(o).strip() for o in out) + + +def steps(arg): + """Each step is a no-arg lambda so nothing runs until one is selected.""" + return { + "reads": reads, + + # Straddles spot (~85): the position should take both tokens. + "open": lambda: manage_clmm( + action="open", connector="orca", network=NET, pool_address=POOL, + lower_price="80", upper_price="90", + base_token_amount="0.01", quote_token_amount="1"), + + # Entirely above spot: 100% base, and the quote side should stay untouched. + "open-above": lambda: manage_clmm( + action="open", connector="orca", network=NET, pool_address=POOL, + lower_price="95", upper_price="105", base_token_amount="0.01"), + + "add": lambda: manage_clmm( + action="add_liquidity", connector="orca", network=NET, + position_address=arg, base_token_amount="0.005"), + "remove": lambda: manage_clmm( + action="remove_liquidity", connector="orca", network=NET, + position_address=arg, percentage_to_remove="50"), + "collect": lambda: manage_clmm( + action="collect_fees", connector="orca", network=NET, position_address=arg), + "close": lambda: manage_clmm( + action="close", connector="orca", network=NET, position_address=arg), + + # Reads one position rather than the whole wallet — the two go through different + # code paths in hummingbot-api, and they have disagreed before. + "info": lambda: manage_clmm( + action="position_info", connector="orca", network=NET, position_address=arg), + } + + +NEEDS_POSITION = ("add", "remove", "collect", "close", "info") + + +async def main(): + name = sys.argv[1] if len(sys.argv) > 1 else None + arg = sys.argv[2] if len(sys.argv) > 2 else None + table = steps(arg) + + if name not in table: + print(__doc__) + print("Steps:") + for key in table: + print(f" {key}") + return + + if name in NEEDS_POSITION and not arg: + print(f"{name} needs a position address: ... {name} ") + return + + print(f"--- {name} ---") + try: + print((await table[name]()).strip()) + except Exception as e: + print(f"!! {type(e).__name__}: {e}") + + +asyncio.run(main()) From f7e210fe7b82f52aca75c0a19e3830afd474d230 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 21:00:30 -0700 Subject: [PATCH 29/65] test(lp): size the meteora legs to the fill, and read balances with refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections from running swap-in. A BUY fills at the routed price, not the quoted one: 5000 requested, 4607.86 filled. The add step asked the pool for the same 5000 it had told the swap to buy, which overdraws whenever the fill comes in short. What is committed is now deliberately less than what is bought, with the quote side sized just above the pool ratio so base stays limiting. Adds a balances step, which asks /portfolio/state for refresh: true. Without it the API answers from a snapshot it rebuilds on a five-minute loop — right after the swap it still reported the pre-swap 350 DOGE-1 while the chain held 4957.86. With refresh it matches the chain exactly. Every other read in the script goes live to Gateway, so the cached form was the one thing here that could disagree with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- scripts_lp_test/test_meteora_amm.py | 89 +++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 24 deletions(-) diff --git a/scripts_lp_test/test_meteora_amm.py b/scripts_lp_test/test_meteora_amm.py index d06f03f59..648df193f 100644 --- a/scripts_lp_test/test_meteora_amm.py +++ b/scripts_lp_test/test_meteora_amm.py @@ -8,13 +8,20 @@ The pool here is a memecoin/SOL pool the wallet already has history with, because no Meteora AMM pool is in Gateway's configured list — only Meteora CLMM. That has a consequence the script is built around: DAMM v2 needs both sides of the pair, and the -wallet holds none of the base token, so `swap-in` has to run first. +wallet starts with far less base token than a leg needs, so `swap-in` has to run first. -That first swap does something else worth watching. Gateway learns tokens and pools from -the chain as they are used, so before `swap-in` this mint has no symbol and this pool is -in no list; afterwards both should be recorded, and `lists` shows it. +`lists` shows what Gateway knows about this mint and this pool. Gateway learns both from +the chain the first time either is used, so the list grows as the script runs — reading +`pool_info` alone is enough to record the pool and its two tokens, before any of the +write steps have run. -EVERY STEP EXCEPT `reads` AND `lists` SIGNS AND SUBMITS A MAINNET TRANSACTION: +`balances` reads the wallet. It asks `/portfolio/state` for `refresh: true`, which is not +the default: without it the API answers from a snapshot it rebuilds on a five-minute loop, +so straight after a swap it still reports the pre-swap balance. Every other read in this +script goes live to Gateway, so the cached form is the one thing here that can disagree +with the chain. + +EVERY STEP EXCEPT `balances`, `lists` AND `reads` SIGNS AND SUBMITS A MAINNET TRANSACTION: ./.venv/bin/python scripts_lp_test/test_meteora_amm.py # list the steps ./.venv/bin/python scripts_lp_test/test_meteora_amm.py lists # before @@ -23,11 +30,13 @@ ./.venv/bin/python scripts_lp_test/test_meteora_amm.py add ./.venv/bin/python scripts_lp_test/test_meteora_amm.py remove -Sized at ~0.01 SOL per leg. The pool charges 4%, so a round trip through it is expected to -lose a few percent of the amount committed — that is the pool's fee, not a defect. +Sized well under 0.01 SOL per leg. The pool charges 4%, so a round trip through it is +expected to give back a few percent less than went in — that is the pool's fee, not a +defect. """ import asyncio +import base64 import json import os import sys @@ -56,22 +65,52 @@ def tool(name): MINT = "DpBzjtgGLF7QA9Ug3eUVGbnqa6j3jvYBn1XuQuktvfhm" PAIR = f"{MINT}-SOL" -# How much of the base token to buy and then commit. At ~0.0000017 SOL each, 5000 is -# roughly 0.0087 SOL — the same order as every other step in this suite. -BASE_UNITS = "5000" +# At ~0.0000018 SOL each, 5000 units is roughly 0.0088 SOL — the same order as every other +# step in this suite. What is committed to the pool is deliberately less than what is +# bought: a BUY fills at the routed price, not the quoted one, so asking the pool for +# exactly what the swap was told to buy overdraws whenever the fill comes in short. It did +# here — 5000 requested, 4607.86 filled. +SWAP_UNITS = "5000" +ADD_UNITS = "3000" +ADD_MORE_UNITS = "1000" +SWAP_OUT_UNITS = "4000" + +# Quote offered alongside. Each is a little above the pool ratio for that base amount, so +# base stays the limiting side and the pool takes the quote it actually needs. +ADD_QUOTE = "0.006" +ADD_MORE_QUOTE = "0.002" API = os.environ.get("HUMMINGBOT_API_URL", "http://localhost:8000") AUTH = os.environ.get("HUMMINGBOT_API_AUTH", "admin:admin") -def _get(path): - request = urllib.request.Request(f"{API}{path}") - import base64 - request.add_header("Authorization", "Basic " + base64.b64encode(AUTH.encode()).decode()) - with urllib.request.urlopen(request, timeout=60) as response: +def _call(path, payload=None): + body = None if payload is None else json.dumps(payload).encode() + request = urllib.request.Request(f"{API}{path}", data=body) + request.add_header("Authorization", + "Basic " + base64.b64encode(AUTH.encode()).decode()) + if body is not None: + request.add_header("Content-Type", "application/json") + with urllib.request.urlopen(request, timeout=120) as response: return json.load(response) +async def balances(): + """The wallet, read fresh. Signs nothing. + + refresh=True is the point: the default answers from a snapshot rebuilt every five + minutes, which still shows the pre-swap balance right after a swap. + """ + state = _call("/portfolio/state", {"refresh": True}) + rows = [ + f" {item.get('token'):<10} {item.get('units')}" + for connectors in state.values() + for name, items in (connectors or {}).items() if "solana" in name.lower() + for item in items if float(item.get("units") or 0) > 0 + ] + return "\n".join(rows) or "no non-zero solana balances" + + async def lists(): """What Gateway currently knows about this token and this pool. Signs nothing. @@ -81,10 +120,10 @@ async def lists(): # Scan the full list rather than passing ?search=
: that filter matches on # symbol and name only, so it reports a token absent when it is present. The pool # filter on the same router does match an address, which is what makes it a trap. - all_tokens = _get(f"/gateway/networks/{NET}/tokens")["tokens"] + all_tokens = _call(f"/gateway/networks/{NET}/tokens")["tokens"] token = [t for t in all_tokens if t.get("address") == MINT] - pool = _get(f"/gateway/networks/{NET}/pools?search={POOL}")["pools"] - totals = (len(all_tokens), _get(f"/gateway/networks/{NET}/pools")["count"]) + pool = _call(f"/gateway/networks/{NET}/pools?search={POOL}")["pools"] + totals = (len(all_tokens), _call(f"/gateway/networks/{NET}/pools")["count"]) return ( f"token {MINT[:8]}…: {json.dumps(token) if token else 'NOT IN LIST'}\n" f"pool {POOL[:8]}…: {json.dumps(pool) if pool else 'NOT IN LIST'}\n" @@ -100,8 +139,8 @@ async def reads(): network=NET, pool_address=POOL)), ("quote_liquidity", manage_amm(action="quote_liquidity", connector="meteora", network=NET, pool_address=POOL, - base_token_amount=BASE_UNITS, - quote_token_amount="0.01")), + base_token_amount=ADD_UNITS, + quote_token_amount=ADD_QUOTE)), ("positions_owned", manage_amm(action="positions_owned", connector="meteora", network=NET, wallet_address=WALLET)), ("position_info", manage_amm(action="position_info", connector="meteora", @@ -119,6 +158,7 @@ async def reads(): def steps(arg): """Each step is a no-arg lambda so nothing runs until one is selected.""" return { + "balances": balances, "lists": lists, "reads": reads, @@ -127,16 +167,17 @@ def steps(arg): # given, even though it has no pool to record. "swap-in": lambda: manage_gateway_swaps( action="execute", connector="jupiter", network=NET, - trading_pair=PAIR, side="BUY", amount=BASE_UNITS), + trading_pair=PAIR, side="BUY", amount=SWAP_UNITS), # No position_address: DAMM v2 opens a new NFT position. "add": lambda: manage_amm( action="add_liquidity", connector="meteora", network=NET, pool_address=POOL, - base_token_amount=BASE_UNITS, quote_token_amount="0.01"), + base_token_amount=ADD_UNITS, quote_token_amount=ADD_QUOTE), # With one: adds to the position that already exists, rather than opening another. "add-more": lambda: manage_amm( action="add_liquidity", connector="meteora", network=NET, pool_address=POOL, - position_address=arg, base_token_amount="2000", quote_token_amount="0.005"), + position_address=arg, base_token_amount=ADD_MORE_UNITS, + quote_token_amount=ADD_MORE_QUOTE), "remove": lambda: manage_amm( action="remove_liquidity", connector="meteora", network=NET, @@ -148,7 +189,7 @@ def steps(arg): # Sells the base token back, leaving the wallet as it started. "swap-out": lambda: manage_gateway_swaps( action="execute", connector="jupiter", network=NET, - trading_pair=PAIR, side="SELL", amount=BASE_UNITS), + trading_pair=PAIR, side="SELL", amount=SWAP_OUT_UNITS), } From f17b0da54ace67b8e4ade097e42c63e9957739fc Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 21:03:24 -0700 Subject: [PATCH 30/65] docs(gateway): record GW-20, position rent booked as deposited liquidity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first Meteora DAMM v2 open recorded quoteTokenAmountAdded as 0.015220830 — the whole SOL outflow net of gas — for a position holding 0.005323709. The difference is the rent for the position NFT accounts, which the chain returns on close and which is therefore locked, not spent. The stored position is 2.86x its real size. The same connector already nets rent on the way out: closePosition.ts adjusts the native side by positionRentRefunded. openPosition.ts computes positionRent, returns it, and does not apply it. hummingbot-api compounds it — the position table has a position_rent column and the CLMM path fills it, but the AMM path never reads it, so the inflated figure is booked with nothing alongside it to back it out. Against a close that does net the refund, this fabricates a ~186% loss on a round trip whose real cost is the 4% pool fee. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- GATEWAY_ISSUES.md | 75 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/GATEWAY_ISSUES.md b/GATEWAY_ISSUES.md index b22ba439e..f08f99f93 100644 --- a/GATEWAY_ISSUES.md +++ b/GATEWAY_ISSUES.md @@ -28,6 +28,7 @@ changed every path these issues live on. | GW-17 | `baseTokenAmountAdded` is signed on some connectors, a magnitude on others | **open** | | GW-18 | pancakeswap-sol's close reports fees and rent as a hardcoded 0 | **open** | | GW-19 | A hyphen in a token symbol makes its pair unquotable (hummingbot-api) | **open** | +| GW-20 | A DAMM v2 open records the position rent as deposited liquidity | **open** | GW-1 through GW-11 are code-complete and green: 1144 tests, 141 suites, clean typecheck, lint 0 errors, and `openapi.json` regenerates identical to the committed copy. @@ -705,14 +706,80 @@ which is what makes it a trap rather than merely a limitation. --- +## GW-20 — a DAMM v2 open records the position rent as deposited liquidity +**Status: open.** Found live 2026-08-19 on the first Meteora AMM add. Worse than GW-17: +that is a sign, this is a magnitude, and here it was wrong by 2.86×. + +Opened a DAMM v2 position with 3000 base and the quote side the pool asked for. What the +chain did, what the position holds, and what was recorded: + +``` +wallet SOL delta -0.015241081 + less gas -0.015220830 <- recorded as quoteTokenAmountAdded +liquidity actually in the pool 0.005323709 <- what positions_owned reports +unaccounted 0.009897121 <- rent for the position NFT accounts +``` + +Rent is **locked, not spent** — the chain returns it when the position account is closed. +Recording it as deposited liquidity overstates the position by the rent, and since the +rent here is nearly twice the liquidity, the stored position is 2.86× its real size. + +The same connector already gets this right on the way out. +`meteora/amm-routes/closePosition.ts:116`: + +```ts +const adjust = (change, mint) => + mint.toBase58() === nativeMint ? Math.max(0, Math.abs(change) - positionRentRefunded) + : Math.abs(change); +quoteTokenAmountRemoved: adjust(balanceChanges[1], poolState.tokenBMint), +``` + +`openPosition.ts:86` computes the same quantity, returns it, and does not apply it: + +```ts +positionRent, // computed at :73, returned, unused +quoteTokenAmountAdded: Math.abs(balanceChanges[1]), // still carries the rent +``` + +**Why it compounds.** hummingbot-api stores `positionRent` on the position row and +populates it for CLMM (`routers/gateway_clmm.py:455,539`) — but the AMM path never reads +it. `grep position_rent routers/gateway_amm.py` returns nothing. So the inflated figure is +booked with nothing recorded alongside that would let a reader back it out: + +```json +"initial_quote_token_amount": 0.015220830000000001, +"quote_token_amount": 0.015220830000000001, +"lp_token_amount": null +``` + +against a position `positions_owned` reports as holding `0.005323709`. + +**The P&L this produces.** The close nets the rent refund out, so it will record roughly +the 0.0053 that was really in the pool. Against an open of 0.0152, that is a fabricated +loss of ~0.0099 SOL on a position that never held more than 0.0053 — a ~186% loss on a +round trip whose real cost is the 4% pool fee. + +**Fix, two halves:** subtract `positionRent` from the native-token side in +`meteora/amm-routes/openPosition.ts`, exactly as `closePosition.ts` already does; and have +`_book_position_add` in `routers/gateway_amm.py` store `positionRent` the way the CLMM path +does, so the value is recoverable for rows already written. + +**GW-4 is related but not the same.** That one was gas inflating native amounts, and it is +fixed — gas is correctly excluded here, which is why the recorded figure matches the +delta-minus-fee exactly. Rent was never part of it. + +--- + ## What is not done - **GW-3's credentials** — the only item needing a decision. - **Mainnet coverage as of 2026-08-19.** Run and confirmed: all three swap types - (router/clmm/amm), the full CLMM lifecycle on Meteora (open → add → remove → close), and - the Raydium AMM round trip (add → remove), which is what surfaced GW-17. Still unrun: - fee collection, pool creation, and `manage_amm(create_pool)`, which no test step - exercises at all. The "verify" notes on the fixed issues above remain outstanding except + (router/clmm/amm), the full CLMM lifecycle on Meteora (open → add → remove → close), the + Raydium AMM round trip (add → remove) which surfaced GW-17, and a Meteora DAMM v2 open + which surfaced GW-20. Orca CLMM and the rest of the DAMM v2 lifecycle have scripts + (`condor/scripts_lp_test/test_orca_clmm.py`, `test_meteora_amm.py`) but are unrun. Also + unrun: fee collection against a position that has any, pool creation, and + `manage_amm(create_pool)`, which no test step exercises at all. The "verify" notes on the fixed issues above remain outstanding except where a section says otherwise. - **GW-12 through GW-16**, none of which is a wrong answer — they are ways a wrong request is accepted quietly, or a right one is described badly. **GW-17 is not in that From ed8a14784eae27d8f11cea61e1bbce90f9019ebd Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 21:06:05 -0700 Subject: [PATCH 31/65] docs(gateway): record GW-21; rename the meteora close step to drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking whether /trading/amm/{open,close} duplicate {add,remove} has two different answers. open is duplicative: add documents positionAddress as "omit to open a new position", meteora's addLiquidity delegates to openPosition when it is absent, and the two response schemas have identical properties. close is not: remove at 100% withdraws the liquidity and leaves the empty position NFT holding its rent, while close does both in one transaction — Gateway says so in closePosition.ts, and only the close response carries positionRentRefunded. The gap is downstream. gateway_client.py implements add and remove and neither of the others, so hummingbot-api cannot close an AMM position, and condor's manage_amm inherits that. The nearest thing is remove at 100%, which strands 0.0099 SOL on the position from GW-20 — against 0.0053 of liquidity — and reports nothing wrong. Renames the script's close step to drain, because that is what it does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- GATEWAY_ISSUES.md | 42 +++++++++++++++++++++++++++++ scripts_lp_test/test_meteora_amm.py | 11 ++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/GATEWAY_ISSUES.md b/GATEWAY_ISSUES.md index f08f99f93..cd7baadd3 100644 --- a/GATEWAY_ISSUES.md +++ b/GATEWAY_ISSUES.md @@ -29,6 +29,7 @@ changed every path these issues live on. | GW-18 | pancakeswap-sol's close reports fees and rent as a hardcoded 0 | **open** | | GW-19 | A hyphen in a token symbol makes its pair unquotable (hummingbot-api) | **open** | | GW-20 | A DAMM v2 open records the position rent as deposited liquidity | **open** | +| GW-21 | Nothing downstream can close an AMM position, so rent is stranded | **open** | GW-1 through GW-11 are code-complete and green: 1144 tests, 141 suites, clean typecheck, lint 0 errors, and `openapi.json` regenerates identical to the committed copy. @@ -768,6 +769,47 @@ does, so the value is recoverable for rows already written. fixed — gas is correctly excluded here, which is why the recorded figure matches the delta-minus-fee exactly. Rent was never part of it. +--- + +## GW-21 — nothing downstream can close an AMM position, so the rent is stranded +**Status: open. The gap is in hummingbot-api and condor; Gateway has the route.** +Found 2026-08-19 while deciding whether `/trading/amm/{open,close}` duplicate +`/trading/amm/{add,remove}`. + +They are not symmetric, and only half the question has an easy answer. + +**`open` is duplicative.** `add` declares `positionAddress` as "Omit to open a new +position", and Meteora's `addLiquidity` delegates to `openPosition` when it is absent. +`AmmOpenPositionResponseData` and `AmmAddLiquidityResponseData` have identical property +sets. `open.ts` only re-frames the fungible-LP case so it answers `positionRent: 0` with +no address, for a caller that does not want to know which kind of AMM it is on. Removing +it costs that normalization and nothing else. + +**`close` is not.** From `meteora/amm-routes/closePosition.ts:17`: + +> This is why close is not the same call as remove at 100%: removing all the liquidity +> leaves an empty position NFT behind, still holding its rent. The SDK's +> `removeAllLiquidityAndClosePosition` does both in one transaction. + +The schemas carry the same distinction: `AmmClosePositionResponseData` has +`positionRentRefunded`; `AmmRemoveLiquidityResponseData` has no rent field at all. + +**The gap.** `services/gateway_client.py` implements `amm_add_liquidity` and +`amm_remove_liquidity` and neither of the other two. So hummingbot-api opens through +`add` — which does return `positionRent`, and which it discards (GW-20) — and has no way +to close at all. condor's `manage_amm` inherits the gap: its actions are `add_liquidity` +and `remove_liquidity`, so the closest thing to a close is remove at 100%, which strands +the rent. On the position opened for GW-20 that is 0.0099 SOL, against 0.0053 of +liquidity. + +Nothing errors. The position drains to zero, reads as empty, and quietly keeps the rent. + +**Fix:** add `amm_close_position` to `services/gateway_client.py` against +`/trading/amm/close`, give `manage_amm` a `close` action, and record +`positionRentRefunded` the way the CLMM close path already does. Whether `open` also +gets wired is the cosmetic half — `add` without a position address already covers it. + + --- ## What is not done diff --git a/scripts_lp_test/test_meteora_amm.py b/scripts_lp_test/test_meteora_amm.py index 648df193f..296a61bcc 100644 --- a/scripts_lp_test/test_meteora_amm.py +++ b/scripts_lp_test/test_meteora_amm.py @@ -182,7 +182,14 @@ def steps(arg): "remove": lambda: manage_amm( action="remove_liquidity", connector="meteora", network=NET, pool_address=POOL, position_address=arg, percentage_to_remove="50"), - "close": lambda: manage_amm( + # NOT a close, despite the name being the obvious one to reach for. Gateway + # distinguishes the two: remove at 100% withdraws the liquidity and leaves the + # empty position NFT behind still holding its rent, while /trading/amm/close + # withdraws and closes the account in one transaction, refunding it. condor and + # hummingbot-api reach only remove — there is no amm_close_position in + # services/gateway_client.py — so running this strands the rent (~0.0099 SOL on + # the position this script opens). See GATEWAY_ISSUES.md GW-21. + "drain": lambda: manage_amm( action="remove_liquidity", connector="meteora", network=NET, pool_address=POOL, position_address=arg, percentage_to_remove="100"), @@ -193,7 +200,7 @@ def steps(arg): } -NEEDS_POSITION = ("add-more", "remove", "close") +NEEDS_POSITION = ("add-more", "remove", "drain") async def main(): From 4362d6a63c88d01c96935b859fa86a0ccac5b4de Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 21:21:33 -0700 Subject: [PATCH 32/65] docs(gateway): GW-21 fixed by collapsing the routes; restore the close step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway 74a1ee512 folds close into remove at 100% and drops /trading/amm/{open,close}, so the gap GW-21 described is gone rather than wired: a full removal closes the position account and refunds its rent, which hummingbot-api 6529688 now records. condor needed no change — manage_amm(remove_liquidity, percentage_to_remove=100) is the close, which is what it was already calling. Renames the script's drain step back to close, because it is one again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- GATEWAY_ISSUES.md | 29 +- .../routines/kalman_grid_operator.py | 374 +++++ .../routines/kalman_regime_check.py | 194 +++ .../strategies/kalman_grid_btc/strategy.md | 106 ++ .../gate-exchange-marketanalysis/CHANGELOG.md | 82 ++ .../gate-exchange-marketanalysis/README.md | 186 +++ .../gate-exchange-marketanalysis/SKILL.md | 172 +++ .../references/gate-cli.md | 95 ++ .../references/mcp.md | 83 ++ .../references/scenarios.md | 1247 ++++++++++++++++ .../gate-exchange-marketanalysis/setup.sh | 123 ++ .../gate_exchange_marketanalysis/SKILL.md | 75 + .../strategies/sndk_gate_hft_mm/strategy.md | 84 ++ .../xrpl_xrpusdc_mm_operator/strategy.md | 177 +++ .../routines/damm_v2_rate_limiter_scanner.py | 268 ++++ .../rate_limiter_lp_operator/strategy.md | 193 +++ routines/usdm_premium_monitor.py | 342 +++++ routines/usdm_report.py | 1256 +++++++++++++++++ scripts_lp_test/test_lp_write.py | 121 ++ scripts_lp_test/test_meteora_amm.py | 16 +- 20 files changed, 5207 insertions(+), 16 deletions(-) create mode 100644 agents/adaptive_grid_trader/routines/kalman_grid_operator.py create mode 100644 agents/adaptive_grid_trader/routines/kalman_regime_check.py create mode 100644 agents/adaptive_grid_trader/strategies/kalman_grid_btc/strategy.md create mode 100644 agents/market_making_expert/skills/gate-exchange-marketanalysis/CHANGELOG.md create mode 100644 agents/market_making_expert/skills/gate-exchange-marketanalysis/README.md create mode 100644 agents/market_making_expert/skills/gate-exchange-marketanalysis/SKILL.md create mode 100644 agents/market_making_expert/skills/gate-exchange-marketanalysis/references/gate-cli.md create mode 100644 agents/market_making_expert/skills/gate-exchange-marketanalysis/references/mcp.md create mode 100644 agents/market_making_expert/skills/gate-exchange-marketanalysis/references/scenarios.md create mode 100755 agents/market_making_expert/skills/gate-exchange-marketanalysis/setup.sh create mode 100644 agents/market_making_expert/skills/gate_exchange_marketanalysis/SKILL.md create mode 100644 agents/market_making_expert/strategies/sndk_gate_hft_mm/strategy.md create mode 100644 agents/market_making_expert/strategies/xrpl_xrpusdc_mm_operator/strategy.md create mode 100644 agents/meteora_launch_lp/routines/damm_v2_rate_limiter_scanner.py create mode 100644 agents/meteora_launch_lp/strategies/rate_limiter_lp_operator/strategy.md create mode 100644 routines/usdm_premium_monitor.py create mode 100644 routines/usdm_report.py create mode 100644 scripts_lp_test/test_lp_write.py diff --git a/GATEWAY_ISSUES.md b/GATEWAY_ISSUES.md index cd7baadd3..9f3b52939 100644 --- a/GATEWAY_ISSUES.md +++ b/GATEWAY_ISSUES.md @@ -29,7 +29,7 @@ changed every path these issues live on. | GW-18 | pancakeswap-sol's close reports fees and rent as a hardcoded 0 | **open** | | GW-19 | A hyphen in a token symbol makes its pair unquotable (hummingbot-api) | **open** | | GW-20 | A DAMM v2 open records the position rent as deposited liquidity | **open** | -| GW-21 | Nothing downstream can close an AMM position, so rent is stranded | **open** | +| GW-21 | Nothing downstream can close an AMM position, so rent is stranded | fixed (routes collapsed) | GW-1 through GW-11 are code-complete and green: 1144 tests, 141 suites, clean typecheck, lint 0 errors, and `openapi.json` regenerates identical to the committed copy. @@ -772,8 +772,8 @@ delta-minus-fee exactly. Rent was never part of it. --- ## GW-21 — nothing downstream can close an AMM position, so the rent is stranded -**Status: open. The gap is in hummingbot-api and condor; Gateway has the route.** -Found 2026-08-19 while deciding whether `/trading/amm/{open,close}` duplicate +**Status: fixed, by removing the choice rather than wiring the second route.** Found +2026-08-19 while deciding whether `/trading/amm/{open,close}` duplicate `/trading/amm/{add,remove}`. They are not symmetric, and only half the question has an easy answer. @@ -804,10 +804,25 @@ liquidity. Nothing errors. The position drains to zero, reads as empty, and quietly keeps the rent. -**Fix:** add `amm_close_position` to `services/gateway_client.py` against -`/trading/amm/close`, give `manage_amm` a `close` action, and record -`positionRentRefunded` the way the CLMM close path already does. Whether `open` also -gets wired is the cosmetic half — `add` without a position address already covers it. +**Fixed** in gateway `74a1ee512` and hummingbot-api `6529688`, by collapsing the four +routes to two rather than teaching two more callers about the other pair: + +- `meteora/amm-routes/removeLiquidity.ts` delegates to `closePosition` at 100%, so a full + removal closes the account and the rent comes back without the caller knowing to ask. +- `/trading/amm/open` and `/trading/amm/close` are gone. `app.integration.test.ts` asserts + they are *not* registered, so re-adding one is a decision rather than a drift back. +- `positionRentRefunded` joins the remove response as optional — present when the removal + closed the account, absent on a partial removal and on fungible-LP AMMs, rather than a 0 + that would read as "closed, refunded nothing". +- hummingbot-api's `close_position` records it, as the CLMM path already did. + +condor needs no change: `manage_amm(remove_liquidity, percentage_to_remove=100)` is now +the close, which is what it was already calling. + +**Verify:** the position opened for GW-20, `F1YcTMd6…`, is still open and still holds its +rent. Removing 100% of it against a container built from `74a1ee512` should refund +~0.0099 SOL and record it — a check GW-20's own fix does not cover, since that one is +about the open side. --- diff --git a/agents/adaptive_grid_trader/routines/kalman_grid_operator.py b/agents/adaptive_grid_trader/routines/kalman_grid_operator.py new file mode 100644 index 000000000..8f0edc220 --- /dev/null +++ b/agents/adaptive_grid_trader/routines/kalman_grid_operator.py @@ -0,0 +1,374 @@ + +import asyncio +import time +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes +from config_manager import get_client +from condor.reports import LiveReport +import numpy as np +import logging + +logger = logging.getLogger(__name__) + +CONTINUOUS = True +CATEGORY = "Analysis" + + +class Config(BaseModel): + """Kalman adaptive grid operator — 1m regime check every minute, tunes grid on bitget_perpetual.""" + connector_name: str = Field(default="bitget_perpetual", description="Exchange connector") + trading_pair: str = Field(default="BTC-USDT", description="Trading pair") + budget_usdt: float = Field(default=100.0, description="Total USDT budget") + reserve_pct: float = Field(default=0.10, description="Fraction held back (never traded)") + max_leverage: int = Field(default=5, description="Max leverage") + max_loss_pct: float = Field(default=0.10, description="Max loss per grid as fraction of budget") + min_order_size: float = Field(default=5.0, description="Min order size in USDT") + interval_sec: int = Field(default=60, description="Tick interval in seconds") + lookback_candles: int = Field(default=60, description="1m candles (60 = last 1 hour)") + q_level: float = Field(default=100.0, description="Kalman process noise: level variance") + q_slope: float = Field(default=1.0, description="Kalman process noise: slope variance") + r_obs: float = Field(default=40.0, description="Kalman observation noise variance") + snr_trend_threshold: float = Field(default=0.3, description="SNR >= this → deploy directional grid") + target_grid_hours: float = Field(default=8.0, description="Target grid lifetime (hours) for D sizing") + retune_d_pct: float = Field(default=0.20, description="Retune grid if Kalman D shifts by this fraction") + min_grid_age_min: int = Field(default=180, description="Min grid age (min) before directional flip") + profit_take_pct: float = Field(default=0.02, description="Take profit at this fraction of budget ($2 on $100)") + stale_ticks: int = Field(default=10, description="Recycle grid if fills stagnant for this many ticks") + + +# ── Kalman filter ───────────────────────────────────────────────────────────── + +def _kalman_filter(prices, q_level, q_slope, r_obs): + """Local linear trend Kalman filter. Returns (slopes, innovations).""" + F = np.array([[1.0, 1.0], [0.0, 1.0]]) + H = np.array([[1.0, 0.0]]) + Q = np.diag([q_level, q_slope]) + R = np.array([[r_obs]]) + x = np.array([prices[0], 0.0]) + P = np.eye(2) * 1e6 + slopes, innovations = [], [] + for price in prices: + x_pred = F @ x + P_pred = F @ P @ F.T + Q + inn = price - float((H @ x_pred)[0]) + S = float((H @ P_pred @ H.T + R)[0, 0]) + K = (P_pred @ H.T / S).flatten() + x = x_pred + K * inn + P = (np.eye(2) - np.outer(K, H)) @ P_pred + slopes.append(float(x[1])) + innovations.append(float(inn)) + return np.array(slopes), np.array(innovations) + + +def _get_signal(slopes, innovations, prices, config): + """Return (regime, profile, snr, slope_pct, sigma_inn, grid_D, current_price).""" + n = len(prices) + window = min(24, max(n // 6, 4)) + recent_slope = slopes[-1] + current_price = float(prices[-1]) + rms_inn = float(np.sqrt(np.mean(innovations[-window:] ** 2))) + sigma_inn = max(rms_inn, 1.0) + slope_pct = recent_slope / current_price * 100 + snr = abs(recent_slope) / sigma_inn + # D scales so grid width is the same regardless of candle timeframe (1m candles here) + grid_D = sigma_inn * float(np.sqrt(config.target_grid_hours * 60)) + if n < 30: + return "UNCERTAIN", "HOLD", snr, slope_pct, sigma_inn, grid_D, current_price + if snr >= config.snr_trend_threshold: + regime = "TRENDING_UP" if recent_slope > 0 else "TRENDING_DOWN" + profile = "LONG" if recent_slope > 0 else "SHORT" + else: + regime = "RANGING/UNCERTAIN" + profile = "HOLD" + return regime, profile, snr, slope_pct, sigma_inn, grid_D, current_price + + +# ── Grid config builder ─────────────────────────────────────────────────────── + +def _grid_config(direction, price, grid_D, config): + trade_budget = config.budget_usdt * (1 - config.reserve_pct) + if direction == "LONG": + start = round(price - grid_D, 1) + end = round(price + 3 * grid_D, 1) + limit = round(price - 1.5 * grid_D, 1) + side = "BUY" + else: + start = round(price - 3 * grid_D, 1) + end = round(price + grid_D, 1) + limit = round(price + 1.5 * grid_D, 1) + side = "SELL" + n_levels = max(2, min(20, int(trade_budget / config.min_order_size))) + return { + "type": "grid_executor", + "timestamp": time.time(), + "connector_name": config.connector_name, + "trading_pair": config.trading_pair, + "start_price": start, + "end_price": end, + "limit_price": limit, + "total_amount_quote": trade_budget, + "n_levels": n_levels, + "min_spread_between_orders": 0.001, + "min_order_amount_quote": config.min_order_size, + "leverage": config.max_leverage, + "side": side, + "time_limit": 86400, + "keep_position": False, + "triple_barrier_config": { + "take_profit": 0.003, + "stop_loss": 0.02, + "stop_loss_order_type": "MARKET", + }, + } + + +# ── Executor helpers ────────────────────────────────────────────────────────── + +async def _teardown(client, executor_id, log, ts): + try: + await client.executors.stop_executor(executor_id) + log.append(f"{ts} TEARDOWN {executor_id[:8]} OK") + except Exception as e: + log.append(f"{ts} WARN stop failed: {e}") + + +async def _deploy(client, direction, price, grid_D, config, log, ts): + gcfg = _grid_config(direction, price, grid_D, config) + try: + result = await client.executors.create_executor(gcfg) + eid = str(result.get("id") or result.get("executor_id", "")) + log.append( + f"{ts} DEPLOY {direction} {eid[:8]} " + f"${gcfg['start_price']:.0f}–${gcfg['end_price']:.0f} " + f"lim=${gcfg['limit_price']:.0f} D=${grid_D:.0f}" + ) + return eid, grid_D + except Exception as e: + log.append(f"{ts} ERROR deploy: {e}") + return None, None + + +# ── Main loop ───────────────────────────────────────────────────────────────── + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + chat_id = context._chat_id + await context.bot.send_message(chat_id=chat_id, text="Kalman Grid Operator started") + + # Persistent state + executor_id = None + direction = None + grid_D_deployed = None + deployed_at = None + pnl_history = [] + fills_history = [] + tick_count = 0 + log = [] + + report = LiveReport( + "Kalman Grid Operator", + source_name="kalman_grid_operator", + tags=["trading", "kalman", "adaptive-grid-trader", "live"], + auto_refresh_seconds=60, + ) + + try: + while True: + try: + tick_count += 1 + ts = time.strftime("%H:%M:%S") + client = await get_client(chat_id, context=context) + if not client: + await asyncio.sleep(config.interval_sec) + continue + + # ── 1. Kalman signal ────────────────────────────────────── + raw = await client.market_data.get_candles( + config.connector_name, config.trading_pair, + interval="1m", max_records=config.lookback_candles, + ) + records = raw if isinstance(raw, list) else raw.get("data", raw.get("candles", [])) + closes = [] + for r in (records or []): + try: + closes.append(float(r["close"])) + except (KeyError, TypeError, ValueError): + continue + + if len(closes) < 20: + log.append(f"{ts} SKIP: only {len(closes)} candles") + await asyncio.sleep(config.interval_sec) + continue + + prices = np.array(closes) + slopes, innovations = _kalman_filter( + prices, config.q_level, config.q_slope, config.r_obs + ) + regime, profile, snr, slope_pct, sigma_inn, grid_D, current_price = _get_signal( + slopes, innovations, prices, config + ) + + # ── 2. Executor state & action decision ─────────────────── + pnl = 0.0 + fills = 0.0 + grid_age_min = 0 + action = "keep" + action_reason = f"snr={snr:.2f}" + new_direction = direction + + if executor_id is not None: + execs = await client.executors.search_executors( + controller_ids=[], status="active", limit=50 + ) + our_exec = next( + (e for e in (execs or []) + if str(e.get("id") or e.get("executor_id", "")) == executor_id), + None, + ) + + if our_exec is None: + # Grid ended on its own — reset and redeploy + log.append(f"{ts} Grid {executor_id[:8]} gone") + executor_id = grid_D_deployed = deployed_at = None + pnl_history.clear() + fills_history.clear() + new_direction = profile if profile in ("LONG", "SHORT") else direction + action = "deploy" + action_reason = "grid_died" + else: + pnl = float(our_exec.get("net_pnl_quote", 0)) + fills = float(our_exec.get("filled_amount_quote", 0)) + grid_age_min = int((time.time() - deployed_at) / 60) if deployed_at else 0 + pnl_history.append(pnl) + fills_history.append(fills) + if len(pnl_history) > 20: + pnl_history.pop(0) + if len(fills_history) > config.stale_ticks + 2: + fills_history.pop(0) + + profit_target = config.budget_usdt * config.profit_take_pct + + # Priority: stale → profit → D-drift → flip → keep + if (len(fills_history) >= config.stale_ticks and + len(set(round(f, 2) for f in fills_history[-config.stale_ticks:])) == 1): + action = "retune" + action_reason = f"stale {config.stale_ticks}t" + new_direction = direction # same direction, fresh range + + elif pnl >= profit_target: + action = "retune" + action_reason = f"profit_take ${pnl:.2f}" + new_direction = profile if profile in ("LONG", "SHORT") else None + + elif (grid_D_deployed and + abs(grid_D - grid_D_deployed) / grid_D_deployed > config.retune_d_pct): + d_shift = abs(grid_D - grid_D_deployed) / grid_D_deployed + action = "retune" + action_reason = f"D_drift {d_shift:.0%}" + new_direction = direction # same direction, updated range + + elif (profile not in ("HOLD", direction) and + profile in ("LONG", "SHORT") and + grid_age_min >= config.min_grid_age_min): + action = "flip" + action_reason = f"→{profile} age={grid_age_min}m" + new_direction = profile + + else: + action = "keep" + action_reason = f"regime={regime} snr={snr:.2f}" + + else: + # No grid running + if profile in ("LONG", "SHORT"): + action = "deploy" + action_reason = f"regime={regime} snr={snr:.2f}" + new_direction = profile + else: + action = "hold" + action_reason = f"regime={regime} snr={snr:.2f}" + + # ── 3. Execute ──────────────────────────────────────────── + if action in ("retune", "flip"): + await _teardown(client, executor_id, log, ts) + executor_id = grid_D_deployed = deployed_at = None + pnl = fills = 0.0 + pnl_history.clear() + fills_history.clear() + direction = new_direction + if direction in ("LONG", "SHORT"): + action = "deploy" + else: + action = "hold" + + if action == "deploy" and new_direction in ("LONG", "SHORT"): + direction = new_direction + trade_budget = config.budget_usdt * (1 - config.reserve_pct) + if trade_budget < config.min_order_size * 2: + log.append(f"{ts} HOLD: budget too small") + action = "hold" + else: + eid, D_dep = await _deploy( + client, direction, current_price, grid_D, config, log, ts + ) + if eid: + executor_id = eid + grid_D_deployed = D_dep + deployed_at = time.time() + elif action == "deploy": + action = "hold" + direction = None + log.append(f"{ts} HOLD: no signal for redeploy") + + # ── 4. Log & LiveReport ─────────────────────────────────── + log.append( + f"{ts} [{action.upper()}] {regime} SNR={snr:.2f} " + f"D=${grid_D:.0f} pnl=${pnl:+.2f} fills=${fills:.2f}" + ) + if len(log) > 60: + log[:] = log[-60:] + + report.clear() + report.builder.manual_order() + + report.builder.section("01 / KALMAN SIGNAL", "1m Kalman regime — updated every tick") + report.builder.kpi("Regime", regime) + report.builder.kpi("Profile", profile) + report.builder.kpi("Price", f"${current_price:,.2f}") + report.builder.kpi("Slope", f"{slope_pct:+.5f}%/m") + report.builder.kpi("σ Innovation", f"${sigma_inn:.2f}") + report.builder.kpi("SNR", f"{snr:.3f}") + report.builder.kpi("Grid D", f"${grid_D:.2f}") + + report.builder.section("02 / GRID STATE") + report.builder.kpi("Status", "RUNNING" if executor_id else "FLAT") + report.builder.kpi("Direction", direction or "—") + report.builder.kpi("Age", f"{grid_age_min}m" if executor_id else "—") + report.builder.kpi("Unrealized PnL", f"${pnl:+.2f}" if executor_id else "—") + report.builder.kpi("Filled", f"${fills:.2f}" if executor_id else "—") + report.builder.kpi("D Deployed", f"${grid_D_deployed:.2f}" if grid_D_deployed else "—") + report.builder.kpi("Action", f"{action} ({action_reason})") + report.builder.kpi("Tick #", str(tick_count)) + + report.builder.section("03 / LOG") + report.builder.markdown("\n".join(f"`{l}`" for l in log[-20:])) + + await report.update() + + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Tick {tick_count} error: {e}") + + await asyncio.sleep(config.interval_sec) + + except asyncio.CancelledError: + if report.report_id is not None: + report.clear() + report.builder.auto_refresh(None) + report.builder.section("STOPPED", f"Final snapshot — {tick_count} ticks") + report.builder.kpi("Total Ticks", str(tick_count)) + report.builder.kpi("Direction", direction or "—") + report.builder.kpi("Grid", executor_id[:8] if executor_id else "FLAT") + report.builder.markdown("\n".join(f"`{l}`" for l in log[-20:])) + await report.update() + return f"Kalman Grid Operator stopped after {tick_count} ticks" diff --git a/agents/adaptive_grid_trader/routines/kalman_regime_check.py b/agents/adaptive_grid_trader/routines/kalman_regime_check.py new file mode 100644 index 000000000..543fd2388 --- /dev/null +++ b/agents/adaptive_grid_trader/routines/kalman_regime_check.py @@ -0,0 +1,194 @@ + +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes +from config_manager import get_client +from condor.reports import ReportBuilder +import numpy as np +import plotly.graph_objects as go +from plotly.subplots import make_subplots +import logging +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + +CATEGORY = "Analysis" + + +def _candle_minutes(interval: str) -> float: + """Parse '1m'→1, '1h'→60, '4h'→240, '1d'→1440.""" + if interval.endswith("m"): + return float(interval[:-1]) + elif interval.endswith("h"): + return float(interval[:-1]) * 60 + elif interval.endswith("d"): + return float(interval[:-1]) * 1440 + return 1.0 + + +class Config(BaseModel): + """Kalman filter regime detection — outputs regime signal and Kalman-sized grid params.""" + connector_name: str = Field(default="bitget_perpetual", description="Exchange connector") + trading_pair: str = Field(default="BTC-USDT", description="Trading pair") + candle_interval: str = Field(default="1m", description="Candle interval (1m, 5m, 1h, etc.)") + lookback_candles: int = Field(default=60, description="Candles to fetch (60 × 1m = last 1 hour)") + q_level: float = Field(default=100.0, description="Process noise: level variance") + q_slope: float = Field(default=1.0, description="Process noise: slope variance") + r_obs: float = Field(default=40.0, description="Observation noise variance") + snr_trend_threshold: float = Field(default=0.3, description="SNR >= this → TRENDING") + snr_range_threshold: float = Field(default=0.1, description="SNR <= this → RANGING") + target_grid_hours: float = Field(default=8.0, description="Target grid lifetime (hours) for D sizing") + + +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" + + result = await client.market_data.get_candles( + config.connector_name, config.trading_pair, + interval=config.candle_interval, max_records=config.lookback_candles + ) + records = result if isinstance(result, list) else result.get("data", result.get("candles", [])) + if not records or len(records) < 20: + return f"Insufficient candle data: got {len(records) if records else 0} records" + + closes, timestamps = [], [] + for r in records: + try: + closes.append(float(r["close"])) + ts = r.get("timestamp", r.get("time", 0)) + if isinstance(ts, (int, float)) and ts > 0: + t = ts / 1000 if ts > 1e10 else ts + timestamps.append(datetime.fromtimestamp(t, tz=timezone.utc).strftime("%m-%d %H:%M")) + else: + timestamps.append(str(ts)) + except (KeyError, TypeError, ValueError): + continue + + if len(closes) < 20: + return f"Could not parse enough closes: got {len(closes)}" + + prices = np.array(closes) + n = len(prices) + x_axis = timestamps[:n] if len(timestamps) == n else list(range(n)) + + # Local Linear Trend Kalman Filter + F = np.array([[1.0, 1.0], [0.0, 1.0]]) + H = np.array([[1.0, 0.0]]) + Q = np.diag([config.q_level, config.q_slope]) + R = np.array([[config.r_obs]]) + x = np.array([prices[0], 0.0]) + P = np.eye(2) * 1e6 + + levels, slopes, innovations = [], [], [] + for price in prices: + x_pred = F @ x + P_pred = F @ P @ F.T + Q + inn = price - float((H @ x_pred)[0]) + S = float((H @ P_pred @ H.T + R)[0, 0]) + K = (P_pred @ H.T / S).flatten() + x = x_pred + K * inn + P = (np.eye(2) - np.outer(K, H)) @ P_pred + levels.append(float(x[0])) + slopes.append(float(x[1])) + innovations.append(float(inn)) + + levels = np.array(levels) + slopes = np.array(slopes) + innovations = np.array(innovations) + + window = min(24, max(n // 6, 4)) + recent_slope = slopes[-1] + recent_price = prices[-1] + rms_inn = float(np.sqrt(np.mean(innovations[-window:] ** 2))) + sigma_inn = max(rms_inn, 1.0) + slope_pct = recent_slope / recent_price * 100 + snr = abs(recent_slope) / sigma_inn + + # D: scale by candle duration so grid width is timeframe-agnostic + candle_min = _candle_minutes(config.candle_interval) + grid_D = sigma_inn * float(np.sqrt(config.target_grid_hours * 60 / candle_min)) + + if n < 30: + regime, profile = "UNCERTAIN", "HOLD" + elif snr >= config.snr_trend_threshold: + regime = "TRENDING_UP" if recent_slope > 0 else "TRENDING_DOWN" + profile = "LONG" if recent_slope > 0 else "SHORT" + elif snr <= config.snr_range_threshold: + regime, profile = "RANGING", "TWO_SIDED" + else: + regime, profile = "UNCERTAIN", "HOLD" + + if regime == "TRENDING_UP": + g_start = recent_price - grid_D + g_end = recent_price + 3 * grid_D + g_limit = recent_price - 1.5 * grid_D + elif regime == "TRENDING_DOWN": + g_start = recent_price - 3 * grid_D + g_end = recent_price + grid_D + g_limit = recent_price + 1.5 * grid_D + else: + g_start = recent_price - 1.5 * grid_D + g_end = recent_price + 1.5 * grid_D + g_limit = None + + fig = make_subplots( + rows=3, cols=1, shared_xaxes=True, + subplot_titles=[ + f"Price vs Kalman Level — {config.trading_pair}", + "Kalman Slope ($/candle)", + "Innovation ($)" + ], + row_heights=[0.5, 0.25, 0.25], + vertical_spacing=0.07, + ) + fig.add_trace(go.Scatter(x=x_axis, y=prices.tolist(), name="Close", + line=dict(color="#94a3b8", width=1)), row=1, col=1) + fig.add_trace(go.Scatter(x=x_axis, y=levels.tolist(), name="Kalman Level", + line=dict(color="#60a5fa", width=2)), row=1, col=1) + slope_colors = ["#22c55e" if s > 0 else "#ef4444" for s in slopes] + fig.add_trace(go.Bar(x=x_axis, y=slopes.tolist(), name="Slope", + marker_color=slope_colors, showlegend=False), row=2, col=1) + fig.add_hline(y=0, line_dash="dash", line_color="gray", line_width=1, row=2, col=1) + inn_colors = ["#f97316" if abs(i) > 2 * sigma_inn else "#64748b" for i in innovations] + fig.add_trace(go.Bar(x=x_axis, y=innovations.tolist(), name="Innovation", + marker_color=inn_colors, showlegend=False), row=3, col=1) + fig.add_hline(y=sigma_inn, line_dash="dot", line_color="#22c55e", line_width=1, row=3, col=1) + fig.add_hline(y=-sigma_inn, line_dash="dot", line_color="#22c55e", line_width=1, row=3, col=1) + fig.update_layout( + height=700, template="plotly_dark", + title=f"Kalman Regime: {regime} | SNR={snr:.3f} | {config.candle_interval} × {n}", + legend=dict(orientation="h", yanchor="top", y=-0.15, xanchor="center", x=0.5), + ) + + builder = ReportBuilder("Kalman Regime Check") + builder.source("routine", "kalman_regime_check") + builder.tags(["analysis", "regime", "kalman", "adaptive-grid-trader"]) + + builder.section("01 / REGIME SIGNAL", "Kalman filter output and profile recommendation") + builder.kpi("Regime", regime) + builder.kpi("Profile", profile) + builder.kpi("Current Price", f"${recent_price:,.2f}") + builder.kpi("Kalman Slope", f"{slope_pct:+.5f}%/candle") + builder.kpi("Innovation RMS", f"${sigma_inn:,.2f}") + builder.kpi("SNR", f"{snr:.3f}") + builder.kpi("Candles Used", str(n)) + + builder.section("02 / GRID SIZING", "Kalman-derived grid parameters") + builder.kpi("Grid D", f"${grid_D:,.2f}") + builder.kpi("Grid Start", f"${g_start:,.2f}") + builder.kpi("Grid End", f"${g_end:,.2f}") + builder.kpi("Limit Price", f"${g_limit:,.2f}" if g_limit else "N/A (ranging/uncertain)") + + builder.section("03 / CHARTS", "Price, slope, and innovation over lookback window") + builder.plotly(fig) + + builder.manual_order() + await builder.save() + + return ( + f"Regime: {regime} → {profile}\n" + f"Price ${recent_price:,.2f} | Slope {slope_pct:+.5f}%/candle | SNR {snr:.3f}\n" + f"Grid D ${grid_D:,.2f} | Range ${g_start:,.2f}–${g_end:,.2f}" + + (f" | Limit ${g_limit:,.2f}" if g_limit else "") + ) diff --git a/agents/adaptive_grid_trader/strategies/kalman_grid_btc/strategy.md b/agents/adaptive_grid_trader/strategies/kalman_grid_btc/strategy.md new file mode 100644 index 000000000..47122026a --- /dev/null +++ b/agents/adaptive_grid_trader/strategies/kalman_grid_btc/strategy.md @@ -0,0 +1,106 @@ +--- +name: kalman_grid_btc +description: '' +agent_key: null +skills: [] +default_config: {} +default_trading_context: '' +created_by: 456181693 +created_at: '2026-08-13T03:14:12.657657+00:00' +--- + + +# Kalman Grid Strategy — BTC-USDT / bitget_perpetual + +## Envelope (fixed — never ask per tick) +- connector: bitget_perpetual +- pair: BTC-USDT +- budget: 100 USDT | reserve: 10% | trade_budget: $90 +- max_leverage: 5x | max_loss_pct: 10% ($10 max per grid) +- min_order_size: 5 USDT +- allowed_profiles: LONG, SHORT only (no TWO_SIDED) + +## Every tick + +### 1 — Kalman signal +``` +manage_routines(action="run", name="kalman_regime_check", config={ + "connector_name": "bitget_perpetual", + "trading_pair": "BTC-USDT", + "candle_interval": "1m", + "lookback_candles": 60 +}) +``` +Extract from result: regime, profile, snr, grid_D, grid_start, grid_end, limit_price. + +### 2 — Executor state +``` +manage_executors(action="list_executors") +``` +Find any active grid_executor for BTC-USDT on bitget_perpetual. Note: executor_id, net_pnl_quote, filled_amount_quote, created_timestamp. + +### 3 — Decide (first match wins) + +**No executor running:** +- profile LONG or SHORT → DEPLOY +- else → HOLD + +**Executor running:** +1. fills_amount_quote unchanged 10+ ticks → RETUNE (teardown + redeploy same direction) +2. net_pnl_quote ≥ $2.00 → PROFIT_TAKE (teardown + redeploy if signal still directional) +3. current grid_D differs >20% from deployed D → RETUNE (teardown + redeploy updated range) +4. profile flipped AND executor age ≥ 180 min → FLIP (teardown + deploy new direction) +5. else → KEEP + +### 4 — Act + +**DEPLOY grid:** +``` +manage_executors(action="create_executor", executor_config={ + "type": "grid_executor", + "connector_name": "bitget_perpetual", + "trading_pair": "BTC-USDT", + "start_price": , + "end_price": , + "limit_price": , + "total_amount_quote": 90, + "n_levels": 9, + "min_spread_between_orders": 0.001, + "min_order_amount_quote": 5, + "leverage": 5, + "side": "BUY" if LONG else "SELL", + "time_limit": 86400, + "keep_position": false, + "triple_barrier_config": { + "take_profit": 0.003, + "stop_loss": 0.02, + "stop_loss_order_type": "MARKET" + } +}) +``` + +**STOP grid:** +``` +manage_executors(action="stop_executor", executor_id=) +``` +Always verify flat (net position = 0) before redeploying. + +### 5 — Journal every tick +``` +trading_agent_journal_write(entries=[ + {"key": "regime", "value": }, + {"key": "snr", "value": }, + {"key": "grid_D", "value": }, + {"key": "profile", "value": }, + {"key": "action", "value": }, + {"key": "pnl", "value": }, + {"key": "fills", "value": } +]) +``` + +## Safety — abort and alert if: +- Available balance < $90 before deploy +- Worst-case loss at limit_price > $10 +- Liquidation price inside limit_price at 5x +- Position cannot be verified flat after teardown + diff --git a/agents/market_making_expert/skills/gate-exchange-marketanalysis/CHANGELOG.md b/agents/market_making_expert/skills/gate-exchange-marketanalysis/CHANGELOG.md new file mode 100644 index 000000000..bfa8e15da --- /dev/null +++ b/agents/market_making_expert/skills/gate-exchange-marketanalysis/CHANGELOG.md @@ -0,0 +1,82 @@ +# Changelog + +All notable changes to the gate-exchange-marketanalysis skill are documented here. + +Format: date-based versioning (`YYYY.M.DD`). Same-day releases may use a sequential suffix: `YYYY.M.DD-1`, `YYYY.M.DD-2`, etc. + +--- + +## [2026.3.11-1] - 2026-03-11 + +### Changed + +- **`gate-cli` command names** — Aligned with new gate-cli interface naming: + - Spot: `get_spot_*` → `cex_spot_get_spot_*` (e.g. `get_spot_order_book` → `gate-cli cex spot market orderbook`, `get_spot_tickers` → `gate-cli cex spot market tickers`, `get_spot_candlesticks` → `gate-cli cex spot market candlesticks`, `get_spot_trades` → `gate-cli cex spot market trades`). + - Futures: `get_futures_*` / `list_futures_*` → `cex_fx_*` (e.g. `get_futures_contract` → `gate-cli cex futures market contract`, `get_futures_order_book` → `gate-cli cex futures market orderbook`, `get_futures_tickers` → `gate-cli cex futures market tickers`, `get_futures_candlesticks` → `gate-cli cex futures market candlesticks`, `get_futures_trades` → `gate-cli cex futures market trades`, `get_futures_funding_rate` → `gate-cli cex futures market funding-rate`, `list_futures_liq_orders` → `gate-cli cex futures market liquidations`, `get_futures_premium_index` → `gate-cli cex futures market premium`). + - Updated in `SKILL.md`, `references/scenarios.md`, `README.md`, and `CHANGELOG.md`. + +--- + +## [2026.3.7-1] - 2026-03-07 + +### Scope + +- Case 8 adds **slippage simulation**: market-order fill vs order book, slippage reported as deviation from best ask (points and %). Spot and futures supported. **Requires both currency pair and quote amount** from the user; no defaults — when either is missing, prompt the user instead of running the simulation. +- Case 9 adds **K-line breakout / support–resistance** analysis from candlesticks and tickers. +- Case 10 adds **liquidity + weekend vs weekday** comparison using order book, 90d candlesticks, and tickers. + +### Added + +- **Case 8: Slippage simulation** — market-order slippage vs best ask + - Trigger: e.g. "slippage simulation", "market buy $10K, how much slippage?", "ADA_USDT slippage simulation" + - **Spot:** `gate-cli cex spot market orderbook` → `gate-cli cex spot market tickers`; **Futures:** `gate-cli cex futures market contract` → `gate-cli cex futures market orderbook` → `gate-cli cex futures market tickers` + - Logic: walk ask ladder for quote amount Q; volume-weighted avg price; slippage = avg price − ask1 (points and %) + - Output: simulation inputs, fill summary, slippage vs best ask, conclusion +- Scenario 8.1: spot slippage simulation (e.g. ADA_USDT / ETH market buy $10K) +- Scenario 8.2: futures slippage simulation (perpetual/contract market long) +- Scenario 8.3: missing pair or amount — prompt user (do not call `gate-cli`; ask for pair and/or quote amount; do not default to $10K) +- **Case 9: K-line breakout / support–resistance** — analyze breakout and support/resistance from recent K-line + - Trigger: e.g. "Based on recent K-line, does SOL/USDT show signs of breaking out upward? Analyze support and resistance." + - **Spot:** `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers`; **Futures:** `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers` + - Logic: historical candlesticks for support/resistance; 24h price, volume, change for momentum and breakout signs + - Output: K-line context, support & resistance table, momentum (24h), breakout assessment +- **Case 10: Liquidity + weekend vs weekday** — evaluate liquidity and compare weekend vs weekday + - Trigger: e.g. "Evaluate ETH liquidity on the exchange and compare weekend vs weekday." + - **Spot:** `gate-cli cex spot market orderbook` → `gate-cli cex spot market candlesticks`(90d) → `gate-cli cex spot market tickers`; **Futures:** `gate-cli cex futures market contract` → `gate-cli cex futures market orderbook` → `gate-cli cex futures market candlesticks`(90d) → `gate-cli cex futures market tickers` + - Logic: order book for current depth; 90d candlesticks split by weekend vs weekday for volume and return; compare and summarize + - Output: current liquidity, 90-day weekend vs weekday table, comparison, conclusion + +### Changed + +- **`gate-cli` command names** — Corrected to match gate-cli: `list_futures_funding_rate` → `get_futures_funding_rate`; `list_futures_premium_index` → `get_futures_premium_index`. Updated in `references/scenarios.md` and README.md. (`list_futures_liq_orders` is correct per `gate-cli`.) +- **Pure English** — all trigger phrases and report templates in SKILL.md and `references/scenarios.md` are in English. +- **Versioning** — version and `updated` follow current date (`YYYY.M.DD`). +- **Case 8 required inputs** — currency pair and quote amount both required; if either missing, prompt user (no default pair, no default amount e.g. $10K). SKILL.md Execution step 3 and Domain Knowledge (Case 8) updated accordingly. + +### Audit + +- Case 8 uses gate-cli only (`gate-cli cex spot market orderbook` / `gate-cli cex futures market orderbook`, `gate-cli cex spot market tickers` / `gate-cli cex futures market tickers`). +- No `gate-cli` calls when pair or amount is missing; user is prompted first. +- All analysis is read-only; no trading operations. + +--- + +## [2026.3.5-1] - 2026-03-05 + +### Scope + +This skill supports **market tape analysis only** (read-only): liquidity, momentum, liquidation, funding arbitrage, basis, manipulation risk, order book explainer. No trading operations. + +### Added +- Initial release (market tape analysis, seven scenarios) +- Routing-based SKILL.md with document loading from `references/scenarios.md` +- **Seven analysis modules:** Liquidity, Momentum, Liquidation monitoring, Funding arbitrage, Basis, Manipulation risk, Order book explainer +- Smart spot/futures market detection (perpetual/contract keywords) +- `gate-cli` call order and Report Template defined in `references/scenarios.md` +- Domain knowledge and safety rules + +### Audit + +- Uses gate-cli tools only; all analysis is read-only +- No trading operations or credential handling in this skill + diff --git a/agents/market_making_expert/skills/gate-exchange-marketanalysis/README.md b/agents/market_making_expert/skills/gate-exchange-marketanalysis/README.md new file mode 100644 index 000000000..2e5fe2a57 --- /dev/null +++ b/agents/market_making_expert/skills/gate-exchange-marketanalysis/README.md @@ -0,0 +1,186 @@ +# Gate Exchange MarketAnalysis + +## Overview + +An AI Agent skill that provides market tape analysis on [Gate](https://www.gate.com), covering ten scenarios: liquidity, momentum, liquidation monitoring, funding rate arbitrage, basis (spot–futures) monitoring, manipulation risk, order book explanation, slippage simulation, K-line breakout/support–resistance, and liquidity with weekend vs weekday. All scenarios use a defined **`gate-cli` call order and output format** in `references/scenarios.md`. + +--- + +### Core Capabilities + +| Capability | Description | Example | +|------------|-------------|---------| +| **Liquidity analysis** | Order book depth, 24h vs 30d volume, slippage | "How is ETH liquidity?" | +| **Momentum** | Buy vs sell share, funding rate | "Is BTC more long or short in 24h?" | +| **Liquidation monitoring** | 1h liq vs baseline, squeeze, wicks | "Recent liquidations?" | +| **Funding arbitrage** | Rate + volume, spot–futures spread | "Any arbitrage opportunities?" | +| **Basis monitoring** | Spot–futures price, premium index | "What is the basis for BTC?" | +| **Manipulation risk** | Depth/volume ratio, large orders | "Is this coin easy to manipulate?" | +| **Order book explainer** | Bids/asks, spread, depth | "Explain the order book" | +| **Slippage simulation** | Market-order slippage vs best ask (pair + quote amount required) | "ADA_USDT slippage for $10K market buy?" | +| **K-line breakout / support–resistance** | Candlesticks + tickers; support/resistance; breakout momentum | "Does SOL/USDT show breakout signs? Analyze support and resistance." | +| **Liquidity + weekend vs weekday** | Order book + 90d candlesticks + tickers; weekend vs weekday volume/return | "Evaluate ETH liquidity and compare weekend vs weekday." | + +> 📊 **Ten scenarios (Case 1–10):** Ask about liquidity, momentum, liquidation, arbitrage, basis, manipulation risk, order book, slippage simulation, K-line breakout/support–resistance, or liquidity vs weekend/weekday; the skill routes to the right case and follows `references/scenarios.md`. + +--- + +## Architecture + +``` +Natural Language Input + ↓ +Intent Routing (Case 1–10, spot vs futures) + ↓ +gate-cli commands + ├── `gate-cli cex spot market orderbook` / `gate-cli cex futures market orderbook` + ├── `gate-cli cex spot market tickers` / `gate-cli cex futures market tickers` + ├── `gate-cli cex spot market candlesticks` / `gate-cli cex futures market candlesticks` + ├── `gate-cli cex spot market trades` / `gate-cli cex futures market funding-rate` + ├── `gate-cli cex futures market liquidations` + └── `gate-cli cex futures market premium` + ↓ +Analysis & Judgment Logic + ↓ +Structured Report → Natural language response +``` + +**Sub-Modules:** `references/scenarios.md` — `gate-cli` call order, parameters, required fields, and report templates per case. + +--- + +## Agent Use Cases + +### 1. Liquidity check +> "How is ETH liquidity?" + +Depth levels, 24h vs 30d volume, slippage; liquidity rating. For perpetual/contract, use futures order book and candlesticks/tickers. + +### 2. Momentum (buy vs sell) +> "Is BTC more long or short in 24h, and is it sustainable?" + +Trades → buy/sell share; tickers, candlesticks, order book top 10, funding rate for bias and sustainability. + +### 3. Liquidation monitoring +> "Recent liquidations?" + +Liquidation orders (if `gate-cli` provides), candlesticks, tickers; anomaly and squeeze labels. + +### 4. Funding arbitrage scan +> "Any arbitrage opportunities?" + +Screen by |rate| and volume; spot tickers and order book; exclude thin books. + +### 5. Basis (spot–futures) +> "What is the basis for BTC?" + +Spot and futures tickers, premium index; current vs history, widening/narrowing. + +### 6. Manipulation risk +> "Is this coin easy to manipulate?" + +Depth ratio (top 10 / 24h volume); large and consecutive same-side trades. + +### 7. Order book explainer +> "Explain the order book" + +Live order book (e.g. limit=10) + ticker; explain bids/asks, spread, depth. + +### 8. Slippage simulation +> "ADA_USDT slippage for a $10K market buy?" + +Requires pair and quote amount. Spot or futures: order book → tickers (futures: `gate-cli cex futures market contract` first for quanto_multiplier). Walk ask ladder; report slippage vs best ask. + +### 9. K-line breakout / support–resistance +> "Does SOL/USDT show signs of breaking out? Analyze support and resistance." + +Candlesticks → tickers; derive support/resistance from OHLC; use 24h price and volume for momentum and breakout assessment. + +### 10. Liquidity + weekend vs weekday +> "Evaluate ETH liquidity and compare weekend vs weekday." + +Order book + 90d candlesticks + tickers (futures: `gate-cli cex futures market contract` first). Split days into weekend vs weekday; compare volume and return. + +--- + +## Quick Start + +### Prerequisites + +1. `gate-cli` installed and configured (`gate-cli config init` or `GATE_API_KEY` / `GATE_API_SECRET`). Install via `sh ./setup.sh` from this skill directory if needed. +2. No extra dependencies. + +### Example Prompts + +``` +# Liquidity +"How is ETH liquidity?" +"BTC perpetual depth" + +# Momentum +"BTC 24h more long or short, sustainable?" + +# Liquidation +"Recent liquidations?" + +# Arbitrage +"Any funding rate arbitrage opportunities?" + +# Basis +"What is the basis for ETH?" + +# Manipulation +"Is PEPE easy to manipulate?" + +# Order book +"Explain the order book with an example" + +# Slippage simulation (pair + amount required) +"ADA_USDT contract slippage: if I market buy $20K, how much slippage?" + +# K-line breakout / support–resistance +"Based on recent K-line, does SOL/USDT show breakout? Analyze support and resistance." + +# Liquidity + weekend vs weekday +"Evaluate ETH contract liquidity and compare weekend vs weekday." +``` + +See `references/scenarios.md` for full `gate-cli` call order and report templates. + +--- + +## File Structure + +``` +gate-exchange-marketanalysis/ +├── README.md # This file +├── SKILL.md # Skill routing and instructions +├── CHANGELOG.md # Version history +└── references/ + ├── scenarios.md # `gate-cli` call order, judgment logic, report templates per case + └── case-test-report.md # Optional: simulation test summary +``` + +--- + +## Security + +- No external scripts or executable code +- Uses gate-cli tools only — no direct API calls +- No credential handling in chat — configure **`gate-cli`** on the host (`gate-cli config init` or `GATE_API_KEY` / `GATE_API_SECRET`) for authenticated market reads where required +- Read-only market data analysis, no trading operations +- No file system writes +- No data collection, telemetry, or analytics + +## Authentication + +Users should configure their Gate API key in the gate-cli settings (see [gate-cli](https://github.com/gate/gate-cli) for setup instructions). All `gate-cli` commands used by this skill access public market data and do **not** require authentication. + +## Source + +- **Repository**: [github.com/gate/gate-skills](https://github.com/gate/gate-skills) +- **Publisher**: [Gate.com](https://www.gate.com) + +## License + +MIT diff --git a/agents/market_making_expert/skills/gate-exchange-marketanalysis/SKILL.md b/agents/market_making_expert/skills/gate-exchange-marketanalysis/SKILL.md new file mode 100644 index 000000000..9ddf3cba7 --- /dev/null +++ b/agents/market_making_expert/skills/gate-exchange-marketanalysis/SKILL.md @@ -0,0 +1,172 @@ +--- +name: gate-exchange-marketanalysis +description: "Gate Exchange market analysis tool. Use when the user asks for deep market metrics like liquidity, slippage, funding arbitrage, or manipulation risk. Triggers on 'liquidity', 'depth', 'slippage', 'momentum', 'buy/sell pressure', 'squeeze', 'funding rate', 'arbitrage', 'basis', 'premium'." +user-invocable: true +disable-model-invocation: false +metadata: + openclaw: + emoji: "💱" + os: + - darwin + - linux + primaryEnv: GATE_API_KEY + requires: + bins: + - gate-cli + env: + - GATE_API_KEY + - GATE_API_SECRET + + install: + - kind: download + os: + - linux + url: "https://github.com/gate/gate-cli/releases/download/v0.6.2/gate-cli_0.6.2_linux_amd64.tar.gz" + bins: + - gate-cli + targetDir: "bin" + label: "Download gate-cli (Linux x64)" + - kind: download + os: + - linux + url: "https://github.com/gate/gate-cli/releases/download/v0.6.2/gate-cli_0.6.2_linux_arm64.tar.gz" + bins: + - gate-cli + targetDir: "bin" + label: "Download gate-cli (Linux arm64)" + - kind: download + os: + - darwin + url: "https://github.com/gate/gate-cli/releases/download/v0.6.2/gate-cli_0.6.2_darwin_amd64.tar.gz" + bins: + - gate-cli + targetDir: "bin" + label: "Download gate-cli (macOS Intel)" + - kind: download + os: + - darwin + url: "https://github.com/gate/gate-cli/releases/download/v0.6.2/gate-cli_0.6.2_darwin_arm64.tar.gz" + bins: + - gate-cli + targetDir: "bin" + label: "Download gate-cli (macOS Apple Silicon)" +--- + +### Resolving `gate-cli` (binary path) + +Resolve **`gate-cli`** in order: **(1)** **`command -v gate-cli`** and **`gate-cli --version`** succeeds; **(2)** **`${HOME}/.local/bin/gate-cli`** if executable; **(3)** **`${HOME}/.openclaw/skills/bin/gate-cli`** if executable. Canonical rules: [`exchange-runtime-rules.md`](https://github.com/gate/gate-skills/blob/master/skills/exchange-runtime-rules.md) §4 (or [`gate-runtime-rules.md`](https://github.com/gate/gate-skills/blob/master/skills/gate-runtime-rules.md) §4). + + +# gate-exchange-marketanalysis + +## General Rules + +⚠️ STOP — You MUST read and strictly follow the shared runtime rules before proceeding. +Do NOT select or call any tool until all rules are read. These rules have the highest priority. +→ Read [gate-runtime-rules.md](https://github.com/gate/gate-skills/blob/master/skills/gate-runtime-rules.md) +- **Only use the `gate-cli` commands explicitly listed in this skill.** Commands not documented here must NOT be run for these workflows, even if other interfaces expose them. + +Market tape analysis covering thirteen scenarios, such as liquidity, momentum, liquidation monitoring, funding arbitrage, basis monitoring, manipulation risk, order book explanation, slippage simulation, K-line breakout/support–resistance, and liquidity with weekend vs weekday. This skill provides structured market insights by orchestrating Gate MCP tools; call order and judgment logic are defined in `references/scenarios.md`. + +--- + +## Skill Dependencies + + +### Authentication +- **Interactive file setup:** when **`GATE_API_KEY`** and **`GATE_API_SECRET`** are **not** both set on the host, run **`gate-cli config init`** to complete the wizard for API key, secret, profiles, and defaults (see [gate-cli](https://github.com/gate/gate-cli)). +- **Env / flags:** **`gate-cli config init`** is **not** required when credentials are already supplied — e.g. **both** **`GATE_API_KEY`** and **`GATE_API_SECRET`** set on the host, or **`--api-key`** / **`--api-secret`** where supported — never ask the user to paste secrets into chat. +- API Key Required: Not necessarily +- Note: This skill is read-only and primarily uses public market-data surfaces. In many runtimes these calls work without authentication, though some deployments may still route them through an authenticated MCP layer. + +### Installation Check +- **Required:** `gate-cli` (run `sh ./setup.sh` from this skill directory if missing; optional `GATE_CLI_SETUP_MODE=release`). +- Add `$HOME/.openclaw/skills/bin` to **`PATH`** if you invoke `gate-cli` by name (or the directory where [`setup.sh`](./setup.sh) installs it). +- **Credentials:** When **`GATE_API_KEY`** and **`GATE_API_SECRET`** are both set (non-empty) for the host, **do not** require **`gate-cli config init`** for `gate-cli`-backed auth. When **both** are unset or empty and the deployment still expects keys, **remind** the operator to run **`gate-cli config init`** **or** to configure **`GATE_API_KEY`** / **`GATE_API_SECRET`** in the **matching skill** from the skill library (never ask the user to paste secrets into chat). +- **Sanity check:** Confirm the CLI works (e.g. **`gate-cli --version`** or a read-only **`gate-cli cex ...`** market call from this skill) before depending on deeper tool chains. + +## Execution mode + +**Read and strictly follow** [`references/gate-cli.md`](./references/gate-cli.md), then execute this skill's market analysis workflow. + +- `SKILL.md` keeps intent routing, scenario mapping, and output semantics. +- `references/gate-cli.md` is the authoritative `gate-cli` execution contract for tool sequencing, parameter checks, and degradation rules. + +## Sub-Modules + +| Module | Purpose | Document | +|--------|---------|----------| +| **Liquidity** | Order book depth, 24h vs 30d volume, slippage | `references/scenarios.md` (Case 1) | +| **Momentum** | Buy vs sell share, funding rate | `references/scenarios.md` (Case 2) | +| **Liquidation** | 1h liq vs baseline, squeeze, wicks | `references/scenarios.md` (Case 3) | +| **Funding arbitrage** | Rate + volume screen, spot–futures spread | `references/scenarios.md` (Case 4) | +| **Basis** | Spot–futures price, premium index | `references/scenarios.md` (Case 5) | +| **Manipulation risk** | Depth/volume ratio, large orders | `references/scenarios.md` (Case 6) | +| **Order book explainer** | Bids/asks, spread, depth | `references/scenarios.md` (Case 7) | +| **Slippage simulation** | Market-order slippage vs best ask | `references/scenarios.md` (Case 8) | +| **K-line breakout / support–resistance** | Candlesticks + tickers; support/resistance; breakout momentum | `references/scenarios.md` (Case 9) | +| **Liquidity + weekend vs weekday** | Order book + 90d candlesticks + tickers; weekend vs weekday volume/return | `references/scenarios.md` (Case 10) | +| **Technical analysis / what to do** | Short + long timeframe K-line, support/resistance, momentum (price vs volume), funding rate; spot + futures; separate short/long-term advice | `references/scenarios.md` (Case 11) | +| **Multi-asset buy & allocation** | Per-asset ticker + order book + 7d daily candles; futures add funding rate; allocation % and rationale | `references/scenarios.md` (Case 12) | +| **Portfolio allocation review** | Same data as Case 12; assess if allocation is reasonable, adjustment advice, what else to buy if no change | `references/scenarios.md` (Case 13) | + +--- + +## Routing Rules + +Determine which module (case) to run based on user intent: + +| User Intent | Keywords | Action | +|-------------|----------|--------| +| Liquidity / depth | liquidity, depth, slippage | Read Case 1, follow MCP order (use futures APIs if perpetual/contract) | +| Momentum | buy vs sell, momentum | Read Case 2, follow MCP order | +| Liquidation | liquidation, squeeze | Read Case 3 (futures only) | +| Funding arbitrage | arbitrage, funding rate | Read Case 4 | +| Basis | basis, premium | Read Case 5 | +| Manipulation risk | manipulation, depth vs volume | Read Case 6 (spot or futures per keywords) | +| Order book explainer | order book, spread | Read Case 7 | +| Slippage simulation | slippage simulation, market buy $X slippage, how much slippage | Read Case 8 (spot or futures per keywords) | +| K-line breakout / support–resistance | breakout, support, resistance, K-line, candlestick | Read Case 9 (spot or futures per keywords) | +| Liquidity + weekend vs weekday | liquidity, weekend, weekday, weekend vs weekday | Read Case 10 (spot or futures per keywords) | +| Technical analysis / what to do | technical analysis, what to do with BTC, long or short, trading advice, current level | Read Case 11 (spot + futures, short & long timeframes) | +| Multi-asset buy & allocation | watchlist, want to buy, analyze several coins, investment advice, how to allocate budget | Read Case 12 | +| Portfolio allocation review | portfolio, allocation, is my allocation reasonable, how to adjust, what else to buy | Read Case 13 | + +--- + +## Execution + +1. **Match user intent** to the routing table above and determine case (1–13) and market type (spot/futures). +2. **Read** the corresponding case in `references/scenarios.md` for MCP call order and required fields. +3. **Case 8 only:** If the user did **not** specify a **currency pair** or did **not** specify a **quote amount** (e.g. $10K), do not assume defaults — **prompt the user** to provide the missing input(s); see Scenario 8.3 in `references/scenarios.md`. +4. **Call Gate MCP** in the exact order defined for that case. +5. **Apply judgment logic** from scenarios (thresholds, flags, ratings). +6. **Output the report** using that case’s Report Template. +7. **Suggest related actions** (e.g. “For basis, ask ‘What is the basis for XXX?’”). +--- + +## Domain Knowledge (short) + +- **Spot vs futures:** Keywords “perpetual”, “contract”, “future”, “perp” → use futures MCP APIs; “spot” or unspecified → spot. +- **Liquidity (Case 1):** Depth < 10 levels → low liquidity; 24h volume < 30-day avg → cold pair; slippage = 2×(ask1−bid1)/(bid1+ask1) > 0.5% → high slippage risk. +- **Momentum (Case 2):** Buy share > 70% → buy-side strong; 24h volume > 30-day avg → active; funding rate sign + order book top 10 for bias. +- **Liquidation (Case 3):** 1h liq > 3× daily avg → anomaly; one-sided liq > 80% → long/short squeeze; price recovered → wick/spike. +- **Arbitrage (Case 4):** |rate| > 0.05% and 24h vol > $10M → candidate; spot–futures spread > 0.2% → bonus; thin depth → exclude. +- **Basis (Case 5):** Current basis vs history; basis widening/narrowing for sentiment. +- **Manipulation (Case 6):** Top-10 depth total / 24h volume < 0.5% → thin depth; consecutive same-direction large orders → possible manipulation. Use spot by default; use futures when user says perpetual/contract. +- **Order book (Case 7):** Show bids/asks example, explain spread with last price, depth and volatility. +- **Slippage simulation (Case 8):** **Requires both a currency pair and a quote amount** (e.g. ETH_USDT, $10K). If user does not specify either, prompt them — do not assume defaults (e.g. do not default to $10K). Spot: `gate-cli cex spot market orderbook` → `gate-cli cex spot market tickers`. Futures: `gate-cli cex futures market contract` → `gate-cli cex futures market orderbook` → `gate-cli cex futures market tickers`. Simulate market buy by walking ask ladder; slippage = volume-weighted avg price − ask1 (points and %). +- **K-line breakout / support–resistance (Case 9):** Trigger: e.g. “breakout, support, resistance”, “K-line”, “does X show signs of breaking out?”. Spot: `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers`. Futures: `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers`. Use candlesticks for support/resistance levels; use tickers for 24h price, volume, change (momentum). +- **Liquidity + weekend vs weekday (Case 10):** Trigger: e.g. “liquidity”, “weekend vs weekday”, “compare weekend and weekday”. Spot: `gate-cli cex spot market orderbook` → `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers`. Futures: `gate-cli cex futures market contract` → `gate-cli cex futures market orderbook` → `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers`. Order book for current depth; 90d candlesticks to split weekend vs weekday volume and return; compare and summarize. +- **Technical analysis / what to do (Case 11):** Trigger: e.g. "technical analysis, what should I do with BTC", "long or short at current level". Spot: `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers`. Futures: `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers` → `gate-cli cex futures market funding-rate`. Use history for support/resistance; compare current price and 24h volume to past for momentum; funding rate for long/short bias; give separate short- and long-term advice. +- **Multi-asset buy & allocation (Case 12):** Trigger: e.g. "I'm watching BTC, ETH, GT and want to buy; analyze and give allocation for $5000". Per asset: `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers` → `gate-cli cex spot market orderbook`; futures: `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers` → `gate-cli cex futures market orderbook` → `gate-cli cex futures market funding-rate`. Spot ticker + order book + 7d daily; add funding for futures; output allocation % and rationale. +- **Portfolio allocation review (Case 13):** Trigger: e.g. "I hold 30% BTC, 30% ETH, 20% DOGE, 15% LTC, 5% USDT; is this allocation reasonable, how to adjust, what else to buy?". Same MCP order as Case 12 (per-asset spot candlesticks + tickers + order_book; futures + funding_rate). Assess allocation, suggest adjustments, or suggest what else to buy if no change. + +--- + +## Important Notes + +- All analysis is read-only — no trading operations are performed. +- Gate MCP must be configured (use `gate-mcp-installer` skill if needed). +- MCP call order and output format are in `references/scenarios.md`; follow them for consistent behavior. +- Always include a disclaimer: analysis is data-based, not investment advice. diff --git a/agents/market_making_expert/skills/gate-exchange-marketanalysis/references/gate-cli.md b/agents/market_making_expert/skills/gate-exchange-marketanalysis/references/gate-cli.md new file mode 100644 index 000000000..1949d7377 --- /dev/null +++ b/agents/market_making_expert/skills/gate-exchange-marketanalysis/references/gate-cli.md @@ -0,0 +1,95 @@ +--- +name: gate-exchange-marketanalysis-gate-cli +version: "2026.3.30-1" +updated: "2026-03-30" +description: "gate-cli execution specification for market analysis scenarios including liquidity, momentum, liquidation, basis, and slippage simulation." +--- + +# Gate Market Analysis MCP Specification + +## 1. Scope and Trigger Boundaries + +In scope: +- Read-only market analysis for spot/futures +- Scenario-based analysis in `references/scenarios.md` (Cases 1-13) + +Out of scope: +- Any order placement, cancellation, leverage, transfer, or fund movement + +Misroute examples: +- If user asks to execute trades, route to execution skills (spot/futures/copilot). + +## 2. `gate-cli` detection and Fallback + +Detection: +1. Verify read-only market data tool families are available. +2. Probe with the smallest required endpoint for selected market type. + +Fallback: +- If futures data endpoints are unavailable, provide spot-only analysis and disclose limitation. +- If both spot/futures endpoints are unavailable, return framework-level reasoning only and mark as data-unavailable. + +## 2.1 `gate-cli cex …` execution flow (MUST) + +For every documented **`gate-cli cex …`** leaf command, **strictly** follow this order: + +1. **Preflight with `--help`:** Run the same command with **`--help`** immediately after the full `cex …` subcommand path (before any other flags), e.g. `gate-cli cex spot account get --help`, to see whether the CLI marks any flags or arguments as **required**. +2. **If `--help` lists required fields** (e.g. `--currency`): obtain values (ask the user only for non-secret business inputs such as symbol or amount; never ask for API secrets in chat), then run the **real** invocation **without** `--help`, including every required flag, e.g. `gate-cli cex spot account get --currency BTC`. +3. **If `--help` shows no required fields** for that subcommand: you may run the bare **`gate-cli cex …`** (only add optional flags the task still needs for correct semantics). + +**Example:** To run `gate-cli cex spot account get` — first run `gate-cli cex spot account get --help`. If help indicates `--currency` is mandatory, supply it (e.g. `--currency BTC`), then execute `gate-cli cex spot account get --currency BTC`. If nothing is required beyond auth, execute `gate-cli cex spot account get` as documented. + +If `--help` is ambiguous, prefer a safe read-only probe or explicit user clarification—especially before writes. + +## 3. Authentication + +- Public market-data endpoints may work without private account auth. +- If runtime policy requires API key, request valid key before analysis. + +## 4. Optional resources + +No mandatory auxiliary resources. + +## 5. `gate-cli` command specification + +Primary read-only tool families used by scenarios: +- Spot market data: `gate-cli cex spot market orderbook`, `gate-cli cex spot market tickers`, `gate-cli cex spot market candlesticks` +- Futures market data: `gate-cli cex futures market contract`, `gate-cli cex futures market orderbook`, `gate-cli cex futures market tickers`, `gate-cli cex futures market candlesticks`, `gate-cli cex futures market funding-rate` + +Parameter rules: +- Always require explicit `currency_pair` / `contract` target. +- Case 8 slippage simulation requires both symbol and quote amount; do not auto-default. +- Candlestick calls must include timeframe aligned with the chosen scenario. + +Common errors: +- Symbol not found / invalid market type: ask user to confirm symbol. +- Empty order book or stale feed: return insufficient-liquidity/data warning. + +## 6. Execution SOP (Non-Skippable) + +1. Route user intent to one scenario case (1-13). +2. Validate market type (spot or futures) and target symbol. +3. Collect required scenario inputs (especially Case 8 amount gate). +4. Execute tool sequence exactly in scenario order. +5. Apply scenario thresholds/rules to produce GO/CAUTION/BLOCK-like assessment language. +6. Return structured report with explicit data confidence. + +## 7. Output Templates + +```markdown +## Market Analysis Summary +- Scenario: {case_id_and_name} +- Target: {symbol_and_market} +- Key Signals: {liquidity_momentum_basis_liquidation_etc} +- Risk Flags: {high_slippage_thin_depth_event_risk} +- Conclusion: {bullish_bearish_neutral_with_conditions} +- Disclaimer: Data-driven analysis only, not investment advice. +``` + +## 8. Safety and Degradation Rules + +1. Keep this skill strictly read-only. +2. Do not output fabricated prices, volumes, depth, or funding values. +3. When data is missing, degrade to partial analysis and label it clearly. +4. Do not infer execution recommendations as guaranteed outcomes. +5. Keep scenario-specific required inputs as hard gates (no hidden defaults for Case 8 amount/symbol). diff --git a/agents/market_making_expert/skills/gate-exchange-marketanalysis/references/mcp.md b/agents/market_making_expert/skills/gate-exchange-marketanalysis/references/mcp.md new file mode 100644 index 000000000..0c9f06a34 --- /dev/null +++ b/agents/market_making_expert/skills/gate-exchange-marketanalysis/references/mcp.md @@ -0,0 +1,83 @@ +--- +name: gate-exchange-marketanalysis-mcp +version: "2026.3.30-1" +updated: "2026-03-30" +description: "MCP execution specification for market analysis scenarios including liquidity, momentum, liquidation, basis, and slippage simulation." +--- + +# Gate Market Analysis MCP Specification + +## 1. Scope and Trigger Boundaries + +In scope: +- Read-only market analysis for spot/futures +- Scenario-based analysis in `references/scenarios.md` (Cases 1-13) + +Out of scope: +- Any order placement, cancellation, leverage, transfer, or fund movement + +Misroute examples: +- If user asks to execute trades, route to execution skills (spot/futures/copilot). + +## 2. MCP Detection and Fallback + +Detection: +1. Verify read-only market data tool families are available. +2. Probe with the smallest required endpoint for selected market type. + +Fallback: +- If futures data endpoints are unavailable, provide spot-only analysis and disclose limitation. +- If both spot/futures endpoints are unavailable, return framework-level reasoning only and mark as data-unavailable. + +## 3. Authentication + +- Public market-data endpoints may work without private account auth. +- If runtime policy requires API key, request valid key before analysis. + +## 4. MCP Resources + +No mandatory MCP resources. + +## 5. Tool Calling Specification + +Primary read-only tool families used by scenarios: +- Spot market data: `cex_spot_get_spot_order_book`, `cex_spot_get_spot_tickers`, `cex_spot_get_spot_candlesticks` +- Futures market data: `cex_fx_get_fx_contract`, `cex_fx_get_fx_order_book`, `cex_fx_get_fx_tickers`, `cex_fx_get_fx_candlesticks`, `cex_fx_get_fx_funding_rate` + +Parameter rules: +- Always require explicit `currency_pair` / `contract` target. +- Case 8 slippage simulation requires both symbol and quote amount; do not auto-default. +- Candlestick calls must include timeframe aligned with the chosen scenario. + +Common errors: +- Symbol not found / invalid market type: ask user to confirm symbol. +- Empty order book or stale feed: return insufficient-liquidity/data warning. + +## 6. Execution SOP (Non-Skippable) + +1. Route user intent to one scenario case (1-13). +2. Validate market type (spot or futures) and target symbol. +3. Collect required scenario inputs (especially Case 8 amount gate). +4. Execute tool sequence exactly in scenario order. +5. Apply scenario thresholds/rules to produce GO/CAUTION/BLOCK-like assessment language. +6. Return structured report with explicit data confidence. + +## 7. Output Templates + +```markdown +## Market Analysis Summary +- Scenario: {case_id_and_name} +- Target: {symbol_and_market} +- Key Signals: {liquidity_momentum_basis_liquidation_etc} +- Risk Flags: {high_slippage_thin_depth_event_risk} +- Conclusion: {bullish_bearish_neutral_with_conditions} +- Disclaimer: Data-driven analysis only, not investment advice. +``` + +## 8. Safety and Degradation Rules + +1. Keep this skill strictly read-only. +2. Do not output fabricated prices, volumes, depth, or funding values. +3. When data is missing, degrade to partial analysis and label it clearly. +4. Do not infer execution recommendations as guaranteed outcomes. +5. Keep scenario-specific required inputs as hard gates (no hidden defaults for Case 8 amount/symbol). diff --git a/agents/market_making_expert/skills/gate-exchange-marketanalysis/references/scenarios.md b/agents/market_making_expert/skills/gate-exchange-marketanalysis/references/scenarios.md new file mode 100644 index 000000000..9402ff150 --- /dev/null +++ b/agents/market_making_expert/skills/gate-exchange-marketanalysis/references/scenarios.md @@ -0,0 +1,1247 @@ +# gate-exchange-marketanalysis — Scenarios & MCP Call Specs + +This document defines the **MCP call order, parameters, required fields, and output format** for each scenario. Implementations must call Gate MCP in the order specified under each Case and produce reports according to the templates below. + +**MCP tool names (Gate MCP):** Spot market data use `gate-cli cex spot market orderbook`, `gate-cli cex spot market candlesticks`, `gate-cli cex spot market tickers`, `gate-cli cex spot market trades`. Futures market data use `gate-cli cex futures market contract`, `gate-cli cex futures market orderbook`, `gate-cli cex futures market candlesticks`, `gate-cli cex futures market tickers`, `gate-cli cex futures market trades`. Futures funding/liquidation/premium use `gate-cli cex futures market funding-rate`, `gate-cli cex futures market liquidations`, `gate-cli cex futures market premium`. Call these exact tool names when invoking Gate MCP. + +| Case | Scenario | Core MCP Call Order | +|------|----------|---------------------| +| 1 | Liquidity analysis | `gate-cli cex spot market orderbook` → `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers` | +| 2 | Momentum (buy vs sell) | `gate-cli cex spot market trades` → `gate-cli cex spot market tickers` → `gate-cli cex spot market candlesticks` → `gate-cli cex spot market orderbook` → `gate-cli cex futures market funding-rate` | +| 3 | Liquidation monitoring | `gate-cli cex futures market liquidations` → `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers` | +| 4 | Funding rate arbitrage | `gate-cli cex futures market tickers` → `gate-cli cex futures market funding-rate` → `gate-cli cex spot market tickers` → `gate-cli cex spot market orderbook` | +| 5 | Basis (spot vs futures) | `gate-cli cex spot market tickers` → `gate-cli cex futures market tickers` → `gate-cli cex futures market premium` | +| 6 | Manipulation risk | Spot: `gate-cli cex spot market orderbook` → `gate-cli cex spot market tickers` → `gate-cli cex spot market trades`. When user says perpetual/contract: `gate-cli cex futures market orderbook` → `gate-cli cex futures market tickers` → `gate-cli cex futures market trades` | +| 7 | Order book explainer | `gate-cli cex spot market orderbook` → `gate-cli cex spot market tickers` | +| 8 | Slippage simulation | Spot: `gate-cli cex spot market orderbook` → `gate-cli cex spot market tickers`. Futures: `gate-cli cex futures market contract` → `gate-cli cex futures market orderbook` → `gate-cli cex futures market tickers` | +| 9 | K-line breakout / support–resistance | `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers`; `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers` | +| 10 | Liquidity + weekend vs weekday | `gate-cli cex spot market orderbook` → `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers`; `gate-cli cex futures market contract` → `gate-cli cex futures market orderbook` → `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers` | +| 11 | Technical analysis / what to do (short + long timeframe, support/resistance, momentum, funding) | Spot: `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers`. Futures: `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers` → `gate-cli cex futures market funding-rate` | +| 12 | Multi-asset buy analysis & allocation | Per asset: `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers` → `gate-cli cex spot market orderbook`; futures: `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers` → `gate-cli cex futures market orderbook` → `gate-cli cex futures market funding-rate` | +| 13 | Portfolio allocation review & adjustment | Same as Case 12: spot ticker + order book + 7d daily; futures add funding rate | + +--- + +## Case 1: Liquidity Analysis + +### MCP Call Spec (document-aligned) + +For liquidity analysis, **call Gate MCP in this order** and extract the listed fields; output must follow the Report Template below. + +| Step | MCP Tool | Parameters | Required Fields | +|------|----------|------------|----------------| +| 1 | `gate-cli cex spot market orderbook` (spot) | `currency_pair={BASE}_USDT`, `limit=20` | Number of ask/bid levels; top 10 bid/ask depth totals; bid1/ask1 (for spread and slippage) | +| 2 | `gate-cli cex spot market candlesticks` (spot) | `currency_pair={BASE}_USDT`, `interval=1d`, `limit=30` | Last 30 days volume (for 30d avg); latest candle for 24h volume reference | +| 3 | `gate-cli cex spot market tickers` (spot) | `currency_pair={BASE}_USDT` | `last`; `quoteVolume` 24h (USDT); `changePercentage` 24h; `high24h`/`low24h` | +| 4 (optional) | `gate-cli cex spot market trades` (spot) | `currency_pair={BASE}_USDT`, `limit=100` | Recent trade size distribution for "recent flow" and participation | + +**Calculation & judgment** (aligned with SKILL): + +- **API choice**: Use futures APIs (e.g. `gate-cli cex futures market orderbook`) when user says "perpetual" or "contract"; otherwise spot. +- **Slippage** = `2×(ask1−bid1)/(bid1+ask1)×100%`; if > 0.5% → flag "high slippage risk". +- **Depth**: asks/bids depth < 10 levels → flag "low liquidity". +- **24h volume** < 30-day volume average → flag "cold pair". +- **Liquidity rating**: Combine above into 1–5 ⭐. + +**Output**: Must include a "Core metrics" table (order book depth, 24h volume, 30d avg volume, bid-ask spread, slippage + status), "Assessment" (liquidity rating x/5 ⭐), and short "Recommendation". + +--- + +### Scenario 1.1: Spot liquidity query + +**Context**: User wants to know ETH spot trading conditions. + +**Prompt examples**: +- "How is ETH liquidity?" + +**Expected behavior**: +1. Call in order per **MCP Call Spec**: `gate-cli cex spot market orderbook` → `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers` (optional `gate-cli cex spot market trades`). +2. From order book: level count, top 10 depth, bid1/ask1. +3. From candlesticks: 30d avg volume, 24h volume. +4. From tickers: last, 24h quote volume, change. +5. Compute slippage; apply document logic for status and rating. +6. Output core metrics table + assessment + recommendation per Report Template. + +**Output**: +```markdown +## ETH Liquidity Analysis + +### Core metrics + +| Metric | Value | Status | +|--------|-------|--------| +| Order book depth | 20 levels | OK | +| 24h volume | $485M | Active | +| 30d avg volume | $320M | - | +| Bid-ask spread | 0.02% | Excellent | +| Slippage risk | 0.03% | Very low | + +### Assessment + +**Liquidity rating**: 5/5 ⭐ + +ETH liquidity is excellent, suitable for large size. +``` + +--- + +### Scenario 1.2: Futures liquidity query + +**Context**: User asks about perpetual/contract depth. + +**Prompt examples**: +- "How is BTC perpetual depth?" + +**Expected behavior**: +1. Detect "perpetual/contract" and use **futures** MCP: `gate-cli cex futures market orderbook` (`settle=usdt`, `contract=BTC_USDT`, `limit=20`) → optional `gate-cli cex futures market tickers`, `gate-cli cex futures market candlesticks`(1d, 30). +2. Extract level count, top 10 depth, bid1/ask1; compute slippage. +3. Output core metrics table + liquidity rating per liquidity criteria. + +**Output**: +```markdown +## BTC_USDT Perpetual — Liquidity Analysis + +| Metric | Value | Status | +|--------|-------|--------| +| Order book depth | 50 levels | Excellent | +| Slippage risk | 0.01% | Very low | + +Liquidity rating: 5/5 ⭐ +``` + +--- + +### Scenario 1.3: Low-liquidity / cold pair warning + +**Context**: User queries a low-cap or illiquid pair. + +**Prompt examples**: +- "How is XYZ liquidity?" + +**Expected behavior**: +1. Still follow **Case 1 MCP Call Spec**: `gate-cli cex spot market orderbook` → `gate-cli cex spot market candlesticks` → `gate-cli cex spot market tickers`. +2. If depth < 10 levels, or 24h volume < 30d avg, or slippage > 0.5%, mark 🔴 in core metrics and output risk note + low liquidity rating. + +**Output**: +```markdown +## XYZ Liquidity Analysis + +### Risk notice + +| Metric | Value | Status | +|--------|-------|--------| +| Order book depth | 5 levels | Insufficient depth | +| 24h volume | $15K | Cold pair | +| Slippage risk | 2.3% | High | + +**Liquidity rating**: 1/5 ⭐ + +⚠️ This pair has poor liquidity; large orders will incur significant slippage. +``` + +--- + +## Case 2: Momentum (buy vs sell) + +### MCP Call Spec (document-aligned) + +**Trigger**: "Is BTC more long or short in 24h, and is it sustainable?" For momentum analysis, **call in this order**; use futures APIs when user asks about contract. + +| Step | MCP Tool | Parameters | Required Fields | +|------|----------|------------|----------------| +| 1 | `gate-cli cex spot market trades` (spot) / `gate-cli cex futures market trades` (futures) | `currency_pair` or `contract`+`settle`, `limit=1000` | Buy/sell volume; buy share = buy_volume / total_volume | +| 2 | `gate-cli cex spot market tickers` (spot) / `gate-cli cex futures market tickers` (futures) | Same pair | 24h volume, 24h change | +| 3 | `gate-cli cex spot market candlesticks` (spot) / `gate-cli cex futures market candlesticks` (futures) | `interval=1d`, `limit=30` | 30-day average volume | +| 4 | `gate-cli cex spot market orderbook` (spot) / `gate-cli cex futures market orderbook` (futures) | `limit=20` | Top 10 bid/ask depth for long/short balance | +| 5 | `gate-cli cex futures market funding-rate` or equivalent | When contract | Funding rate; positive → long bias, negative → short bias | + +**Calculation & judgment** (aligned with SKILL): + +- **Buy share > 70%** → "buy-side strong"; sell share > 70% → "sell-side strong". +- **24h volume > 30d avg** → "active". +- **Funding rate** sign + **order book top 10** balance → overall bias and sustainability. + +**Output**: Must include "Buy/sell forces" table, momentum direction, sustainability, and short analysis. + +--- + +### Scenario 2.1: Basic momentum query + +**Context**: User wants to judge short-term long vs short strength. + +**Prompt examples**: +- "Is BTC more long or short in 24h, and is it sustainable?" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex spot market trades`/`gate-cli cex futures market trades` → `gate-cli cex spot market tickers`/`gate-cli cex futures market tickers` → `gate-cli cex spot market candlesticks`/`gate-cli cex futures market candlesticks` → `gate-cli cex spot market orderbook`/`gate-cli cex futures market orderbook` → `gate-cli cex futures market funding-rate` (futures when contract). +2. From trades: buy/sell volume, buy share; tickers: 24h volume and change; candlesticks: 30d avg; order book: top 10 long/short depth; funding rate for bias. +3. Apply logic (buy > 70% → buy-side strong; 24h > 30d avg → active; funding + book → direction and sustainability). +4. Output buy/sell table + direction + analysis per Report Template. + +**Output**: +```markdown +## BTC Momentum Analysis + +### Buy/sell forces + +| Metric | Value | +|--------|-------| +| Buy share | 65% | +| Sell share | 35% | +| 24h volume | $2.1B | +| 30d avg volume | $1.8B | +| Activity | Active | + +### Conclusion + +**Momentum direction**: Buy-side slightly ahead + +Buy share 65% but below 70% "strong" threshold; currently long-leaning but not one-sided. Volume above 30d avg; activity is rising. +``` + +--- + +### Scenario 2.2: One-sided strong buy + +**Context**: User asks whether buy side is strong. + +**Prompt examples**: +- "Is ETH buy side strong?" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex spot market trades`(ETH_USDT) → `gate-cli cex spot market tickers` → `gate-cli cex spot market candlesticks`. +2. Compute buy/sell share; if buy > 70% mark as buy-side strong. +3. Output buy/sell table + direction (buy-side strong). + +**Output**: +```markdown +## ETH Momentum Analysis + +### Buy/sell forces + +| Metric | Value | +|--------|-------| +| Buy share | 78% | +| Sell share | 22% | + +### Conclusion + +**Momentum direction**: Buy-side strong + +Buy share 78%, well above 70% threshold; clear long-dominated tape. With volume expansion, trend may extend. +``` + +--- + +### Scenario 2.3: Futures momentum query + +**Context**: User explicitly asks about contract momentum. + +**Prompt examples**: +- "BTC contract momentum" + +**Expected behavior**: +1. Detect "contract" and use **futures** MCP: `gate-cli cex futures market trades` (`settle=usdt`, `contract=BTC_USDT`) → `gate-cli cex futures market tickers` → `gate-cli cex futures market candlesticks`. +2. Extract buy/sell share, 24h volume, 30d avg per MCP Call Spec; same output structure, data from futures. + +--- + +## Case 3: Liquidation Monitoring + +### MCP Call Spec (document-aligned) + +**Trigger**: "Recent liquidations?", "Which coins liquidated most?" For liquidation monitoring, **call in this order** (futures only). + +| Step | MCP Tool | Parameters | Required Fields | +|------|----------|------------|----------------| +| 1 | `gate-cli cex futures market liquidations` | `settle=usdt`, time range (last 1h; optional 24h for daily baseline) | Liq volume by contract; long (size>0) / short (size<0); 1h total liq | +| 2 | `gate-cli cex futures market candlesticks` | `settle=usdt`, `contract`, `interval=5m`, `limit=12` | Price during liq window, current price, recovery | +| 3 | `gate-cli cex futures market tickers` | `settle=usdt` (or specific contract) | Current price, 24h change | + +**Calculation & judgment** (aligned with SKILL): + +- **1h liq > 3× daily avg** → flag "anomaly". +- **One-sided liq > 80%** (long or short) → flag "long squeeze" or "short squeeze". +- **Price recovered** (vs wick low/high) → flag "wick / spike". + +**Output**: Must include "Market overview" table, "Anomaly contracts" table, and wick analysis when relevant (low, current price, recovery). + +--- + +### Scenario 3.1: Market-wide liquidation overview + +**Context**: User wants a market-wide liquidation view. + +**Prompt examples**: +- "Recent liquidations?" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex futures market liquidations` → `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers`. +2. Aggregate liq by contract; long/short share; if daily baseline available, compute 1h vs daily multiple. +3. Apply logic: 1h liq > 3× daily → anomaly; one-sided > 80% → long/short squeeze; price recovered → wick. +4. Output market overview table + anomaly contracts table. + +**Output**: +```markdown +## Liquidation Monitoring + +**Time**: 2026-03-05 15:30 + +### Market overview + +| Metric | Value | +|--------|-------| +| 1h total liq | $45M | +| Long liq | $38M (84%) | +| Short liq | $7M (16%) | + +### Anomaly contracts + +| Contract | Liq volume | Multiple | Type | +|----------|------------|----------|------| +| ETH_USDT | $18M | 4.2x | Long squeeze | +| SOL_USDT | $8M | 3.5x | Long squeeze | + +Long liq 84%; current move is squeezing long leverage. +``` + +--- + +### Scenario 3.2: Wick / spike detection + +**Context**: User suspects a wick/spike (e.g. BTC). + +**Prompt examples**: +- "Did BTC just wick?" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex futures market liquidations`(1h, optional filter contract=BTC_USDT) → `gate-cli cex futures market candlesticks`(BTC_USDT, 5m, 12) → `gate-cli cex futures market tickers`. +2. From liq: long/short share; from candlesticks: low, current price; recovery = (current − low) / (pre-spike high − low) or similar. +3. If long-dominated liq and recovery > 80%, output wick analysis (liq table + low/current/recovery + wick conclusion). + +**Output**: +```markdown +## BTC Wick Analysis + +### Liquidation data + +| Metric | Value | +|--------|-------| +| 1h liq | $25M | +| Long liq | $23M (92%) | +| Low | $62,100 | +| Current | $63,800 | +| Recovery | 85% | + +### Conclusion + +**Type**: Wick / spike + +- Long-dominated liq (92%) +- Price recovered 85% +- Typical short wick squeezing long leverage +``` + +--- + +## Case 4: Funding Rate Arbitrage Scan + +### MCP Call Spec (document-aligned) + +**Trigger**: "Any arbitrage opportunities?", "Which coins have extreme funding?" For arbitrage scan, **call in this order**. + +| Step | MCP Tool | Parameters | Required Fields | +|------|----------|------------|----------------| +| 1 | `gate-cli cex futures market tickers` | `settle=usdt` | All contracts' funding_rate, 24h volume | +| 2 | `gate-cli cex futures market funding-rate` or equivalent | For candidates / full market | Rate details | +| 3 | `gate-cli cex spot market tickers` (spot) | Per candidate `currency_pair={BASE}_USDT` | Spot last; spot–futures spread | +| 4 | `gate-cli cex spot market orderbook` (spot) | For top candidates `currency_pair`, `limit=20` | Top 10 depth; exclude if depth too thin | + +**Calculation & judgment** (aligned with SKILL): + +- **|rate| > 0.05% and 24h vol > $10M** → candidate. +- **Spot–futures spread > 0.2%** → bonus. +- **Book depth too thin** → exclude. + +**Output**: Must include "Arbitrage opportunities" table, strategy note (long basis / short basis), and risk disclaimer. + +--- + +### Scenario 4.1: Market-wide arbitrage scan + +**Context**: User wants to find funding arbitrage opportunities. + +**Prompt examples**: +- "Any arbitrage opportunities?" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex futures market tickers` → `gate-cli cex futures market funding-rate` → `gate-cli cex spot market tickers`(candidates) → `gate-cli cex spot market orderbook`(top candidates). +2. Logic: |rate|>0.05% and 24h vol>$10M → candidate; spot–futures spread>0.2% → bonus; thin depth → exclude. +3. Output arbitrage table + strategy + risk note. + +**Output**: +```markdown +## Funding Rate Arbitrage Scan + +**Time**: 2026-03-05 15:30 + +### Top 5 opportunities + +| Contract | Rate | Ann. | Basis | Depth | Strategy | +|----------|------|------|-------|-------|----------| +| DOGE_USDT | +0.15% | 164% | +0.3% | OK | Long basis | +| PEPE_USDT | +0.12% | 131% | +0.2% | Fair | Long basis | +| WIF_USDT | -0.10% | 109% | -0.1% | OK | Short basis | + +### Strategy + +**Long basis**: Short futures + long spot +**Short basis**: Long futures + short spot (borrow) + +⚠️ Risk: Actual PnL must account for fees and execution. +``` + +--- + +### Scenario 4.2: Extreme funding query + +**Context**: User wants coins with extreme funding rates. + +**Prompt examples**: +- "Which coins have extreme funding?" + +**Expected behavior**: +1. Call `gate-cli cex futures market tickers`(settle=usdt); filter |funding_rate| > 0.001 (0.1%). +2. Sort by |rate|; label severity (e.g. extreme positive, high negative). +3. Output "Extreme funding" table (contract, rate, status). + +**Output**: +```markdown +## Extreme Funding + +| Contract | Rate | Status | +|----------|------|--------| +| DOGE_USDT | +0.18% | Extreme positive | +| SHIB_USDT | +0.15% | High positive | +| WIF_USDT | -0.12% | High negative | + +Positive rate > 0.1% means high cost to long; may signal short-term pullback risk. +``` + +--- + +## Case 5: Basis (Spot vs Futures) Monitoring + +### MCP Call Spec (document-aligned) + +**Trigger**: "What is the basis?", "Spot–futures spread." For basis monitoring, **call in this order**. + +| Step | MCP Tool | Parameters | Required Fields | +|------|----------|------------|----------------| +| 1 | `gate-cli cex spot market tickers` (spot) | `currency_pair={BASE}_USDT` | Spot `last` | +| 2 | `gate-cli cex futures market tickers` | `settle=usdt`, optional `contract={BASE}_USDT` | Futures price, mark_price, index_price | +| 3 | `gate-cli cex futures market premium` or equivalent | `settle=usdt`, `contract={BASE}_USDT` | premium_index; if history available, for mean and deviation | + +**Calculation & judgment** (aligned with SKILL): + +- **Current basis vs historical mean** (deviation). +- **Basis widening / narrowing** (widening → sentiment heating; narrowing → mean reversion). + +**Output**: Must include "Basis data" table, current vs historical mean, widening/narrowing conclusion, and short recommendation. + +--- + +### Scenario 5.1: Single-coin basis query + +**Context**: User asks for BTC spot–futures spread. + +**Prompt examples**: +- "What is BTC basis?" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex spot market tickers`(BTC_USDT) → `gate-cli cex futures market tickers`(usdt, BTC_USDT) → optional `gate-cli cex futures market premium`. +2. Compute basis, basis rate; if premium history available, historical mean. +3. Output basis table + analysis + recommendation per Report Template. + +**Output**: +```markdown +## BTC Spot–Futures Basis + +### Basis data + +| Metric | Value | +|--------|-------| +| Spot | $63,500 | +| Futures | $63,700 | +| Basis | +$200 | +| Basis rate | +0.31% | +| Historical mean | +0.15% | + +### Analysis + +Current basis rate 0.31%, above historical mean 0.15%; **elevated positive basis**. Possible reasons: strong bullish sentiment; suitable for long-basis arbitrage. +``` + +--- + +### Scenario 5.2: Negative basis warning + +**Context**: User queries ETH basis. + +**Prompt examples**: +- "ETH spot–futures spread" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex spot market tickers`(ETH_USDT) → `gate-cli cex futures market tickers`(usdt, ETH_USDT) → optional `gate-cli cex futures market premium`(settle=usdt, contract=ETH_USDT). +2. Compute basis and basis rate; if premium index available, use for context; if negative, output basis table + ⚠️ negative basis warning (bearish / short crowding). + +**Output**: +```markdown +## ETH Spot–Futures Basis + +### Basis data + +| Metric | Value | +|--------|-------| +| Spot | $3,200 | +| Futures | $3,185 | +| Basis | -$15 | +| Basis rate | -0.47% | + +### Notice + +Currently **negative basis** (futures below spot), which often indicates: +- Bearish sentiment +- Or short crowding +``` + +--- + +## Case 6: Manipulation Risk Analysis (Is the coin easy to manipulate?) + +### MCP Call Spec (document-aligned) + +**Trigger**: "How is this coin’s depth vs volume?" / "Is it easy to manipulate?" + +**API choice**: When user mentions **perpetual, contract, futures**, use **futures** tools; otherwise use **spot** tools. + +| Step | MCP Tool (spot) | MCP Tool (futures, when user says perpetual/contract) | Parameters | Required Fields | +|------|-----------------|--------------------------------------------------------|------------|----------------| +| 1 | `gate-cli cex spot market orderbook` | `gate-cli cex futures market orderbook` | Spot: `currency_pair={BASE}_USDT`. Futures: `settle=usdt`, `contract={BASE}_USDT`. `limit=20` | Top 10 bid depth sum, top 10 ask depth sum | +| 2 | `gate-cli cex spot market tickers` | `gate-cli cex futures market tickers` | Same pair / contract + settle | 24h quote volume (quoteVolume) | +| 3 | `gate-cli cex spot market trades` | `gate-cli cex futures market trades` or equivalent | Same pair; `limit=500` (or 24h window) | Trade size distribution; consecutive same-direction large orders | + +**Calculation & judgment** (aligned with SKILL): + +- **Top 10 depth total / 24h volume < 0.5%** → "thin depth". +- **24h trades have consecutive same-direction large orders** → "possible manipulation". + +**Output**: Must include "Depth analysis" table (top 10 depth, 24h volume, depth ratio, assessment), "Large order" summary, and "Manipulation risk" conclusion. + +--- + +### Scenario 6.1: Manipulation risk query + +**Context**: User is concerned about small-cap coin manipulation. + +**Prompt examples**: +- "Is PEPE easy to manipulate?" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex spot market orderbook`(PEPE_USDT) → `gate-cli cex spot market tickers` → `gate-cli cex spot market trades`(limit=500). +2. Compute depth ratio; from trades identify large and consecutive same-side. +3. Output depth table + large order summary + risk conclusion per Report Template. + +**Output**: +```markdown +## PEPE Manipulation Risk + +### Depth analysis + +| Metric | Value | Assessment | +|--------|-------|------------| +| Top 10 depth | $850K | - | +| 24h volume | $320M | - | +| Depth ratio | 0.27% | Thin | + +### Large orders + +In last 500 trades: +- 3 consecutive large buys (15% of sample) +- Max single: $125K + +### Risk conclusion + +**Manipulation risk**: High + +- Depth ratio < 0.5% implies small size can move price +- Consecutive same-side large orders suggest possible manipulation +``` + +--- + +### Scenario 6.2: Healthy pair (low risk) + +**Context**: User queries a major pair (e.g. BTC). + +**Prompt examples**: +- "How is BTC depth vs volume?" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex spot market orderbook`(BTC_USDT) → `gate-cli cex spot market tickers` → optional `gate-cli cex spot market trades`. +2. Compute depth ratio; if > 2% assess as good depth, low manipulation risk. +3. Output depth table + risk conclusion (low). + +**Output**: +```markdown +## BTC Manipulation Risk + +### Depth analysis + +| Metric | Value | Assessment | +|--------|-------|------------| +| Top 10 depth | $85M | - | +| 24h volume | $2.1B | - | +| Depth ratio | 4.0% | Good | + +### Risk conclusion + +**Manipulation risk**: Low + +BTC has ample depth; large size would be needed to move price; manipulation risk is low. +``` + +--- + +### Scenario 6.3: Futures manipulation risk (perpetual/contract) + +**Context**: User asks about manipulation for a **perpetual/contract** (e.g. "BTC contract easy to manipulate?"). + +**Prompt examples**: +- "Is BTC contract easy to manipulate?" +- "How is ETH perpetual depth vs volume?" + +**Expected behavior**: +1. Detect "perpetual" or "contract" and use **futures** MCP: `gate-cli cex futures market contract`(settle=usdt, contract=BTC_USDT) → `gate-cli cex futures market orderbook`(settle=usdt, contract=BTC_USDT, limit=20) → `gate-cli cex futures market tickers` → `gate-cli cex futures market trades` (or equivalent, limit=500). +2. Use `quanto_multiplier` from contract to convert order book size to notional; extract top 10 depth total and 24h volume; from futures trades detect consecutive same-direction large orders. +3. Apply same judgment: depth ratio < 0.5% → thin; consecutive same-side large → possible manipulation. +4. Output depth analysis table + large order summary + manipulation risk conclusion (same structure as 6.1/6.2, data from futures). + +**Output**: Same structure as Scenario 6.1 or 6.2; data source is futures contract, order book, tickers, and trades. + +--- + +## Case 7: Order Book Explainer + +### MCP Call Spec (document-aligned) + +**Trigger**: "Explain the order book", "What is the order book?", "How to read the book?" For order book explainer, **call in this order**. + +| Step | MCP Tool | Parameters | Required Fields | +|------|----------|------------|----------------| +| 1 | `gate-cli cex spot market orderbook` (spot) / `gate-cli cex futures market orderbook` (futures) | `currency_pair` or `contract`+`settle`, `limit=10` | bids/asks sample (price and size per level) | +| 2 | `gate-cli cex spot market tickers` (spot) / `gate-cli cex futures market tickers` (futures) | Same pair | `last` for spread explanation | + +**Interpretation** (aligned with SKILL): + +- Show sample bids/asks (levels and sizes). +- Explain **spread** (bid–ask) using ticker last. +- Fast moves + deep depth → good liquidity. + +**Output**: Must include order book tutorial, live order book example, key metrics (best bid, best ask, spread), and short takeaways. + +--- + +### Scenario 7.1: Beginner explainer + +**Context**: User is new and needs a tutorial. + +**Prompt examples**: +- "Explain the order book" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex spot market orderbook` (e.g. BTC_USDT, limit=10) → `gate-cli cex spot market tickers`. +2. Fill order book table and key metrics with live data; add tutorial text (Bids/Asks/Spread, what spread means). +3. Output tutorial + live example + takeaways. + +**Output**: +```markdown +## Order Book Explained + +### What is the order book + +The order book is the exchange’s "list of orders": + +- **Asks**: Sell orders, sorted by price low to high +- **Bids**: Buy orders, sorted by price high to low +- **Spread**: Gap between best ask and best bid + +### Live example (BTC) + +**Asks** ↑ +| Price | Size | Cumulative | +|-------|------|------------| +| 63,550 | 2.5 | 7.8 | +| 63,530 | 1.8 | 5.3 | +| 63,510 | 3.5 | 3.5 | ← Best ask + +------- Last: 63,505 ------- + +**Bids** ↓ +| Price | Size | Cumulative | +|-------|------|------------| +| 63,500 | 4.2 | 4.2 | ← Best bid +| 63,480 | 2.1 | 6.3 | +| 63,460 | 3.0 | 9.3 | + +### Takeaways + +- **Spread** = 63,510 − 63,500 = $10 (0.016%) +- Tighter spread → better liquidity +- Deeper book → less impact from large orders +``` + +--- + +### Scenario 7.2: Specific pair order book + +**Context**: User wants to see a specific pair’s book (e.g. ETH). + +**Prompt examples**: +- "Show ETH order book" + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex spot market orderbook`(ETH_USDT, limit=10) → `gate-cli cex spot market tickers`(ETH_USDT). +2. Output ETH live table (asks/bids, price, size, cumulative) + last + spread and short comment (e.g. liquidity, support). + +**Output**: +```markdown +## ETH Order Book + +**Asks** +| Price | Size | Cumulative | +|-------|------|------------| +| 3,205 | 45 | 120 | +| 3,203 | 32 | 75 | +| 3,201 | 43 | 43 | ← Best ask + +--- Last: 3,200 --- + +**Bids** +| Price | Size | Cumulative | +|-------|------|------------| +| 3,200 | 55 | 55 | ← Best bid +| 3,198 | 28 | 83 | +| 3,196 | 40 | 123 | + +Spread: $1 (0.03%) — liquidity good. Bid depth heavier than asks; support below is stronger. +``` + +--- + +## Case 8: Slippage Simulation + +### MCP Call Spec (document-aligned) + +**Trigger**: "slippage simulation", "market buy $X slippage", "how much slippage if I market buy $10K?", e.g. "ADA_USDT slippage simulation: if I market buy $10K, how much slippage?" + +**Required inputs** (both must be provided; do not use defaults): + +- **Currency pair** (e.g. `ETH_USDT`, `ADA_USDT`, `BTC_USDT`): identifies which order book and ticker to use. **If the user does not specify a pair**, prompt them to provide one (e.g. "Please specify a pair, e.g. ETH_USDT, ADA_USDT."). +- **Quote amount** (e.g. $10,000 USDT): the notional to simulate for the market buy. **If the user does not specify an amount**, prompt them to provide one (e.g. "Please specify the quote amount, e.g. $10K USDT."). **Do not assume a default** (e.g. do not default to $10K). + +**API choice**: When user mentions **perpetual, contract, futures**, use **futures** tools; otherwise use **spot** tools. + +| Step | MCP Tool (spot) | MCP Tool (futures, when user says perpetual/contract) | Parameters | Required Fields | +|------|-----------------|--------------------------------------------------------|------------|----------------| +| 1 | `gate-cli cex spot market orderbook` | `gate-cli cex futures market contract` | Spot: `currency_pair={BASE}_USDT`, `limit=50`. Futures: `settle=usdt`, `contract={BASE}_USDT` | Spot: asks (price, size), bid1/ask1. Futures: `quanto_multiplier` (contract size) for ladder notional | +| 2 | — | `gate-cli cex futures market orderbook` | Futures: `settle=usdt`, `contract={BASE}_USDT`, `limit=50` | Asks (price, size) for ladder walk; bid1/ask1 | +| 3 | `gate-cli cex spot market tickers` | `gate-cli cex futures market tickers` | Same pair / contract + settle | `last`, `lowestAsk` (or use ask1 from order book) | + +**Calculation & judgment** (aligned with SKILL): + +- **Order book + latest price**: Use current order book and ticker last / best ask. +- **Simulate market buy for quote amount Q (e.g. $10K USDT)**: Walk the **ask** ladder from best ask upward; at each level fill `amount_i` at `price_i` until cumulative quote volume `sum(price_i × amount_i)` ≥ Q (last level may be partially filled so total cost ≈ Q). +- **Outputs**: Total base filled, volume spent, **volume-weighted average execution price** = total_cost / total_base. +- **Slippage = deviation from best ask**: + - **Price deviation**: `avg_price − ask1` (points in price). + - **Relative deviation**: `(avg_price − ask1) / ask1 × 100%`; optionally in bps: `× 10000`. + +**Output**: Must include "Simulation inputs" (pair, quote amount, ask1), "Fill summary" (total base, avg price), "Slippage" (vs ask1: points and %), and short "Conclusion". + +--- + +### Scenario 8.1: Spot slippage simulation (e.g. ADA_USDT market buy $10K) + +**Context**: User wants to know how much slippage to expect for a market buy of a given USDT amount on spot. + +**Prompt examples**: +- "ADA_USDT slippage simulation: if I market buy $10K, how much slippage?" +- "How much slippage for a $10K market buy in ETH?" + +**Expected behavior**: +1. **Require pair and amount**: If the user did not specify a **currency pair** (e.g. ADA_USDT, ETH_USDT), prompt them to provide one; do not run the simulation or assume a default pair. If the user did not specify a **quote amount** (e.g. $10,000 USDT), prompt them to provide one; do not assume a default (e.g. do not default to $10K). +2. Parse pair (e.g. ADA_USDT, ETH_USDT) and quote amount (e.g. $10,000 USDT) from the user. +3. Call per **MCP Call Spec**: `gate-cli cex spot market orderbook`(pair, limit=50) → `gate-cli cex spot market tickers`(pair). +4. Walk ask ladder until cumulative quote ≥ quote amount; compute total base filled, total cost, volume-weighted avg price. +5. ask1 = first ask price from order book (or ticker lowestAsk). Slippage = avg_price − ask1 (points) and (avg_price − ask1)/ask1 × 100 (%). +6. Output simulation inputs table + fill summary + slippage vs ask1 + conclusion. + +**Output**: +```markdown +## ADA_USDT Slippage Simulation (Spot Market Buy) + +### Simulation inputs + +| Item | Value | +|------|--------| +| Pair | ADA_USDT (spot) | +| Quote amount | $10,000 USDT | +| Best ask | 0.xxxx USDT | + +### Fill summary + +| Metric | Value | +|--------|--------| +| Total base filled | x,xxx ADA | +| Total cost | ~$10,000 USDT | +| Volume-weighted avg price | 0.xxxx USDT | + +### Slippage vs best ask + +| Metric | Value | +|--------|--------| +| Price deviation (points) | +0.xxxx USDT | +| Relative deviation | +x.xx% | + +### Conclusion + +For a $10K market buy, slippage vs best ask is about x.xx% (about x.xxxx points). Slippage can be higher when depth is thin; consider splitting large orders or using limit orders. +``` + +--- + +### Scenario 8.2: Futures slippage simulation (perpetual/contract) + +**Context**: User asks slippage for a **perpetual/contract** market buy (long) of a given USDT amount. + +**Prompt examples**: +- "BTC perpetual market long $50K, how much slippage?" + +**Expected behavior**: +1. **Require pair**: If no contract/pair is specified (e.g. BTC_USDT), prompt the user to provide one; do not assume a default. +2. Detect "perpetual" or "contract" and use **futures** MCP: `gate-cli cex futures market contract`(settle=usdt, contract={pair}) → `gate-cli cex futures market orderbook`(settle=usdt, contract={pair}, limit=50) → `gate-cli cex futures market tickers`(settle, contract). +3. Use `quanto_multiplier` from contract to convert order book size (contracts) to base notional; same ladder logic on **asks** for quote amount; compute avg price, slippage = avg_price − ask1 (points and %). +4. **Output**: Same structure as Scenario 8.1; data source is futures order book + futures tickers. + +--- + +### Scenario 8.3: Missing pair or amount — prompt user + +**Context**: User asks for slippage simulation but does **not** specify a **currency pair** and/or does **not** specify a **quote amount** (e.g. "How much slippage if I market buy $10K?" with no pair; or "ETH_USDT slippage" with no amount). + +**Prompt examples**: +- "How much slippage if I market buy $10K?" (missing pair) +- "ETH_USDT slippage" / "ADA_USDT perpetual slippage simulation" (missing amount) +- "Slippage simulation" (missing both pair and amount) + +**Expected behavior**: +1. Do **not** call MCP. Do **not** assume a default pair or a default amount (e.g. do not default to $10K). +2. Reply with a short prompt asking for the missing input(s): pair and/or quote amount. + +**Output** (when pair is missing, or amount is missing, or both): +```markdown +To run the slippage simulation, I need both: + +1. **Currency pair** (e.g. spot: ETH_USDT, ADA_USDT; or perpetual: BTC_USDT). +2. **Quote amount** (e.g. $10,000 USDT). I will not assume a default — please specify the amount. + +Example: "ETH_USDT slippage for a $10K market buy" or "ADA_USDT perpetual, market long $5K, how much slippage?" +``` + +--- + +## Case 9: K-Line Breakout / Support–Resistance + +### MCP Call Spec (document-aligned) + +**Trigger**: "Based on recent K-line chart, does SOL/USDT show signs of breaking out upward? Analyze support and resistance." + +**API choice**: Use **spot** tools when user asks about spot pair (or unspecified); use **futures** tools when user says perpetual/contract. + +| Step | MCP Tool (spot) | MCP Tool (futures) | Parameters | Required Fields | +|------|-----------------|--------------------|------------|-----------------| +| 1 | `gate-cli cex spot market candlesticks` | `gate-cli cex futures market candlesticks` | Spot: `currency_pair={BASE}_USDT`. Futures: `settle=usdt`, `contract={BASE}_USDT`. `interval=1d` (or 4h), `limit=30–90` | OHLC; volume; identify local highs/lows for support/resistance; trend structure | +| 2 | `gate-cli cex spot market tickers` | `gate-cli cex futures market tickers` | Same pair / contract + settle | `last`; 24h `quoteVolume`; `changePercentage`; `high24h`/`low24h` for momentum context | + +**Calculation & judgment** (aligned with SKILL): + +- **K-line**: Query historical candlesticks; from OHLC identify **support** (recent lows, consolidation floors) and **resistance** (recent highs, consolidation ceilings). +- **Momentum**: Use 24h price, volume, and change from tickers to assess whether current level has breakout momentum (e.g. volume expansion near resistance, price above key levels). + +**Output**: Must include "K-line context" (period, key levels), "Support & resistance" table or list, "Momentum" (24h price, volume, change), and short "Breakout assessment" (e.g. signs of upward breakout or not). + +--- + +### Scenario 9.1: Spot K-line support–resistance (e.g. SOL/USDT) + +**Context**: User asks whether a spot pair shows breakout signs and wants support/resistance from recent K-line. + +**Prompt examples**: +- "Based on recent K-line chart, does SOL/USDT show signs of breaking out upward? Analyze support and resistance." + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex spot market candlesticks`(SOL_USDT, interval=1d or 4h, limit=30–90) → `gate-cli cex spot market tickers`(SOL_USDT). +2. From candlesticks: derive support (e.g. recent lows, swing lows) and resistance (e.g. recent highs, swing highs); note trend structure (higher highs/lows vs lower). +3. From tickers: last, 24h volume, 24h change, high24h/low24h; use to assess momentum (e.g. volume confirmation, price relative to key levels). +4. Output: K-line context + support/resistance levels + momentum summary + breakout assessment (e.g. clear / no clear signs of upward breakout; data-based, not investment advice). + +**Output**: +```markdown +## SOL/USDT — K-Line Support & Resistance + +### K-line context + +- Period: last 30 days (1d) +- Key levels derived from OHLC + +### Support & resistance + +| Type | Level (approx) | Note | +|--------|-----------------|-------------| +| Resistance | $XXX | Recent high | +| Resistance | $XXX | Prior swing | +| Support | $XXX | Recent low | +| Support | $XXX | Consolidation floor | + +### Momentum (24h) + +| Metric | Value | +|----------|---------| +| Last | $XXX | +| 24h vol | $XXX | +| 24h change | +X.XX% | + +### Breakout assessment + +Based on recent K-line and 24h data: [e.g. price near/above resistance with volume expansion suggests upward breakout potential; or: no clear breakout yet, watch resistance and volume]. Analysis is data-based, not investment advice. +``` + +--- + +### Scenario 9.2: Futures K-line support–resistance + +**Context**: User asks the same for a **perpetual/contract** (e.g. SOL_USDT perpetual). + +**Prompt examples**: +- "Based on recent K-line, does SOL perpetual show breakout? Analyze support and resistance." +- "BTC contract: support and resistance from candlesticks?" + +**Expected behavior**: +1. Detect "perpetual" or "contract" and use **futures** MCP: `gate-cli cex futures market candlesticks`(settle=usdt, contract=SOL_USDT, interval=1d, limit=30–90) → `gate-cli cex futures market tickers`(settle=usdt, contract=SOL_USDT). +2. Same logic: derive support/resistance from OHLC; use tickers for 24h price, volume, change; output same structure with futures data. + +--- + +## Case 10: Liquidity + Weekend vs Weekday + +### MCP Call Spec (document-aligned) + +**Trigger**: "Evaluate ETH liquidity on the exchange and compare weekend vs weekday." + +**API choice**: Use **spot** tools when user asks about spot (or unspecified); use **futures** tools when user says perpetual/contract. + +| Step | MCP Tool (spot) | MCP Tool (futures) | Parameters | Required Fields | +|------|-----------------|--------------------|------------|-----------------| +| 1 | `gate-cli cex spot market orderbook` | `gate-cli cex futures market contract` | Spot: `currency_pair={BASE}_USDT`, `limit=20`. Futures: `settle=usdt`, `contract={BASE}_USDT` | Spot: depth, bid1/ask1. Futures: `quanto_multiplier` for depth notional | +| 2 | `gate-cli cex spot market candlesticks` | `gate-cli cex futures market orderbook` | Spot: same pair, `interval=1d`, `limit=90`. Futures: `settle=usdt`, `contract={BASE}_USDT`, `limit=20` | Spot: daily OHLC, volume. Futures: depth levels; top 10 bid/ask totals; bid1/ask1 | +| 3 | `gate-cli cex spot market tickers` | `gate-cli cex futures market candlesticks` | Same pair/contract; `interval=1d`, `limit=90` (or from/to for ~90 days) | Daily OHLC, volume, quote volume; tag weekend vs weekday | +| 4 | — | `gate-cli cex futures market tickers` | Same pair/contract | `last`; 24h volume; current context | + +**Calculation & judgment** (aligned with SKILL): + +- **Order book**: Query depth; summarize current depth (levels, top 10 totals, spread) for **liquidity**. +- **90-day K-line**: From candlesticks, split days into **weekend** (Sat/Sun) vs **weekday** (Mon–Fri). Compute for each group: avg daily return (or sum of returns), avg/sum of volume and quote volume. Compare weekend vs weekday: volatility (e.g. absolute return), volume/quote volume (liquidity difference). + +**Output**: Must include "Current liquidity" (order book depth, spread), "90-day weekend vs weekday" table (e.g. avg daily volume, avg daily return, or similar), "Comparison" summary, and short "Conclusion". + +--- + +### Scenario 10.1: Spot liquidity + weekend vs weekday (e.g. ETH) + +**Context**: User wants ETH liquidity assessment and weekend vs weekday comparison. + +**Prompt examples**: +- "Evaluate ETH liquidity on the exchange and compare weekend vs weekday." + +**Expected behavior**: +1. Call per **MCP Call Spec**: `gate-cli cex spot market orderbook`(ETH_USDT, limit=20) → `gate-cli cex spot market candlesticks`(ETH_USDT, interval=1d, limit=90 or from/to ~90 days) → `gate-cli cex spot market tickers`(ETH_USDT). +2. From order book: depth levels, top 10 bid/ask totals, spread → current liquidity summary. +3. From candlesticks: for each day (timestamp), classify weekend vs weekday; aggregate by group: e.g. avg daily volume, avg daily quote volume, avg absolute daily return or avg daily return; optionally count days. +4. Compare: e.g. "Weekend avg volume vs weekday avg volume"; "Weekend vs weekday volatility/return." +5. Output: Current liquidity table + "90-day weekend vs weekday" table + comparison + conclusion. Include disclaimer: data-based, not investment advice. + +**Output**: +```markdown +## ETH — Liquidity & Weekend vs Weekday + +### Current liquidity + +| Metric | Value | +|------------------|---------| +| Order book depth | XX levels | +| Top 10 bid total | $XXX | +| Top 10 ask total | $XXX | +| Spread | X.XX% | + +### 90-day: Weekend vs weekday + +| Metric | Weekend | Weekday | Note | +|---------------|---------|---------|--------| +| Avg daily vol | $XXX | $XXX | Base | +| Avg daily quote vol | $XXX | $XXX | USDT | +| Avg daily return | +X.XX% | +X.XX% | Or abs return | +| Days count | XX | XX | | + +### Comparison + +- Liquidity: [e.g. current order book depth is good / moderate] +- Weekend vs weekday: [e.g. weekend volume/volatility is lower / higher / similar to weekday]. [One-line summary of volume and volatility difference.] + +### Conclusion + +[Short summary: ETH liquidity on exchange + weekend vs weekday difference.] Analysis is data-based, not investment advice. +``` + +--- + +### Scenario 10.2: Futures liquidity + weekend vs weekday + +**Context**: User asks the same for **perpetual/contract** (e.g. ETH_USDT perpetual). + +**Prompt examples**: +- "Evaluate ETH contract liquidity and compare weekend vs weekday." +- "BTC perpetual: liquidity and weekend vs weekday comparison." + +**Expected behavior**: +1. Detect "perpetual" or "contract" and use **futures** MCP: `gate-cli cex futures market contract`(settle=usdt, contract=ETH_USDT) → `gate-cli cex futures market orderbook`(settle=usdt, contract=ETH_USDT, limit=20) → `gate-cli cex futures market candlesticks`(settle=usdt, contract=ETH_USDT, interval=1d, limit=90) → `gate-cli cex futures market tickers`(settle=usdt, contract=ETH_USDT). +2. Use `quanto_multiplier` from contract to interpret order book depth in notional; same logic: order book for current depth; 90d candlesticks split weekend vs weekday for volume and return; output same structure with futures data. + +--- + +## Case 11: Technical Analysis — What to Do (Short + Long Timeframe, Support/Resistance, Momentum, Funding) + +### MCP Call Spec (document-aligned) + +**Trigger**: "Technical analysis: what should I do with BTC now?", "Should I go long or short at current level?", "Give me a trading recommendation based on technicals." + +**API choice**: Use **spot** and **futures** (query both); spot for price and volume, futures add funding rate for long/short bias. Query short and long timeframes; give separate advice per timeframe. + +| Step | MCP Tool (spot) | MCP Tool (futures) | Parameters | Required Fields | +|------|-----------------|--------------------|------------|-----------------| +| 1 | `gate-cli cex spot market candlesticks` | `gate-cli cex futures market candlesticks` | Spot: `currency_pair={BASE}_USDT`. Futures: `settle=usdt`, `contract={BASE}_USDT`. Short & long: `interval=4h` and `1d`, `limit=30–90` | OHLC; volume; support/resistance (recent highs/lows); trend structure | +| 2 | `gate-cli cex spot market tickers` | `gate-cli cex futures market tickers` | Same pair / contract + settle | `last`; 24h `quoteVolume`; `changePercentage`; compare to history for momentum | +| 3 | — | `gate-cli cex futures market funding-rate` | `contract`+`settle` | Funding rate; positive → long cost high / short bias; negative → short cost high / long bias | + +**Calculation & judgment** (aligned with SKILL): + +- **Support/resistance**: From historical candlesticks (short and long timeframe) identify recent highs, lows, consolidation bounds; assess current price level. +- **Momentum**: Compare current price and 24h volume to past (e.g. 7d/30d average); volume–price confirmation, volume breakout / low-volume pullback. +- **Long/short bias**: Funding rate sign and size; high positive → long crowding, cautious or contrarian; high negative → short crowding, bias long. +- **Short vs long timeframe**: Query both (e.g. 4h and 1d); give short-term action from short timeframe and trend/key levels from long timeframe. + +**Output**: Must include "K-line & key levels" (both timeframes), "Momentum" (current price, 24h volume vs past), "Funding & long/short" (futures only), "Short-term advice" and "Long-term advice". + +--- + +### Scenario 11.1: Technical analysis — what to do (e.g. BTC) + +**Context**: User wants a technical-based recommendation for a pair (e.g. BTC): long/short/wait, short and long timeframe. + +**Prompt examples**: +- "Technical analysis: what should I do with BTC now?" + +**Expected behavior**: +1. **Spot**: Call per **MCP Call Spec**: `gate-cli cex spot market candlesticks`(BTC_USDT, interval=4h and 1d, limit=30–90 each) → `gate-cli cex spot market tickers`(BTC_USDT). +2. **Futures**: Call per **MCP Call Spec**: `gate-cli cex futures market candlesticks`(settle=usdt, contract=BTC_USDT, same timeframes) → `gate-cli cex futures market tickers` → `gate-cli cex futures market funding-rate`(contract=BTC_USDT). +3. From candlesticks: derive support/resistance; from ticker: current price, 24h volume, change; compare to history for momentum; from funding_rate: long/short bias. +4. Output: K-line context + key levels (both timeframes) + momentum conclusion + funding long/short conclusion + short-term recommendation + long-term recommendation per Report Template. + +**Output**: +```markdown +## BTC Technical Analysis — Current Recommendation + +### K-line & key levels (short & long timeframe) + +| Timeframe | Support (approx) | Resistance (approx) | Note | +|-----------|------------------|----------------------|------| +| 4h | $XX,XXX | $XX,XXX | Short-term structure | +| 1d | $XX,XXX | $XX,XXX | Trend & key levels | + +### Momentum (current price vs 24h volume vs past) + +| Metric | Value | Vs past | +|---------------|--------|---------| +| Current price | $XX,XXX | - | +| 24h volume | $X.XB | Above/below 7d avg | +| 24h change | ±X.XX% | - | + +Conclusion: Volume–price [aligned/divergent]; momentum [bullish/bearish/neutral]. + +### Funding & long/short (futures) + +| Contract | Funding rate | Long/short read | +|-----------|--------------|-----------------| +| BTC_USDT | ±0.0X% | Long/short cost elevated; short/long bias | + +### Short-term advice (e.g. 4h) + +[Based on short-term support/resistance and momentum: e.g. near support consider buying, near resistance reduce; or wait.] + +### Long-term advice (e.g. 1d) + +[Based on daily trend and key levels: e.g. uptrend → buy dips; or wait for breakout confirmation.] + +Analysis is data-based, not investment advice. +``` + +--- + +## Case 12: Multi-Asset Buy Analysis & Allocation + +### MCP Call Spec (document-aligned) + +**Trigger**: "I'm watching BTC, ETH and GT and want to buy some; analyze these three and give investment advice; I have $5000, how should I allocate across them?" + +**API choice**: Use **spot** for each asset (ticker + order book + last 7d daily); if user involves **futures** or needs futures view, also use **futures** (candlesticks + tickers + order_book + funding_rate) per asset. + +| Step | MCP Tool (spot) | MCP Tool (futures, when needed) | Parameters | Required Fields | +|------|-----------------|----------------------------------|------------|-----------------| +| 1 | `gate-cli cex spot market candlesticks` | `gate-cli cex futures market candlesticks` | Spot: `currency_pair={BASE}_USDT`, `interval=1d`, `limit=7`. Futures: `settle=usdt`, `contract={BASE}_USDT`, same interval/limit | Last 7d daily OHLC; volume | +| 2 | `gate-cli cex spot market tickers` | `gate-cli cex futures market tickers` | Same pair / contract + settle | `last`; 24h volume; change; liquidity reference | +| 3 | `gate-cli cex spot market orderbook` | `gate-cli cex futures market orderbook` | `limit=20` | Depth; bid-ask spread; large orders | +| 4 | — | `gate-cli cex futures market funding-rate` | `contract`+`settle` | Funding rate; futures add long/short cost | + +**Calculation & judgment** (aligned with SKILL): + +- **Spot**: Gate spot ticker (price, volume, change) + order book (depth, spread) + last 7d daily (trend, volatility). +- **Futures**: If analysis involves futures, add funding rate (positive → long cost high; negative → short cost high). +- **Allocation**: Combine volatility, liquidity, trend and risk per asset; for user’s total amount (e.g. $5000) give suggested weights (e.g. BTC X%, ETH Y%, GT Z%) with brief rationale. + +**Output**: Must include per-asset "Spot overview" (ticker + order book + 7d conclusion), "Futures funding" (if applicable), and "Allocation suggestion" (weights and brief rationale). + +--- + +### Scenario 12.1: Multi-asset analysis & allocation (e.g. BTC, ETH, GT, $5000) + +**Context**: User is watching several assets (e.g. BTC, ETH, GT), plans to buy, states total budget (e.g. $5000); needs analysis and allocation advice. + +**Prompt examples**: +- "I'm watching BTC, ETH and GT and want to buy; analyze these three and give investment advice; I have $5000, how should I allocate across them?" + +**Expected behavior**: +1. Call per **MCP Call Spec** for each of BTC_USDT, ETH_USDT, GT_USDT: `gate-cli cex spot market candlesticks`(interval=1d, limit=7) → `gate-cli cex spot market tickers` → `gate-cli cex spot market orderbook`(limit=20). +2. If futures view needed: for same contracts call `gate-cli cex futures market candlesticks` → `gate-cli cex futures market tickers` → `gate-cli cex futures market orderbook` → `gate-cli cex futures market funding-rate`. +3. From ticker: price and 24h performance; from order book: depth and spread; from 7d daily: short-term trend; from futures: funding rate. +4. Output: per-asset spot overview table + futures funding (if applicable) + allocation suggestion (e.g. BTC 40%, ETH 40%, GT 20% with brief rationale) per Report Template. + +**Output**: +```markdown +## Multi-Asset Analysis & Allocation (BTC / ETH / GT, budget $5000) + +### Per-asset spot overview (ticker + order book + last 7d daily) + +| Asset | Price | 24h change | 24h volume | Order book depth/spread | 7d trend (brief) | +|-------|-------|------------|------------|--------------------------|------------------| +| BTC | $XX | ±X% | $XX | Depth/spread | - | +| ETH | $XX | ±X% | $XX | Depth/spread | - | +| GT | $XX | ±X% | $XX | Depth/spread | - | + +### Futures funding rate (if applicable) + +| Contract | Funding rate | Note | +|-----------|--------------|-------------| +| BTC_USDT | ±X.XX% | Long/short cost | +| ETH_USDT | ±X.XX% | Same | +| GT_USDT | ±X.XX% | Same | + +### Allocation suggestion ($5000 example) + +| Asset | Suggested % | Amount | Brief rationale | +|-------|--------------|---------|------------------------------------| +| BTC | XX% | XXX U | Liquidity, moderate volatility | +| ETH | XX% | XXX U | Correlation with BTC, volatility | +| GT | XX% | XXX U | Smaller cap / volatile; size limit | + +Analysis is data-based, not investment advice. +``` + +--- + +## Case 13: Portfolio Allocation Review & Adjustment + +### MCP Call Spec (document-aligned) + +**Trigger**: "I hold 30% BTC, 30% ETH, 20% DOGE, 15% LTC, 5% USDT; is this allocation reasonable, how should I adjust, and if I don’t need to change it what else can I buy?" + +**API choice**: Same as Case 12 — use **spot** for each held asset (ticker + order book + last 7d daily); add **futures** funding rate when relevant. + +| Step | MCP Tool (spot) | MCP Tool (futures, when needed) | Parameters | Required Fields | +|------|-----------------|----------------------------------|------------|-----------------| +| 1 | `gate-cli cex spot market candlesticks` | `gate-cli cex futures market candlesticks` | Spot: `currency_pair={BASE}_USDT`, `interval=1d`, `limit=7`. Futures: same contract + settle | Last 7d daily OHLC; volume | +| 2 | `gate-cli cex spot market tickers` | `gate-cli cex futures market tickers` | Same pair / contract + settle | Current price; 24h volume; change | +| 3 | `gate-cli cex spot market orderbook` | `gate-cli cex futures market orderbook` | `limit=20` | Depth; spread | +| 4 | — | `gate-cli cex futures market funding-rate` | `contract`+`settle` | Funding rate | + +**Calculation & judgment** (aligned with SKILL): + +- **Spot**: Gate spot ticker + order book + last 7d daily (same as Case 12). +- **Futures**: If user holds or considers futures, add funding rate. +- **Allocation review**: From volatility, correlation, liquidity and current trend per asset, judge whether user’s allocation is too concentrated/diversified or risk too high; state "reasonable" or "suggest adjustment" and direction (e.g. reduce X, add Y or USDT). +- **If no adjustment**: If current allocation is fine, suggest "what else to buy" (e.g. other majors, stable yield) with brief rationale. + +**Output**: Must include per-asset "Spot overview" (same as Case 12), "Allocation assessment" (reasonable / suggest adjustment), "Adjustment suggestion" (if any), "Other names to consider" (if no adjustment). + +--- + +### Scenario 13.1: Portfolio allocation review & adjustment (e.g. BTC, ETH, DOGE, LTC, USDT) + +**Context**: User states current allocation (e.g. 30% BTC, 30% ETH, 20% DOGE, 15% LTC, 5% USDT) and asks if it’s reasonable, how to adjust, and what else to buy if no change. + +**Prompt examples**: +- "I hold 30% BTC, 30% ETH, 20% DOGE, 15% LTC, 5% USDT; is this allocation reasonable, how should I adjust my portfolio, and if I don’t need to change it what else can I buy?" + +**Expected behavior**: +1. Call per **MCP Call Spec** for each of BTC, ETH, DOGE, LTC: `gate-cli cex spot market candlesticks`(interval=1d, limit=7) → `gate-cli cex spot market tickers` → `gate-cli cex spot market orderbook`(limit=20); if futures involved, also call candlesticks → tickers → order_book → funding_rate for the contracts. +2. From 7d performance, liquidity, volatility and correlation per asset, assess user's allocation (e.g. majors 60%, alts 35%, cash 5% — risk acceptable or not). +3. Output: per-asset spot overview → allocation assessment (reasonable / suggest adjustment) → concrete adjustment (what to reduce/add) → if no change, "other names to consider" per Report Template. + +**Output**: +```markdown +## Portfolio Allocation Review & Advice (BTC / ETH / DOGE / LTC / USDT) + +### Per-asset spot overview (ticker + order book + last 7d daily) + +| Asset | Price | 24h change | 24h volume | Order book depth/spread | 7d trend (brief) | +|-------|-------|------------|------------|--------------------------|------------------| +| BTC | $XX | ±X% | $XX | - | - | +| ETH | $XX | ±X% | $XX | - | - | +| DOGE | $XX | ±X% | $XX | - | - | +| LTC | $XX | ±X% | $XX | - | - | + +### Allocation assessment + +Current allocation: BTC 30%, ETH 30%, DOGE 20%, LTC 15%, USDT 5%. + +Conclusion: [Reasonable / Suggest adjustment]. Rationale: [1–2 sentences on concentration, volatility, liquidity, correlation.] + +### Adjustment suggestion (if needed) + +[E.g. reduce DOGE weight, increase USDT for volatility buffer; or keep as is.] + +### If no adjustment, other names to consider + +[E.g. consider adding XXX, YYY for diversification or return; or keep current portfolio.] + +Analysis is data-based, not investment advice. +``` diff --git a/agents/market_making_expert/skills/gate-exchange-marketanalysis/setup.sh b/agents/market_making_expert/skills/gate-exchange-marketanalysis/setup.sh new file mode 100755 index 000000000..29df33a68 --- /dev/null +++ b/agents/market_making_expert/skills/gate-exchange-marketanalysis/setup.sh @@ -0,0 +1,123 @@ +#!/bin/sh +set -e + +REPO="gate/gate-cli" +BINARY="gate-cli" + +# --- Parse flags --- +VERSION="" +while [ $# -gt 0 ]; do + case "$1" in + --version) + if [ -z "$2" ]; then + echo "Error: --version requires a value (e.g. --version v0.3.2)" >&2 + exit 1 + fi + VERSION="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +# --- Detect OS and arch --- +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) + echo "Unsupported architecture: $ARCH" >&2 + exit 1 + ;; +esac + +case "$OS" in + linux|darwin) ;; + *) + echo "Unsupported OS: $OS" >&2 + exit 1 + ;; +esac + +# --- Resolve version --- +if [ -z "$VERSION" ]; then + VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') +fi + +# Fallback when API is rate-limited, blocked, or grep/sed yields nothing (matches gate-cli v0.6.0 per releases/latest). +if [ -z "$VERSION" ]; then + VERSION="v0.6.0" + echo "setup.sh: GitHub API returned no tag_name; using fallback ${VERSION}" >&2 +fi + +# Strip leading 'v' for the archive filename +BARE_VERSION="${VERSION#v}" +ARCHIVE="${BINARY}_${BARE_VERSION}_${OS}_${ARCH}.tar.gz" +BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" + +# --- Download --- +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +echo "Downloading ${ARCHIVE}..." +curl -fsSL "${BASE_URL}/${ARCHIVE}" -o "${TMP}/${ARCHIVE}" +curl -fsSL "${BASE_URL}/checksums.txt" -o "${TMP}/checksums.txt" + +# --- Verify checksum --- +echo "Verifying checksum..." +CHECKSUM_LINE=$(grep -F " ${ARCHIVE}" "${TMP}/checksums.txt" || true) +if [ -z "$CHECKSUM_LINE" ]; then + echo "Error: ${ARCHIVE} not found in checksums.txt" >&2 + exit 1 +fi +if command -v shasum > /dev/null 2>&1; then + (cd "$TMP" && echo "$CHECKSUM_LINE" | shasum -a 256 --check --status) +elif command -v sha256sum > /dev/null 2>&1; then + (cd "$TMP" && echo "$CHECKSUM_LINE" | sha256sum --check --status) +else + echo "Warning: no sha256sum or shasum found, skipping checksum verification" >&2 +fi + +# --- Extract --- +tar -xzf "${TMP}/${ARCHIVE}" -C "$TMP" "${BINARY}" + +# --- Install --- +install_bin() { + local dir="$1" + local use_sudo="$2" + if [ "$use_sudo" = "true" ]; then + sudo install -m 755 "${TMP}/${BINARY}" "${dir}/${BINARY}" + else + install -m 755 "${TMP}/${BINARY}" "${dir}/${BINARY}" + fi +} + +LOCAL_BIN="$HOME/.openclaw/skills/bin" +mkdir -p "$LOCAL_BIN" 2>/dev/null || true + +if install_bin "$LOCAL_BIN" "false" 2>/dev/null; then + INSTALL_DIR="$LOCAL_BIN" + # Check if it's on PATH + case ":$PATH:" in + *":${LOCAL_BIN}:"*) ;; + *) + echo "" + echo "Installed to ${LOCAL_BIN}/${BINARY}" + echo "Add the following to your shell profile to use it:" + echo " export PATH=\"\$HOME/.openclaw/skills/bin:\$PATH\"" + ;; + esac +else + SYSTEM_BIN="/usr/local/bin" + install_bin "$SYSTEM_BIN" "true" + INSTALL_DIR="$SYSTEM_BIN" +fi + +echo "" +echo "gate-cli ${VERSION} installed to ${INSTALL_DIR}/${BINARY}" +echo "Run: gate-cli --version" \ No newline at end of file diff --git a/agents/market_making_expert/skills/gate_exchange_marketanalysis/SKILL.md b/agents/market_making_expert/skills/gate_exchange_marketanalysis/SKILL.md new file mode 100644 index 000000000..216cfe598 --- /dev/null +++ b/agents/market_making_expert/skills/gate_exchange_marketanalysis/SKILL.md @@ -0,0 +1,75 @@ +--- +name: gate_exchange_marketanalysis +description: Gate.io perpetual market analysis framework — trend, volatility, volume + profile, order book, and funding rate read before deploying or adjusting any Gate + MM strategy. +when_to_use: Before deploying, resuming, or tuning any market-making strategy on gate_io_perpetual. + Run this analysis on the target pair to determine regime, optimal spread profile, + fill timing, and whether conditions favor symmetric or asymmetric quoting. +created: '2026-08-18T03:19:30Z' +source: agent:market_making_expert +--- + +## Gate Exchange Market Analysis — Framework + +### Step 1: Trend & Momentum +Pull 14-day hourly candles. Compute: +- 14-day range and % move +- 7-day and 1-day range +- Structure: higher highs / lower lows / ranging +- Flag: is today the first meaningful rejection? Or continuation? + +Regime labels: +- `volatile_trending`: >20% in 7d, expanding candle bodies, surging volume +- `volatile_ranging`: wide swings but no sustained direction +- `quiet_ranging`: <5% in 7d, small bodies, low volume — ideal for tight symmetric MM + +### Step 2: Volatility (ATR estimate from hourly candles) +- Quiet regime: candle bodies < $3–5, volume < 10K/hr +- Active regime: candle bodies $30–90, volume 50K+/hr +- Breakout regime: bodies $80–100+, volume 100K+/hr +→ Breakout/active = use asymmetric spreads; quiet = symmetric spreads viable + +### Step 3: Volume Profile +Identify the 3–5 highest-volume candles over 14 days: +- Volume spike at low = capitulation (buy-side opportunity) +- Volume spike at breakout = momentum confirmation +- Volume spike at new high = potential distribution +Current volume vs. prior quiet hours: if still >3× quiet baseline, regime is still hot. + +### Step 4: Order Book +Fetch snapshot. Key reads: +- Best bid/ask spread: if 1 tick → highly competitive, queue position matters +- Ask depth L1: if <0.1 contracts → paper thin, sell L1 fills instantly on any sweep +- Bid walls: large qty at a level = defended support, safe for buy-side quoting +- Ask walls: large qty at a level = resistance, good TP target zone + +MM implications: +- Thin ask → place sell L1 at least 2–3 ticks behind best ask for queue safety +- Fat bid wall → buy L1 can be placed just above it to compete for queue + +### Step 5: Funding Rate +- <+0.01%: neutral, no bias +- +0.01–0.03%: mild long premium — slight inventory lean to long is fine +- >+0.05%: crowded long — widen sell spreads or pause sells +- Negative: short squeeze risk — tighten buys, widen sells + +### Step 6: Regime → Spread Profile Decision +| Regime | Buy spreads | Sell spreads | Skew | Notes | +|---|---|---|---|---| +| quiet_ranging | 0.02/0.08/0.20% | 0.02/0.08/0.20% | 1.0 | Symmetric | +| volatile_ranging | 0.03/0.10/0.25% | 0.05/0.15/0.35% | 1.5 | Mild asymmetry | +| volatile_trending | 0.02/0.08/0.20% | 0.04/0.16/0.40% | 1.8–2.0 | Sell side 2× wider | + +### Step 7: Gate.io Rebate Check +- Confirm VIP tier has negative maker fee (target: −0.015%) +- take_profit=0.0003 (0.03%) = breakeven without rebate +- With rebate: +0.06% gross per round-trip +- Circuit breaker: if net PnL per fill is negative after first 20 fills → pause and check tier + +### Output +After running this framework, state: +- regime: