From df6596cd50d6d997329edc8a5ae1bfcf4f04da14 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 12 Aug 2026 18:22:53 -0700 Subject: [PATCH 01/18] =?UTF-8?q?feat(executors):=20orphaned-position=20li?= =?UTF-8?q?fecycle=20for=20LP=20executors=20=E2=80=94=20flag,=20listing,?= =?UTF-8?q?=20DB-aware=20stop,=20resolve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API-side of the gateway#678 retry-ownership work (canonical design: docs/retry-architecture.md in the companion gateway PR). Executors created via the API run in-process with no controller, so the API owns the "react to a stranded position" role: - Stop on a terminal executor returns already_terminated with close_type, position_address, orphaned_position, and hold_reason instead of the 404 dead end #678 hit (terminal executors are popped from memory within one tick, so "not in memory" almost always means "already terminated"). 404 is reserved for ids the DB has never seen. - Completion flags stranded exposure in the persisted final state: an involuntary hold (POSITION_HOLD with hold_reason set — an LP close that exhausted its retries) or a legacy FAILED-with-position gets orphaned_position: true and an error-level log. Voluntary holds never match (a successful close clears position_address first). - GET /executors/positions/orphaned lists recovery candidates (SQL-filtered to lp_executor; involuntary holds, FAILED-with-position, and SYSTEM_CLEANUP restarts flagged needs_onchain_reconciliation). - POST /executors/{id}/resolve-orphan marks a candidate recovered after the position is closed externally, silencing listings and warnings. - bots/controllers lp_rebalancer mirror: halt + skip accounting for executors that ended with a live position (re-creating one would mint a second position on top of the stranded one). Validated live on mainnet: forced close-failure cascade terminated as the involuntary hold, surfaced in the orphan listing with hold_reason, re-stop returned already_terminated, and resolve-orphan cleared it after a direct gateway close recovered all funds + rent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HahKfEY9rvKnZijrzUAFSq --- .../generic/lp_rebalancer/lp_rebalancer.py | 63 +++++-- database/repositories/executor_repository.py | 22 +++ models/__init__.py | 4 + models/executors.py | 46 ++++- routers/executors.py | 52 ++++++ services/executor_service.py | 164 ++++++++++++++++++ 6 files changed, 339 insertions(+), 12 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index fd4c43d6..8793f4ce 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -58,7 +58,8 @@ class LPRebalancerConfig(ControllerConfigBase): position_offset_pct: Decimal = Field( default=Decimal("0.01"), json_schema_extra={"is_updatable": True}, - description="Offset from current price. Positive = out-of-range (single-sided). Negative = in-range (needs both tokens, autoswap will convert |offset|%)" + description="Offset from current price. Positive = out-of-range (single-sided). " + "Negative = in-range (needs both tokens, autoswap will convert |offset|%)" ) # Rebalance threshold - used to set LP executor's limit prices @@ -184,6 +185,9 @@ def __init__(self, config: LPRebalancerConfig, *args, **kwargs): # Track the executor we created self._current_executor_id: Optional[str] = None + # Set when a FAILED LP executor still reports a live on-chain position; the + # controller halts new position creation until it is recovered manually + self._orphaned_position_address: Optional[str] = None # Track amounts from last closed position (for autoswap sizing) self._last_closed_base_amount: Optional[Decimal] = None @@ -416,6 +420,16 @@ def determine_executor_actions(self) -> List[ExecutorAction]: actions = [] + # An orphaned on-chain position (FAILED close) must be recovered before any new + # position is opened - creating a fresh executor here would stack live exposure + # on top of the stranded one. + if self._orphaned_position_address: + self.logger().debug( + f"Halted: position {self._orphaned_position_address} from a FAILED executor is " + "still open on-chain and requires manual recovery" + ) + return actions + # Handle order executor tracking and completion (for autoswap) if self._pending_swap_side is not None: if not self._swap_executor_id: @@ -509,10 +523,15 @@ def determine_executor_actions(self) -> List[ExecutorAction]: # Previous executor terminated - capture final amounts and update position_hold terminated_executor = self.get_tracked_executor() if terminated_executor: - # Skip position_hold update if executor failed (no tokens were actually deposited/returned) - if terminated_executor.close_type == CloseType.FAILED: + # Skip position_hold update if the executor failed (nothing deposited or + # returned) or ended as an involuntary hold (close exhausted): in the + # latter case base/quote amounts are pool balances of a still-open + # position, and booking them as returned tokens would corrupt the hold. + if (terminated_executor.close_type == CloseType.FAILED + or terminated_executor.custom_info.get("hold_reason")): self.logger().warning( - f"Executor {terminated_executor.id} FAILED - skipping position_hold update" + f"Executor {terminated_executor.id} ended {terminated_executor.close_type} " + "without returning tokens - skipping position_hold update" ) else: self._last_closed_base_amount = Decimal(str(terminated_executor.custom_info.get("base_amount", 0))) @@ -539,16 +558,32 @@ def determine_executor_actions(self) -> List[ExecutorAction]: f"Position hold total: base={self._position_hold_base}, quote={self._position_hold_quote}" ) - # Check if executor FAILED - retry with same side from executor's config + # Check if the executor went terminal abnormally - FAILED (nothing on-chain) + # or an involuntary POSITION_HOLD (close retries exhausted, hold_reason set) executor_failed = terminated_executor and terminated_executor.close_type == CloseType.FAILED + involuntary_hold = bool(terminated_executor and terminated_executor.custom_info.get("hold_reason")) failed_executor_side = None - if executor_failed: + if executor_failed or involuntary_hold: failed_executor_side = terminated_executor.custom_info.get("side") + # A terminal executor that still reports a position address went down on + # the CLOSE side: its deposit is still on-chain (involuntary hold, or a + # legacy FAILED-with-position from a force-stop). Re-opening would stack + # a second position on top of the stranded one. + orphaned_position = terminated_executor.custom_info.get("position_address") + if orphaned_position: + self._orphaned_position_address = orphaned_position + self._current_executor_id = None + self.logger().error( + f"Executor {terminated_executor.id} ended {terminated_executor.close_type} " + f"with position {orphaned_position} still open on-chain. Halting new " + "position creation until the position is closed or recovered manually." + ) + return actions # Capture closed position bounds for side determination (only for successful closes) closed_lower_price = None closed_upper_price = None - if terminated_executor and not executor_failed: + if terminated_executor and not executor_failed and not involuntary_hold: closed_lower_price = Decimal(str(terminated_executor.custom_info.get("lower_price", 0))) closed_upper_price = Decimal(str(terminated_executor.custom_info.get("upper_price", 0))) @@ -576,7 +611,10 @@ def determine_executor_actions(self) -> List[ExecutorAction]: else: # Price is within old bounds (shouldn't happen with limit-price auto-close) side = self._determine_side_from_price(self._pool_price) - self.logger().info(f"Price {self._pool_price} in range [{closed_lower_price}, {closed_upper_price}] → side={side} from limits") + self.logger().info( + f"Price {self._pool_price} in range [{closed_lower_price}, {closed_upper_price}] " + f"→ side={side} from limits" + ) else: # Fallback to price limits if not self._pool_price: @@ -941,7 +979,8 @@ def to_format_status(self) -> List[str]: width = self.config.position_width_pct offset = self.config.position_offset_pct threshold = self.config.rebalance_threshold_pct - line = f"| Config: side={side_str}, amount={amt} {self._quote_token}, width={width}%, offset={offset}%, threshold={threshold}%" + line = (f"| Config: side={side_str}, amount={amt} {self._quote_token}, " + f"width={width}%, offset={offset}%, threshold={threshold}%") status.append(line + " " * (box_width - len(line) + 1) + "|") status.append("|" + " " * box_width + "|") @@ -985,7 +1024,8 @@ def to_format_status(self) -> List[str]: lower_limit = Decimal(str(lower_price)) * (Decimal("1") - threshold_pct) upper_limit = Decimal(str(upper_price)) * (Decimal("1") + threshold_pct) - line = f"| Price: {float(self._pool_price):.{price_decimals}f} | Auto-close if: <{float(lower_limit):.{price_decimals}f} or >{float(upper_limit):.{price_decimals}f}" + line = (f"| Price: {float(self._pool_price):.{price_decimals}f} | Auto-close if: " + f"<{float(lower_limit):.{price_decimals}f} or >{float(upper_limit):.{price_decimals}f}") status.append(line + " " * (box_width - len(line) + 1) + "|") state = custom.get("state", "UNKNOWN") @@ -1070,7 +1110,8 @@ def to_format_status(self) -> List[str]: line = f"| Swaps Executed: {len(closed_swaps)}" status.append(line + " " * (box_width - len(line) + 1) + "|") - line = f"| Fees Collected: {float(total_fees_base):.6f} {self._base_token} + {float(total_fees_quote):.6f} {self._quote_token} = {float(total_fees_value):.6f} {self._quote_token}" + line = (f"| Fees Collected: {float(total_fees_base):.6f} {self._base_token} + " + f"{float(total_fees_quote):.6f} {self._quote_token} = {float(total_fees_value):.6f} {self._quote_token}") status.append(line + " " * (box_width - len(line) + 1) + "|") status.append("+" + "-" * box_width + "+") diff --git a/database/repositories/executor_repository.py b/database/repositories/executor_repository.py index c6c6c0b4..5ebdd4ae 100644 --- a/database/repositories/executor_repository.py +++ b/database/repositories/executor_repository.py @@ -134,6 +134,28 @@ async def get_executors( result = await self.session.execute(stmt) return list(result.scalars().all()) + async def get_executors_by_close_types( + self, + close_types: List[str], + executor_type: Optional[str] = None, + limit: Optional[int] = 500, + ) -> List[ExecutorRecord]: + """Get executors whose close_type is one of the given values, newest first. + + Filter by executor_type in SQL where possible: applying `limit` to a broad + candidate set and filtering in Python can silently drop the very rows the + caller is looking for once the newest N candidates are all irrelevant. + """ + stmt = select(ExecutorRecord).where(ExecutorRecord.close_type.in_(close_types)) + if executor_type: + stmt = stmt.where(ExecutorRecord.executor_type == executor_type) + stmt = stmt.order_by(desc(ExecutorRecord.created_at)) + if limit is not None: + stmt = stmt.limit(limit) + + result = await self.session.execute(stmt) + return list(result.scalars().all()) + async def get_active_executors( self, account_name: Optional[str] = None, diff --git a/models/__init__.py b/models/__init__.py index 8ecd9eb3..0fbe6a8a 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -70,6 +70,8 @@ ExecutorFilterRequest, ExecutorResponse, ExecutorsSummaryResponse, + OrphanedPositionRecord, + OrphanedPositionsResponse, StopExecutorRequest, StopExecutorResponse, ) @@ -384,4 +386,6 @@ "ExecutorResponse", "ExecutorDetailResponse", "ExecutorsSummaryResponse", + "OrphanedPositionRecord", + "OrphanedPositionsResponse", ] diff --git a/models/executors.py b/models/executors.py index 278fd2a2..c261d5f2 100644 --- a/models/executors.py +++ b/models/executors.py @@ -421,8 +421,52 @@ class CreateExecutorResponse(BaseModel): class StopExecutorResponse(BaseModel): """Response after stopping an executor.""" executor_id: str = Field(description="Executor identifier") - status: str = Field(description="New status (usually 'stopping')") + status: str = Field(description="New status: 'stopping', or 'already_terminated' when the stop was a no-op") keep_position: bool = Field(description="Whether position was kept open") + close_type: Optional[str] = Field(default=None, description="Final close_type when already terminated") + position_address: Optional[str] = Field( + default=None, description="On-chain position address from the executor's final state, if any" + ) + orphaned_position: bool = Field( + default=False, + description="True when the executor terminated with a live on-chain position that needs recovery" + ) + hold_reason: Optional[str] = Field( + default=None, + description="Why a POSITION_HOLD terminal was involuntary (e.g. close_retries_exhausted); None for voluntary holds" + ) + + +class OrphanedPositionRecord(BaseModel): + """A terminated executor that may still own an on-chain position.""" + executor_id: str = Field(description="Executor identifier") + executor_type: str = Field(description="Executor type (e.g. lp_executor)") + account_name: Optional[str] = Field(default=None, description="Account name") + connector_name: Optional[str] = Field(default=None, description="Connector name") + trading_pair: Optional[str] = Field(default=None, description="Trading pair") + controller_id: str = Field(default="main", description="Controller/agent grouping label") + close_type: Optional[str] = Field( + default=None, description="POSITION_HOLD (involuntary hold), FAILED, or SYSTEM_CLEANUP" + ) + closed_at: Optional[str] = Field(default=None, description="Termination timestamp (ISO format)") + position_address: Optional[str] = Field( + default=None, description="On-chain position address (None for restart cleanups, which never persisted state)" + ) + state: Optional[str] = Field(default=None, description="Executor state at termination (e.g. FAILED, CLOSING)") + hold_reason: Optional[str] = Field( + default=None, + description="Why the hold was involuntary (e.g. close_retries_exhausted); None for legacy FAILED/SYSTEM_CLEANUP records" + ) + needs_onchain_reconciliation: bool = Field( + default=False, + description="True when the position address is unknown and on-chain state must be checked externally" + ) + + +class OrphanedPositionsResponse(BaseModel): + """Terminated executors that may have stranded on-chain positions.""" + count: int = Field(description="Number of orphan candidates") + orphans: List[OrphanedPositionRecord] = Field(description="Orphan candidate records") class ExecutorsSummaryResponse(BaseModel): diff --git a/routers/executors.py b/routers/executors.py index 1110fd43..bffe1552 100644 --- a/routers/executors.py +++ b/routers/executors.py @@ -18,6 +18,7 @@ ExecutorFilterRequest, ExecutorLogsResponse, ExecutorsSummaryResponse, + OrphanedPositionsResponse, PerformanceReportResponse, PositionHoldResponse, PositionsSummaryResponse, @@ -357,6 +358,57 @@ async def stop_executor( # Position Hold Endpoints # ======================================== +@router.get("/positions/orphaned", response_model=OrphanedPositionsResponse) +async def get_orphaned_positions( + executor_service: ExecutorService = Depends(get_executor_service) +): + """ + List terminated executors that may still own an on-chain position. + + Covers three orphan classes: + - Involuntary holds: close_type POSITION_HOLD with hold_reason set (an LP close + that exhausted its retries): the position is live on-chain with no automated + owner. + - Legacy FAILED records whose final state still reported a position_address + (force-stop stragglers, records persisted by older executors). + - Executors terminated by SYSTEM_CLEANUP after an API restart: their on-chain + state was never persisted, so they need external reconciliation. + + This is a DB-side listing. Before recovering, cross-check candidates against + on-chain reality via the gateway positions-owned endpoints + (/trading/clmm/positions-owned, /trading/amm/positions-owned). + """ + try: + orphans = await executor_service.get_orphaned_positions() + return OrphanedPositionsResponse(count=len(orphans), orphans=orphans) + except Exception as e: + logger.error(f"Error listing orphaned positions: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error listing orphaned positions: {str(e)}") + + +@router.post("/{executor_id}/resolve-orphan") +async def resolve_orphaned_position( + executor_id: str, + executor_service: ExecutorService = Depends(get_executor_service) +): + """ + Mark an orphaned position as recovered. + + Call after the stranded on-chain position has been closed (or adopted) + externally. Removes the executor from /executors/positions/orphaned and from + agent-facing orphan warnings. Only valid for terminated executors that are + orphan candidates: an involuntary hold (POSITION_HOLD with hold_reason or the + orphaned_position flag), FAILED, or SYSTEM_CLEANUP. + """ + try: + return await executor_service.resolve_orphaned_position(executor_id) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error resolving orphaned position for {executor_id}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error resolving orphaned position: {str(e)}") + + @router.get("/positions/summary", response_model=PositionsSummaryResponse) async def get_positions_summary( controller_id: Optional[str] = None, diff --git a/services/executor_service.py b/services/executor_service.py index 21c235c1..8288daee 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -629,6 +629,31 @@ async def stop_executor( """ executor = self._active_executors.get(executor_id) if not executor: + # Terminal executors are popped from memory within one control-loop tick, + # so "not in memory" usually means "already terminated", not "unknown". + # Fall back to the DB and answer with the final state as a no-op success. + # This deliberately includes rows still marked RUNNING in the DB: an + # executor known to the DB but absent from memory is dead regardless of + # its stored status (completion race, failed persist, restart window), + # and answering 404 there is the gateway#678 dead end. 404 is reserved + # for executor ids the DB has never seen (or a DB outage, which + # get_executor logs and swallows to None). + db_record = await self.get_executor(executor_id) + if db_record: + custom_info = db_record.get("custom_info") or {} + logger.info( + f"Stop requested for already-terminated executor {executor_id} " + f"(db status: {db_record.get('status')}, close_type: {db_record.get('close_type')}) - no-op" + ) + return { + "executor_id": executor_id, + "status": "already_terminated", + "keep_position": keep_position, + "close_type": db_record.get("close_type"), + "position_address": custom_info.get("position_address"), + "orphaned_position": bool(custom_info.get("orphaned_position", False)), + "hold_reason": custom_info.get("hold_reason"), + } raise HTTPException(status_code=404, detail=f"Executor {executor_id} not found") if executor.is_closed: @@ -649,6 +674,116 @@ async def stop_executor( "keep_position": keep_position } + async def get_orphaned_positions(self) -> List[Dict[str, Any]]: + """ + List executors that terminated while potentially still owning an on-chain position. + + Covers both orphan classes: + - close_type FAILED with a position_address in the persisted final state + (e.g. an LP close that exhausted retries - gateway#678) + - close_type SYSTEM_CLEANUP (RUNNING rows rewritten after an API restart); + these have no final state, so the position address is unknown and the + on-chain state must be reconciled externally + + This listing is DB-side only: cross-check candidates against on-chain reality + (gateway CLMM/AMM positions-owned endpoints) before recovering. Recovered + orphans are silenced with resolve_orphaned_position(). + + Raises on DB errors rather than returning [] - "no orphans" from a broken DB + would read as all-clear on a safety endpoint. + """ + if not self.db_manager: + raise RuntimeError("Orphan listing unavailable: no database configured") + + # lp_executor is the only executor type that owns an on-chain position + # account; filtering in SQL keeps the limit meaningful (a Python-side filter + # over the newest N mixed candidates can silently drop older real orphans). + async with self.db_manager.get_session_context() as session: + repo = ExecutorRepository(session) + records = await repo.get_executors_by_close_types( + ["FAILED", "SYSTEM_CLEANUP", "POSITION_HOLD"], executor_type="lp_executor" + ) + + orphans: List[Dict[str, Any]] = [] + for record in records: + final_state: Dict[str, Any] = {} + if record.final_state: + try: + final_state = json.loads(record.final_state) + except (json.JSONDecodeError, TypeError): + final_state = {} + + if final_state.get("orphan_resolved"): + continue + + position_address = final_state.get("position_address") + if record.close_type == "FAILED" and not position_address: + # Failed without on-chain exposure - not an orphan + continue + if record.close_type == "POSITION_HOLD" and not ( + final_state.get("orphaned_position") or final_state.get("hold_reason") + ): + # Voluntary hold (keep_position=True stop) - position was closed on-chain + continue + + orphans.append({ + "executor_id": record.executor_id, + "executor_type": record.executor_type, + "account_name": record.account_name, + "connector_name": record.connector_name, + "trading_pair": record.trading_pair, + "controller_id": record.controller_id or "main", + "close_type": record.close_type, + "closed_at": record.closed_at.isoformat() if record.closed_at else None, + "position_address": position_address, + "state": final_state.get("state"), + "hold_reason": final_state.get("hold_reason"), + "needs_onchain_reconciliation": position_address is None, + }) + + return orphans + + async def resolve_orphaned_position(self, executor_id: str) -> Dict[str, Any]: + """ + Mark an orphaned position as recovered so it stops surfacing. + + Call this after the stranded on-chain position has been closed (or adopted) + externally. Sets orphan_resolved in the persisted final state, which removes + the record from get_orphaned_positions() and from agent-facing warnings. + """ + if not self.db_manager: + raise HTTPException(status_code=503, detail="No database configured") + + async with self.db_manager.get_session_context() as session: + repo = ExecutorRepository(session) + record = await repo.get_executor_by_id(executor_id) + if not record: + raise HTTPException(status_code=404, detail=f"Executor {executor_id} not found") + if record.status == "RUNNING": + raise HTTPException(status_code=400, detail=f"Executor {executor_id} is still running") + + final_state: Dict[str, Any] = {} + if record.final_state: + try: + final_state = json.loads(record.final_state) + except (json.JSONDecodeError, TypeError): + final_state = {} + + is_involuntary_hold = record.close_type == "POSITION_HOLD" and ( + final_state.get("orphaned_position") or final_state.get("hold_reason") + ) + if record.close_type not in ("FAILED", "SYSTEM_CLEANUP") and not is_involuntary_hold: + raise HTTPException( + status_code=400, + detail=f"Executor {executor_id} (close_type: {record.close_type}) is not an orphan candidate", + ) + final_state["orphaned_position"] = False + final_state["orphan_resolved"] = True + await repo.update_executor(executor_id=executor_id, final_state=json.dumps(final_state)) + + logger.info(f"Orphaned position for executor {executor_id} marked resolved") + return {"executor_id": executor_id, "orphan_resolved": True} + async def _handle_executor_completion(self, executor_id: str): """Handle cleanup when an executor completes.""" # Atomically claim the executor so a concurrent completion (e.g. the @@ -679,6 +814,24 @@ async def _handle_executor_completion(self, executor_id: str): close_type = executor.close_type.name if executor.close_type else "UNKNOWN" logger.info(f"Executor {executor_id} completed with close_type: {close_type}") + # Surface stranded on-chain exposure loudly: a live position address on an + # involuntary hold (hold_reason set) or a legacy FAILED means the position + # has no automated owner from this point on. + if executor.close_type in (CloseType.FAILED, CloseType.POSITION_HOLD): + try: + completion_info = executor.get_custom_info() + position_address = completion_info.get("position_address") + hold_reason = completion_info.get("hold_reason") + except Exception: + position_address = None + hold_reason = None + if position_address and (hold_reason or executor.close_type == CloseType.FAILED): + logger.error( + f"Executor {executor_id} ended {close_type} with position {position_address} " + f"still open on-chain (hold_reason: {hold_reason}) - orphaned position requires " + "recovery (flagged in DB record; see /executors/positions/orphaned)" + ) + def _format_executor_info( self, executor_id: str, @@ -996,6 +1149,17 @@ async def _persist_executor_completed(self, executor_id: str, executor: Executor # Get custom_info directly from executor to avoid Pydantic serialization issues # with TrackedOrder and other complex types custom_info = executor.get_custom_info() + + # A stranded live on-chain position: an involuntary hold (close retries + # exhausted -> POSITION_HOLD with hold_reason set, gateway#678) or a legacy + # FAILED-with-position (force-stop straggler, older wheel). Flag it in the + # persisted final_state so /executors/positions/orphaned, dashboards, and + # agents can find and recover it. Voluntary holds never match: a successful + # close clears position_address before the executor terminates. + if custom_info.get("position_address") and ( + custom_info.get("hold_reason") or close_type == "FAILED" + ): + custom_info["orphaned_position"] = True # Serialize custom_info, fallback to None if serialization fails final_state_json = None metadata = self._executor_metadata.get(executor_id, {}) From 4fa3172266019bb185f998220c71b6ffd696bf14 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 13 Aug 2026 09:57:23 -0700 Subject: [PATCH 02/18] feat(clmm): pass bin_count through to Gateway pool-info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /gateway/clmm/pool-info accepts bin_count and forwards it to Gateway's unified trading/clmm/pool-info, so the per-tick liquidity distribution is reachable for orca, raydium, uniswap and pancakeswap (Meteora always returns its own bins). The response model already carried bins. Requests with bin_count > 0 skip the direct-Raydium-API shortcut: that API returns no bin distribution, and only Gateway computes it from on-chain ticks. Also wraps pre-existing long lines and drops an unused import in routers/gateway_clmm.py — the flake8 pre-commit hook lints the whole file and would not otherwise accept a commit touching it. Co-Authored-By: Claude Fable 5 --- routers/gateway_clmm.py | 59 ++++++++++++++++++++++++-------------- services/gateway_client.py | 17 ++++++++--- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 7e9c92ec..c3ec25b0 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -26,7 +26,6 @@ CLMMPositionInfo, CLMMPositionsOwnedRequest, CLMMRemoveLiquidityRequest, - TimeBasedMetrics, ) from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error @@ -258,7 +257,7 @@ async def _refresh_position_data(position, accounts_service: AccountsService, cl ) logger.debug(f"Refreshed position {position.position_address}: price={current_price}, in_range={in_range}, " - f"base={base_token_amount}, quote={quote_token_amount}") + f"base={base_token_amount}, quote={quote_token_amount}") except Exception as e: logger.error(f"Error refreshing position {position.position_address}: {e}", exc_info=True) @@ -270,6 +269,7 @@ async def get_clmm_pool_info( connector: str, network: str, pool_address: str, + bin_count: int = 0, accounts_service: AccountsService = Depends(get_accounts_service) ): """ @@ -279,20 +279,27 @@ async def get_clmm_pool_info( connector: CLMM connector (e.g., 'meteora', 'raydium') network: Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta') pool_address: Pool contract address + bin_count: If > 0, include the per-tick liquidity distribution (`bins`) + around the active tick. Meteora always returns its bins and ignores + this; orca, raydium, uniswap and pancakeswap honour it. Example: - GET /gateway/clmm/pool-info?connector=meteora&network=solana-mainnet-beta&pool_address=2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3 + GET /gateway/clmm/pool-info?connector=meteora&network=solana-mainnet-beta + &pool_address=2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3 Returns: Pool information including liquidity, price, bins (for Meteora), etc. All field names are returned in snake_case format. Note: - For Raydium connector, uses Raydium API directly instead of Gateway. + For Raydium connector, uses Raydium API directly instead of Gateway, + except when bin_count > 0 — the Raydium API returns no bin distribution, + so those requests go to Gateway, which computes it from on-chain ticks. """ try: - # Special handling for Raydium - use Raydium API directly (not Gateway) - if connector.lower() == "raydium": + # Special handling for Raydium - use Raydium API directly (not Gateway). + # Skipped when bins are requested: only Gateway can produce them. + if connector.lower() == "raydium" and not bin_count: logger.info(f"Using Raydium API directly for pool info: {pool_address}") # Fetch from Raydium API @@ -323,7 +330,8 @@ async def get_clmm_pool_info( result = check_gateway_error(await accounts_service.gateway_client.clmm_pool_info( connector=connector, chain_network=network, - pool_address=pool_address + pool_address=pool_address, + bin_count=bin_count )) # Parse the camelCase Gateway response into snake_case Pydantic model @@ -520,7 +528,8 @@ async def open_clmm_position( # Position address can be at root level or nested in data object data = result.get("data", {}) - position_address = result.get("positionAddress") or result.get("position") or data.get("positionAddress") or data.get("position") + position_address = (result.get("positionAddress") or result.get("position") + or data.get("positionAddress") or data.get("position")) # Extract position rent (SOL locked for position NFT) position_rent = data.get("positionRent") @@ -590,7 +599,8 @@ async def open_clmm_position( } await clmm_repo.create_event(event_data) - logger.info(f"Recorded CLMM OPEN event in database: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + logger.info(f"Recorded CLMM OPEN event in database: {transaction_hash} " + f"(status: {tx_status}, gas: {gas_fee} {gas_token})") except Exception as db_error: # Log but don't fail the operation - it was submitted successfully logger.error(f"Error recording CLMM position in database: {db_error}", exc_info=True) @@ -692,7 +702,8 @@ async def add_liquidity_to_clmm_position( "status": tx_status } await clmm_repo.create_event(event_data) - logger.info(f"Recorded CLMM ADD_LIQUIDITY event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + logger.info(f"Recorded CLMM ADD_LIQUIDITY event: {transaction_hash} " + f"(status: {tx_status}, gas: {gas_fee} {gas_token})") except Exception as db_error: logger.error(f"Error recording ADD_LIQUIDITY event: {db_error}", exc_info=True) @@ -784,7 +795,8 @@ async def remove_liquidity_from_clmm_position( "status": tx_status } await clmm_repo.create_event(event_data) - logger.info(f"Recorded CLMM REMOVE_LIQUIDITY event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + logger.info(f"Recorded CLMM REMOVE_LIQUIDITY event: {transaction_hash} " + f"(status: {tx_status}, gas: {gas_fee} {gas_token})") except Exception as db_error: logger.error(f"Error recording REMOVE_LIQUIDITY event: {db_error}", exc_info=True) @@ -876,7 +888,8 @@ async def close_clmm_position( base_fee_to_collect = Decimal(str(pos.get("baseFeeAmount", 0))) quote_fee_to_collect = Decimal(str(pos.get("quoteFeeAmount", 0))) close_price = float(pos.get("price", 0)) if pos.get("price") else None - logger.info(f"Before closing: price={close_price}, pending fees base={base_fee_to_collect}, quote={quote_fee_to_collect}") + logger.info(f"Before closing: price={close_price}, pending fees " + f"base={base_fee_to_collect}, quote={quote_fee_to_collect}") break else: logger.warning(f"Could not find position {request.position_address} in positions_owned response") @@ -909,7 +922,8 @@ async def close_clmm_position( # Use response values if available, otherwise use pre-fetched values base_fee_collected = Decimal(str(base_fee_from_response)) if base_fee_from_response is not None else base_fee_to_collect - quote_fee_collected = Decimal(str(quote_fee_from_response)) if quote_fee_from_response is not None else quote_fee_to_collect + quote_fee_collected = (Decimal(str(quote_fee_from_response)) + if quote_fee_from_response is not None else quote_fee_to_collect) logger.info(f"Collected fees on close: base={base_fee_collected}, quote={quote_fee_collected}") @@ -933,7 +947,8 @@ async def close_clmm_position( "status": tx_status } await clmm_repo.create_event(event_data) - logger.info(f"Recorded CLMM CLOSE event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + logger.info(f"Recorded CLMM CLOSE event: {transaction_hash} " + f"(status: {tx_status}, gas: {gas_fee} {gas_token})") # Update position: add to collected, reset pending to 0, mark as CLOSED new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected @@ -972,14 +987,16 @@ async def close_clmm_position( status_code = verify_result.get("status") if status_code in (404, 500): await clmm_repo.close_position(request.position_address) - logger.info(f"Position {request.position_address} verified as closed (Gateway returned {status_code})") + logger.info(f"Position {request.position_address} verified as closed " + f"(Gateway returned {status_code})") else: logger.warning(f"Unexpected error verifying position close: {verify_result}") elif verify_result and "address" in verify_result: # Position still exists - might be a failed close or delayed propagation - logger.warning(f"Position {request.position_address} still exists after close transaction. Will be handled by poller.") + logger.warning(f"Position {request.position_address} still exists after close " + "transaction. Will be handled by poller.") else: - logger.debug(f"Could not verify position close status, will be handled by poller") + logger.debug("Could not verify position close status, will be handled by poller") except Exception as verify_error: logger.warning(f"Error verifying position close: {verify_error}. Will be handled by poller.") @@ -1104,7 +1121,8 @@ async def collect_fees_from_clmm_position( # Use response values if available, otherwise use pre-fetched values base_fee_collected = Decimal(str(base_fee_from_response)) if base_fee_from_response is not None else base_fee_to_collect - quote_fee_collected = Decimal(str(quote_fee_from_response)) if quote_fee_from_response is not None else quote_fee_to_collect + quote_fee_collected = (Decimal(str(quote_fee_from_response)) + if quote_fee_from_response is not None else quote_fee_to_collect) # Extract gas fee from Gateway response gas_fee = data.get("fee") @@ -1132,7 +1150,8 @@ async def collect_fees_from_clmm_position( "status": tx_status } await clmm_repo.create_event(event_data) - logger.info(f"Recorded CLMM COLLECT_FEES event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + logger.info(f"Recorded CLMM COLLECT_FEES event: {transaction_hash} " + f"(status: {tx_status}, gas: {gas_fee} {gas_token})") # Update position: add to collected, reset pending to 0 new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected @@ -1405,5 +1424,3 @@ async def search_clmm_positions( except Exception as e: logger.error(f"Error searching CLMM positions: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Error searching CLMM positions: {str(e)}") - - diff --git a/services/gateway_client.py b/services/gateway_client.py index cc15d8d1..dc3848a6 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -672,14 +672,23 @@ async def clmm_pool_info( self, connector: str, chain_network: str, - pool_address: str + pool_address: str, + bin_count: int = 0 ) -> Dict: - """Get detailed CLMM pool information by pool address""" - return await self._request("GET", "trading/clmm/pool-info", params={ + """Get detailed CLMM pool information by pool address. + + bin_count > 0 asks Gateway for the per-tick liquidity distribution + (`bins`) around the active tick. Meteora always returns its bins and + ignores the parameter; orca, raydium, uniswap and pancakeswap honour it. + """ + params = { "connector": connector, "chainNetwork": chain_network, "poolAddress": pool_address - }) + } + if bin_count: + params["binCount"] = bin_count + return await self._request("GET", "trading/clmm/pool-info", params=params) async def clmm_fetch_pools( self, From c4ebae4eb45b7973c48a472c806c6e8396125894 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 13 Aug 2026 10:17:53 -0700 Subject: [PATCH 03/18] refactor(clmm): route Raydium pool-info through Gateway like every other connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /gateway/clmm/pool-info special-cased Raydium: it skipped Gateway entirely, called api-v3.raydium.io directly, and reshaped that response to look like Gateway's. That divergence cost real data — the transform hardcoded active_bin_id to None, bin_step to 1, and bins to [] — and it meant Raydium could not answer bin_count at all, since only Gateway computes the tick distribution. Raydium now takes the same path as meteora/orca/uniswap/pancakeswap. The Raydium API helpers and their aiohttp import go with it. Co-Authored-By: Claude Fable 5 --- routers/gateway_clmm.py | 119 ---------------------------------------- 1 file changed, 119 deletions(-) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index c3ec25b0..4b8a91a2 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -7,7 +7,6 @@ from decimal import Decimal from typing import List, Optional -import aiohttp from fastapi import APIRouter, Depends, HTTPException, Query from database import AsyncDatabaseManager @@ -35,94 +34,6 @@ router = APIRouter(tags=["Gateway CLMM"], prefix="/gateway") -async def fetch_raydium_pool_info(pool_address: str) -> Optional[dict]: - """ - Fetch pool info from Raydium API. - - Args: - pool_address: Pool contract address - - Returns: - Dictionary with pool info from Raydium API, or None if failed - """ - try: - url = f"https://api-v3.raydium.io/pools/info/ids?ids={pool_address}" - async with aiohttp.ClientSession() as session: - async with session.get(url, headers={"accept": "application/json"}) as response: - response.raise_for_status() - data = await response.json() - - if not data.get("success"): - logger.error(f"Raydium API returned unsuccessful response: {data}") - return None - - # Extract the first pool from the data list - pools_data = data.get("data", []) - if not pools_data: - logger.error(f"Raydium API returned empty data for pool: {pool_address}") - return None - - # Return the pool data directly (not wrapped in data key) - return pools_data[0] - except aiohttp.ClientError as e: - logger.error(f"Failed to fetch pool info from Raydium API: {e}") - return None - except Exception as e: - logger.error(f"Error fetching Raydium pool info: {e}", exc_info=True) - return None - - -def transform_raydium_to_clmm_response(raydium_data: dict, pool_address: str) -> dict: - """ - Transform Raydium API response to match Gateway's CLMMPoolInfoResponse format. - - Args: - raydium_data: Pool data from Raydium API (pools/info/ids endpoint) - pool_address: Pool contract address - - Returns: - Dictionary matching Gateway's pool info structure - """ - # Extract token info - mint_a = raydium_data.get("mintA", {}) - mint_b = raydium_data.get("mintB", {}) - - base_token_address = mint_a.get("address", "") - quote_token_address = mint_b.get("address", "") - - # Get current price - current_price = Decimal(str(raydium_data.get("price", 0))) - - # Get token amounts - base_amount = Decimal(str(raydium_data.get("mintAmountA", 0))) - quote_amount = Decimal(str(raydium_data.get("mintAmountB", 0))) - - # Get fee rate (convert from decimal to percentage, e.g., 0.0025 -> 0.25%) - fee_rate = raydium_data.get("feeRate", 0.0025) - fee_pct = Decimal(str(fee_rate * 100)) - - # Check if this is a CLMM (Concentrated) pool - pool_type = raydium_data.get("type", "Standard") - is_clmm = pool_type == "Concentrated" - - # Return in Gateway-compatible format - return { - "address": pool_address, - "baseTokenAddress": base_token_address, - "quoteTokenAddress": quote_token_address, - "binStep": 1 if is_clmm else None, # CLMM pools have tick spacing - "feePct": fee_pct, - "price": current_price, - "baseTokenAmount": base_amount, - "quoteTokenAmount": quote_amount, - "activeBinId": None, # Not available from this endpoint - "dynamicFeePct": None, - "minBinId": None, - "maxBinId": None, - "bins": [] # Bin data not available from pool info endpoint - } - - def get_transaction_status_from_response(gateway_response: dict) -> str: """ Determine transaction status from Gateway response. @@ -291,38 +202,8 @@ async def get_clmm_pool_info( Pool information including liquidity, price, bins (for Meteora), etc. All field names are returned in snake_case format. - Note: - For Raydium connector, uses Raydium API directly instead of Gateway, - except when bin_count > 0 — the Raydium API returns no bin distribution, - so those requests go to Gateway, which computes it from on-chain ticks. """ try: - # Special handling for Raydium - use Raydium API directly (not Gateway). - # Skipped when bins are requested: only Gateway can produce them. - if connector.lower() == "raydium" and not bin_count: - logger.info(f"Using Raydium API directly for pool info: {pool_address}") - - # Fetch from Raydium API - raydium_data = await fetch_raydium_pool_info(pool_address) - if raydium_data is None: - raise HTTPException(status_code=503, detail="Failed to get pool info from Raydium API") - - # Check if this is a CLMM pool - Standard AMM pools are not supported on this endpoint - pool_type = raydium_data.get("type", "Standard") - if pool_type != "Concentrated": - raise HTTPException( - status_code=400, - detail=f"Pool {pool_address} is a Raydium {pool_type} AMM pool, not a CLMM pool. " - f"This endpoint only supports Concentrated Liquidity (CLMM) pools." - ) - - # Transform to Gateway-compatible format - result = transform_raydium_to_clmm_response(raydium_data, pool_address) - - # Parse into response model - return CLMMPoolInfoResponse(**result) - - # Default behavior for other connectors: use Gateway if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") From d045d01e22da827dc5e1ddfa39fe6b459665eb52 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 17 Aug 2026 06:49:12 -0700 Subject: [PATCH 04/18] feat(clmm): make orphaned LP positions closable through the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An lp_executor that exhausts its close retries terminates as an involuntary POSITION_HOLD with the position still on-chain. Recovering it means closing that position by address — which this API could not do. Two things blocked it: - /gateway/clmm/close and /collect-fees read the position's pool only from the gateway_clmm_positions table, and 404'd when it was absent. An lp_executor opens its position straight from the bot to Gateway, so it is never in that table: on a live deployment the table was empty and every orphan 404'd. Both endpoints now accept pool_address on the request, resolving database-first and erroring with 400 (a bad request, not a missing position) naming pool_address as the fix. Gateway's close needs only position_address; pool_address is used to snapshot pending fees before the close so they can be reported. - /executors/positions/orphaned reported connector_name and trading_pair but not the DEX or the pool, so a caller had nothing to build a close from. Note connector_name holds the *network* for an lp_executor ("solana-mainnet-beta"). The DEX ("orca/clmm") and pool live in the executor's stored config; both are now surfaced as lp_provider and pool_address. Also wraps two pre-existing over-length lines in gateway_trading.py, which the whole-file flake8 hook fails on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj --- models/executors.py | 16 +++++++++++++++- models/gateway_trading.py | 19 +++++++++++++++++-- routers/gateway_clmm.py | 27 +++++++++++++++++++++------ services/executor_service.py | 13 +++++++++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/models/executors.py b/models/executors.py index c261d5f2..c1e5f774 100644 --- a/models/executors.py +++ b/models/executors.py @@ -442,8 +442,22 @@ class OrphanedPositionRecord(BaseModel): executor_id: str = Field(description="Executor identifier") executor_type: str = Field(description="Executor type (e.g. lp_executor)") account_name: Optional[str] = Field(default=None, description="Account name") - connector_name: Optional[str] = Field(default=None, description="Connector name") + connector_name: Optional[str] = Field( + default=None, + description="Connector name. For lp_executor this is the network id (e.g. 'solana-mainnet-beta'), " + "not the DEX - see lp_provider for the DEX" + ) trading_pair: Optional[str] = Field(default=None, description="Trading pair") + lp_provider: Optional[str] = Field( + default=None, + description="DEX connector that holds the position (e.g. 'orca/clmm'), read from the executor config. " + "Pass its base name to the CLMM close endpoint" + ) + pool_address: Optional[str] = Field( + default=None, + description="Pool the position was opened against, read from the executor config. Required to close a " + "position that was never recorded in the API database (LP-executor positions never are)" + ) controller_id: str = Field(default="main", description="Controller/agent grouping label") close_type: Optional[str] = Field( default=None, description="POSITION_HOLD (involuntary hold), FAILED, or SYSTEM_CLEANUP" diff --git a/models/gateway_trading.py b/models/gateway_trading.py index c9cb9c48..5330710d 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -13,6 +13,7 @@ # Swap Models (Router: Jupiter, 0x) # ============================================ + class SwapQuoteRequest(BaseModel): """Request for swap price quote""" connector: str = Field(description="DEX router connector (e.g., 'jupiter', '0x')") @@ -29,8 +30,12 @@ class SwapQuoteResponse(BaseModel): quote: str = Field(description="Quote token symbol") price: Decimal = Field(description="Quoted price (base/quote)") amount: Decimal = Field(description="Amount specified in request (BUY: base amount to receive, SELL: base amount to sell)") - amount_in: Optional[Decimal] = Field(default=None, description="Actual input amount (BUY: quote to spend, SELL: base to sell)") - amount_out: Optional[Decimal] = Field(default=None, description="Actual output amount (BUY: base to receive, SELL: quote to receive)") + amount_in: Optional[Decimal] = Field( + default=None, description="Actual input amount (BUY: quote to spend, SELL: base to sell)" + ) + amount_out: Optional[Decimal] = Field( + default=None, description="Actual output amount (BUY: base to receive, SELL: quote to receive)" + ) expected_amount: Optional[Decimal] = Field(default=None, description="Deprecated: use amount_out instead") slippage_pct: Decimal = Field(description="Applied slippage percentage") gas_estimate: Optional[Decimal] = Field(default=None, description="Estimated gas cost") @@ -116,6 +121,11 @@ class CLMMClosePositionRequest(BaseModel): connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") position_address: str = Field(description="Position address to close") + pool_address: Optional[str] = Field( + default=None, + description="Pool the position belongs to. Only needed for positions this API never recorded " + "(e.g. opened by an lp_executor straight against Gateway); otherwise read from the database" + ) wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") @@ -124,6 +134,11 @@ class CLMMCollectFeesRequest(BaseModel): connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") position_address: str = Field(description="Position address to collect fees from") + pool_address: Optional[str] = Field( + default=None, + description="Pool the position belongs to. Only needed for positions this API never recorded " + "(e.g. opened by an lp_executor straight against Gateway); otherwise read from the database" + ) wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 4b8a91a2..e45fd005 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -742,11 +742,19 @@ async def close_clmm_position( wallet_address=request.wallet_address ) - # If no pool_address from database, we can't query Gateway + # Positions this API never recorded (an lp_executor opens straight against Gateway) have no + # row to read the pool from, so accept it on the request. Gateway's close needs only + # position_address - pool_address is for the pre-close fee snapshot below. + pool_address = pool_address or request.pool_address + if not pool_address: raise HTTPException( - status_code=404, - detail=f"Position {request.position_address} not found in database. Pool address is required." + status_code=400, + detail=( + f"Position {request.position_address} is not in the database, so its pool is unknown. " + "Pass pool_address explicitly - LP-executor positions are never recorded here, and " + "/executors/positions/orphaned reports the pool for each orphan." + ) ) # Fetch pending fees and current price BEFORE closing (Gateway doesn't always return these in response) @@ -948,11 +956,18 @@ async def collect_fees_from_clmm_position( wallet_address=request.wallet_address ) - # If no pool_address from database, we can't query Gateway + # Positions this API never recorded (an lp_executor opens straight against Gateway) have no + # row to read the pool from, so accept it on the request. + pool_address = pool_address or request.pool_address + if not pool_address: raise HTTPException( - status_code=404, - detail=f"Position {request.position_address} not found in database. Pool address is required." + status_code=400, + detail=( + f"Position {request.position_address} is not in the database, so its pool is unknown. " + "Pass pool_address explicitly - LP-executor positions are never recorded here, and " + "/executors/positions/orphaned reports the pool for each orphan." + ) ) # Fetch pending fees BEFORE collecting (Gateway doesn't always return collected amounts in response) diff --git a/services/executor_service.py b/services/executor_service.py index 8288daee..46e58143 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -726,12 +726,25 @@ async def get_orphaned_positions(self) -> List[Dict[str, Any]]: # Voluntary hold (keep_position=True stop) - position was closed on-chain continue + # The DEX and pool live in the executor config, not in any column: for lp_executor the + # connector_name column carries the network id. Both are needed to close the position, + # and LP-executor positions are opened by the bot straight against Gateway so they are + # never in the API's own CLMM position table to be looked up there. + config: Dict[str, Any] = {} + if record.config: + try: + config = json.loads(record.config) + except (json.JSONDecodeError, TypeError): + config = {} + orphans.append({ "executor_id": record.executor_id, "executor_type": record.executor_type, "account_name": record.account_name, "connector_name": record.connector_name, "trading_pair": record.trading_pair, + "lp_provider": config.get("lp_provider"), + "pool_address": config.get("pool_address"), "controller_id": record.controller_id or "main", "close_type": record.close_type, "closed_at": record.closed_at.isoformat() if record.closed_at else None, From 664944f4fe00aa2ffcacffcdba0b6fd228c7eeb2 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 17 Aug 2026 11:46:14 -0700 Subject: [PATCH 05/18] chore(gateway): default the Gateway container to the development image The CLMM work on this branch depends on Gateway changes that ship in hummingbot/gateway#679 and are not in the `latest` tag, so a container started from the default image cannot serve the endpoints this branch calls. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj --- models/gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/gateway.py b/models/gateway.py index 87d6d862..9ab63269 100644 --- a/models/gateway.py +++ b/models/gateway.py @@ -19,7 +19,7 @@ class GatewayConfig(BaseModel): clients (which use ``CONFIG_PASSWORD``). The passphrase is therefore always ``CONFIG_PASSWORD``; a separate value would only break the API<->Gateway mTLS chain. """ - image: str = Field(default="hummingbot/gateway:latest", description="Docker image for Gateway") + image: str = Field(default="hummingbot/gateway:development", description="Docker image for Gateway") port: int = Field(default=15888, description="Port for Gateway API") From ff0a932cc79745bb65e8f33404a3eb105c33a4eb Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 15:50:46 -0700 Subject: [PATCH 06/18] fix(clmm): drop the pool_address no-op from positions_owned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway's /trading/clmm/positions-owned takes no pool filter — its handler reads only connector, chainNetwork and walletAddress — so the pool_address this API required, forwarded, and documented as a filter never filtered anything: every caller always got the wallet's full position list labeled as one pool's. Remove the parameter end to end (request model, router, gateway_client) so the contract says what actually happens; each returned row carries its own pool_address for callers that want one pool. Includes flake8 fixes in gateway_transaction_poller.py that the pre-commit hook now enforces on the touched file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- models/gateway_trading.py | 8 ++++++-- routers/gateway_clmm.py | 26 ++++++++++++-------------- services/gateway_client.py | 16 ++++++---------- services/gateway_transaction_poller.py | 17 +++++++++-------- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 5330710d..7989b6dc 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -152,10 +152,14 @@ class CLMMCollectFeesResponse(BaseModel): class CLMMPositionsOwnedRequest(BaseModel): - """Request to get all CLMM positions owned by a wallet for a specific pool""" + """Request to get all CLMM positions owned by a wallet. + + Mirrors Gateway's /trading/clmm/positions-owned, which takes no pool filter — + every CLMM position the wallet owns on the connector is returned, each row + carrying its own pool_address. + """ connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - pool_address: str = Field(description="Pool contract address to filter positions") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index e45fd005..dc2fac98 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -101,8 +101,7 @@ async def _refresh_position_data(position, accounts_service: AccountsService, cl positions_list = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=position.connector, chain_network=position.network, # position.network is already in 'chain-network' format - wallet_address=wallet_address, - pool_address=position.pool_address + wallet_address=wallet_address )) # Find our specific position in the list @@ -373,8 +372,7 @@ async def open_clmm_position( # opening a position without knowing its tokens would corrupt the position record. pool_info = check_gateway_error(await accounts_service.gateway_client.clmm_pool_info( connector=request.connector, - chain_network=request.network, - pool_address=request.pool_address + chain_network=request.network )) # Extract tokens from pool info @@ -766,8 +764,7 @@ async def close_clmm_position( positions_list = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=request.connector, chain_network=request.network, # request.network is already in 'chain-network' format - wallet_address=wallet_address, - pool_address=pool_address + wallet_address=wallet_address )) # Find our specific position and get pending fees and current price @@ -978,8 +975,7 @@ async def collect_fees_from_clmm_position( positions_list = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=request.connector, chain_network=request.network, # request.network is already in 'chain-network' format - wallet_address=wallet_address, - pool_address=pool_address + wallet_address=wallet_address )) # Find our specific position and get pending fees @@ -1089,16 +1085,20 @@ async def get_clmm_positions_owned( accounts_service: AccountsService = Depends(get_accounts_service) ): """ - Get all CLMM liquidity positions owned by a wallet for a specific pool. + Get all CLMM liquidity positions owned by a wallet. + + Mirrors Gateway's /trading/clmm/positions-owned, which takes no pool filter: + every CLMM position the wallet owns on the connector is returned, each row + carrying its own pool_address. (The old pool_address request field was a + silent no-op — Gateway never read it and the response was never filtered.) Example: connector: 'meteora' network: 'solana-mainnet-beta' - pool_address: '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3' wallet_address: (optional, uses default if not provided) Returns: - List of CLMM position information for the specified pool + List of CLMM position information """ try: if not await accounts_service.gateway_client.ping(): @@ -1113,12 +1113,10 @@ async def get_clmm_positions_owned( wallet_address=request.wallet_address ) - # Get positions for the specified pool result = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=request.connector, chain_network=request.network, # request.network is already in 'chain-network' format - wallet_address=wallet_address, - pool_address=request.pool_address + wallet_address=wallet_address )) # Gateway returns a list directly diff --git a/services/gateway_client.py b/services/gateway_client.py index dc3848a6..3d983b3b 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -618,18 +618,19 @@ async def clmm_positions_owned( self, connector: str, chain_network: str, - wallet_address: str, - pool_address: Optional[str] = None + wallet_address: str ) -> List[Dict]: """ - Get CLMM positions owned by a wallet. + Get ALL CLMM positions owned by a wallet on a connector. + + Gateway's /trading/clmm/positions-owned takes no pool filter (its handler + reads only connector, chainNetwork and walletAddress); callers that care + about one pool filter the returned rows by their poolAddress field. Args: connector: CLMM connector (e.g., 'meteora', 'raydium') chain_network: Chain and network in format 'chain-network' (e.g., 'solana-mainnet-beta') wallet_address: Wallet address to query - pool_address: Optional pool address to filter positions. - If not provided, returns ALL positions across all pools. Returns: List of position dictionaries with fields like: @@ -646,11 +647,6 @@ async def clmm_positions_owned( "chainNetwork": chain_network, "walletAddress": wallet_address, } - - # Only add poolAddress if specified (allows fetching all positions) - if pool_address: - params["poolAddress"] = pool_address - return await self._request("GET", "trading/clmm/positions-owned", params=params) async def clmm_collect_fees( diff --git a/services/gateway_transaction_poller.py b/services/gateway_transaction_poller.py index 2781043e..ba6e254f 100644 --- a/services/gateway_transaction_poller.py +++ b/services/gateway_transaction_poller.py @@ -480,8 +480,7 @@ async def _discover_positions_from_gateway(self) -> int: gateway_positions = await self.gateway_client.clmm_positions_owned( connector=connector, chain_network=chain_network, - wallet_address=wallet_address, - pool_address=None # Get all positions across all pools + wallet_address=wallet_address ) if not gateway_positions or not isinstance(gateway_positions, list): @@ -509,7 +508,7 @@ async def _discover_positions_from_gateway(self) -> int: closed_positions.discard(position_address) open_positions.add(position_address) logger.warning(f"Reopened position {position_address} - " - f"was CLOSED in DB but still exists on-chain") + f"was CLOSED in DB but still exists on-chain") continue # Create new position in database @@ -525,7 +524,7 @@ async def _discover_positions_from_gateway(self) -> int: discovered_count += 1 open_positions.add(position_address) logger.info(f"Discovered new position: {position_address} " - f"(pool: {pos_data.get('poolAddress', 'unknown')[:16]}...)") + f"(pool: {pos_data.get('poolAddress', 'unknown')[:16]}...)") except Exception as e: logger.warning(f"Error discovering positions for {connector}/{chain}/{wallet_address}: {e}") @@ -731,11 +730,13 @@ async def _refresh_position_state(self, position: GatewayCLMMPosition, clmm_repo # Gateway returns 500 instead of 404 when position doesn't exist (closed) # Treat any error (404 or 500) on position-info as "position closed" if status_code in (404, 500): - logger.info(f"Position {position.position_address} not found on Gateway (status: {status_code}), marking as CLOSED") + logger.info(f"Position {position.position_address} not found on Gateway " + f"(status: {status_code}), marking as CLOSED") await clmm_repo.close_position(position.position_address) return # Other errors → skip update, don't close - logger.debug(f"Gateway error for position {position.position_address}: {result.get('error')} (status: {status_code})") + logger.debug(f"Gateway error for position {position.position_address}: " + f"{result.get('error')} (status: {status_code})") return # Validate response has required fields @@ -798,8 +799,8 @@ async def _refresh_position_state(self, position: GatewayCLMMPosition, clmm_repo ) logger.debug(f"Refreshed position {position.position_address}: price={current_price}, in_range={in_range}, " - f"base={base_token_amount}, quote={quote_token_amount}, " - f"base_fee={base_fee_pending}, quote_fee={quote_fee_pending}") + f"base={base_token_amount}, quote={quote_token_amount}, " + f"base_fee={base_fee_pending}, quote_fee={quote_fee_pending}") except Exception as e: logger.error(f"Error refreshing position state {position.position_address}: {e}", exc_info=True) From 432d59e068cad79eda946bf8a34bb13633354e1d Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 16:08:01 -0700 Subject: [PATCH 07/18] fix(clmm): close the contract gaps against Gateway's unified trading routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit fixes, most severe first: - close: read the confirmed transaction data Gateway returns — the removed base/quote amounts and positionRentRefunded — record them on the CLOSE event and surface them in a new CLMMClosePositionResponse. The rent tracked as locked at open was never reconciled as refunded. - open/add/remove: prefer Gateway's confirmed on-chain amounts (baseTokenAmountAdded/Removed) over the requested amounts when persisting and responding; requested amounts remain the submitted-not-confirmed fallback. Also fixes REMOVE_LIQUIDITY events never persisting: the event payload carried a "percentage" key GatewayCLMMEvent has no column for, so create_event raised into the log-and-continue handler on every call. - new endpoints mirroring Gateway routes hapi never exposed: POST /gateway/clmm/quote-position (pre-trade deposit split), POST /gateway/clmm/create-pool (CLMM pools; AMM had this, CLMM did not), GET /gateway/clmm/position-info (single position by address). - positions_owned/position-info: pass through rewardTokenAddress / rewardAmount (farm rewards; populated by pancakeswap-sol today) instead of dropping them. - amm create-pool: expose openTime (Raydium CPMM) and slippagePct (Uniswap seeding) which Gateway accepts. - open: reject extra_params keys other than strategyType with a 400 — Gateway's unified open silently ignores everything else. - drop dead surface: dynamicFeePct/minBinId/maxBinId on pool-info (Gateway's declared response schema strips them before serialization; nothing consumes them) and the camelCase pageSize field (renamed page_size; no consumers). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- models/__init__.py | 8 ++ models/gateway_trading.py | 91 ++++++++++++- routers/gateway_amm.py | 2 + routers/gateway_clmm.py | 260 +++++++++++++++++++++++++++++++++++-- services/gateway_client.py | 79 +++++++++++ 5 files changed, 420 insertions(+), 20 deletions(-) diff --git a/models/__init__.py b/models/__init__.py index 0fbe6a8a..5991d18b 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -107,8 +107,10 @@ AMMTransactionResponse, CLMMAddLiquidityRequest, CLMMClosePositionRequest, + CLMMClosePositionResponse, CLMMCollectFeesRequest, CLMMCollectFeesResponse, + CLMMCreatePoolRequest, CLMMGetPositionInfoRequest, CLMMOpenPositionRequest, CLMMOpenPositionResponse, @@ -119,6 +121,8 @@ CLMMPoolListResponse, CLMMPositionInfo, CLMMPositionsOwnedRequest, + CLMMQuotePositionRequest, + CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, GetPoolInfoRequest, PoolInfo, @@ -341,8 +345,12 @@ "CLMMRemoveLiquidityRequest", "CLMMClosePositionRequest", "CLMMCollectFeesRequest", + "CLMMClosePositionResponse", + "CLMMCreatePoolRequest", "CLMMCollectFeesResponse", "CLMMPositionsOwnedRequest", + "CLMMQuotePositionRequest", + "CLMMQuotePositionResponse", "CLMMPositionInfo", "CLMMGetPositionInfoRequest", "CLMMPoolInfoRequest", diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 7989b6dc..3185c347 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -93,6 +93,14 @@ class CLMMOpenPositionResponse(BaseModel): pool_address: str = Field(description="Pool address") lower_price: Decimal = Field(description="Lower price bound") upper_price: Decimal = Field(description="Upper price bound") + base_token_amount_added: Optional[Decimal] = Field( + default=None, + description="Base amount actually added on-chain (confirmed txs only; the requested amount otherwise)") + quote_token_amount_added: Optional[Decimal] = Field( + default=None, + description="Quote amount actually added on-chain (confirmed txs only; the requested amount otherwise)") + position_rent: Optional[Decimal] = Field( + default=None, description="Native token locked as rent for the position account (refunded on close)") status: str = Field(default="submitted", description="Transaction status") @@ -151,6 +159,72 @@ class CLMMCollectFeesResponse(BaseModel): status: str = Field(default="submitted", description="Transaction status") +class CLMMClosePositionResponse(CLMMCollectFeesResponse): + """Response after closing a position: fees collected plus what the close returned. + + The removed amounts and rent refund come from Gateway's confirmed transaction data, + so they are None for submitted-not-confirmed transactions. + """ + base_token_amount_removed: Optional[Decimal] = Field( + default=None, description="Base liquidity actually withdrawn on-chain") + quote_token_amount_removed: Optional[Decimal] = Field( + default=None, description="Quote liquidity actually withdrawn on-chain") + position_rent_refunded: Optional[Decimal] = Field( + default=None, description="Native token rent refunded when the position account closed") + + +class CLMMQuotePositionRequest(BaseModel): + """Request to quote a candidate CLMM position before opening or adding. + + Mirrors Gateway's GET /trading/clmm/quote-position: given the price range and + one or both deposit amounts, returns the actual base/quote split the pool + would take (and which side limits it) without signing anything. + """ + connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'orca')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + pool_address: str = Field(description="Pool contract address") + lower_price: Decimal = Field(description="Lower price bound") + upper_price: Decimal = Field(description="Upper price bound") + base_token_amount: Optional[Decimal] = Field(default=None, description="Base amount to deposit (one side may be omitted)") + quote_token_amount: Optional[Decimal] = Field(default=None, description="Quote amount to deposit (one side may be omitted)") + slippage_pct: Optional[Decimal] = Field(default=None, description="Max acceptable slippage percentage") + + +class CLMMQuotePositionResponse(BaseModel): + """Gateway's position quote: the deposit split the pool would actually take.""" + base_limited: bool = Field(alias="baseLimited", description="True when the base side limits the deposit") + base_token_amount: Decimal = Field(alias="baseTokenAmount", description="Base amount the position would take") + quote_token_amount: Decimal = Field(alias="quoteTokenAmount", description="Quote amount the position would take") + base_token_amount_max: Decimal = Field(alias="baseTokenAmountMax", description="Base ceiling after slippage") + quote_token_amount_max: Decimal = Field(alias="quoteTokenAmountMax", description="Quote ceiling after slippage") + + model_config = {"populate_by_name": True} + + +class CLMMCreatePoolRequest(BaseModel): + """Request to create a new (empty) CLMM pool — liquidity is added by opening positions. + + Mirrors Gateway's POST /trading/clmm/create-pool. Connector extras are consumed + only by their owning connector. + """ + connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'orca', 'uniswap')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + base_token: str = Field(description="Base token symbol or address") + quote_token: str = Field(description="Quote token symbol or address") + initial_price: Optional[Decimal] = Field( + default=None, description="Initial price (quote per base); market price when omitted") + wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") + # Connector-specific create-pool extras: + bin_step: Optional[int] = Field(default=None, description="Meteora DLMM bin step (bps)") + fee_bps: Optional[int] = Field(default=None, description="Meteora DLMM base fee (bps)") + amm_config_index: Optional[int] = Field(default=None, description="Raydium CLMM AMM config index (fee tier)") + fee: Optional[Decimal] = Field(default=None, description="Orca/EVM fee parameter (connector-specific)") + tick_spacing: Optional[int] = Field(default=None, description="Orca Whirlpool tick spacing (fee tier)") + amm_config: Optional[str] = Field(default=None, description="pancakeswap-sol CLMM amm_config account address") + gas_price: Optional[Decimal] = Field(default=None, description="EVM gas price in gwei (uniswap/pancakeswap)") + max_gas: Optional[int] = Field(default=None, description="EVM max gas limit (uniswap/pancakeswap)") + + class CLMMPositionsOwnedRequest(BaseModel): """Request to get all CLMM positions owned by a wallet. @@ -179,6 +253,10 @@ class CLMMPositionInfo(BaseModel): quote_fee_amount: Optional[Decimal] = Field(default=None, description="Quote token uncollected fees") lower_bin_id: Optional[int] = Field(default=None, description="Lower bin ID (Meteora)") upper_bin_id: Optional[int] = Field(default=None, description="Upper bin ID (Meteora)") + reward_token_address: Optional[str] = Field( + default=None, description="Reward token contract address (farm rewards, where the connector reports them)") + reward_amount: Optional[Decimal] = Field( + default=None, description="Unclaimed reward-token amount (farm rewards)") in_range: bool = Field(description="Whether position is currently in range") @@ -227,9 +305,9 @@ class CLMMPoolInfoResponse(BaseModel): base_token_amount: Decimal = Field(alias="baseTokenAmount", description="Total base token liquidity") quote_token_amount: Decimal = Field(alias="quoteTokenAmount", description="Total quote token liquidity") active_bin_id: Optional[int] = Field(None, alias="activeBinId", description="Currently active bin ID (Meteora DLMM only)") - dynamic_fee_pct: Optional[Decimal] = Field(None, alias="dynamicFeePct", description="Dynamic fee percentage") - min_bin_id: Optional[int] = Field(None, alias="minBinId", description="Minimum bin ID (Meteora-specific)") - max_bin_id: Optional[int] = Field(None, alias="maxBinId", description="Maximum bin ID (Meteora-specific)") + # No dynamicFeePct/minBinId/maxBinId: those are Meteora connector extensions that + # Gateway's unified /trading/clmm/pool-info response schema strips before serialization, + # so they can never arrive here — and nothing downstream consumes them. bins: List[CLMMPoolBin] = Field(default_factory=list, description="List of bins with liquidity") model_config = { @@ -245,9 +323,6 @@ class CLMMPoolInfoResponse(BaseModel): "base_token_amount": 8645709.142366, "quote_token_amount": 1095942.335132, "active_bin_id": -374, - "dynamic_fee_pct": 0.2, - "min_bin_id": -21835, - "max_bin_id": 21835, "bins": [] } } @@ -409,8 +484,10 @@ class AMMCreatePoolRequest(BaseModel): # Connector-specific create-pool extras (only consumed by their owning connector): config_address: Optional[str] = Field(default=None, description="Meteora DAMM v2 config account (required for meteora)") fee_config_index: Optional[int] = Field(default=None, description="Raydium CPMM fee config index (optional)") + open_time: Optional[int] = Field(default=None, description="Raydium CPMM pool open time (unix seconds; optional)") gas_price: Optional[Decimal] = Field(default=None, description="Uniswap (EVM) gas price in gwei (optional)") max_gas: Optional[int] = Field(default=None, description="Uniswap (EVM) max gas limit (optional)") + slippage_pct: Optional[Decimal] = Field(default=None, description="Uniswap seeding slippage percentage (optional)") class AMMCreatePoolResponse(BaseModel): @@ -495,4 +572,4 @@ class CLMMPoolListResponse(BaseModel): pools: List[CLMMPoolListItem] = Field(description="List of available pools") total: int = Field(description="Total number of matching pools") page: int = Field(description="Current page number") - pageSize: int = Field(description="Number of pools per page") + page_size: int = Field(description="Number of pools per page") diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index 42d9d0d9..24130d90 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -304,8 +304,10 @@ async def create_amm_pool( initial_price=float(request.initial_price) if request.initial_price is not None else None, config_address=request.config_address, fee_config_index=request.fee_config_index, + open_time=request.open_time, gas_price=float(request.gas_price) if request.gas_price is not None else None, max_gas=request.max_gas, + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, )) return AMMCreatePoolResponse(**result) except HTTPException: diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index dc2fac98..9526b360 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -13,10 +13,13 @@ from database.repositories import GatewayCLMMRepository from deps import get_accounts_service, get_database_manager from models import ( + AMMCreatePoolResponse, CLMMAddLiquidityRequest, CLMMClosePositionRequest, + CLMMClosePositionResponse, CLMMCollectFeesRequest, CLMMCollectFeesResponse, + CLMMCreatePoolRequest, CLMMOpenPositionRequest, CLMMOpenPositionResponse, CLMMPoolInfoResponse, @@ -24,6 +27,8 @@ CLMMPoolListResponse, CLMMPositionInfo, CLMMPositionsOwnedRequest, + CLMMQuotePositionRequest, + CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, ) from services.accounts_service import AccountsService @@ -319,7 +324,7 @@ async def get_clmm_pools( pools=pools, total=total, page=page, - pageSize=limit + page_size=limit ) except HTTPException: @@ -356,6 +361,23 @@ async def open_clmm_position( Transaction hash and position address """ try: + # Gateway's unified open destructures ONLY strategyType from the body; any other + # extra_params key is silently dropped there. Reject unknown keys here so a typo + # (or a connector param the unified route does not carry) fails loudly instead of + # opening a position with the parameter ignored. + supported_extra_params = {"strategyType"} + if request.extra_params: + unknown = set(request.extra_params) - supported_extra_params + if unknown: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " + f"/trading/clmm/open honors only {sorted(supported_extra_params)} " + "and silently ignores everything else." + ) + ) + if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -415,6 +437,18 @@ async def open_clmm_position( if position_rent: logger.info(f"Position rent: {position_rent} SOL") + # Prefer the CONFIRMED on-chain amounts over the requested ones: slippage and + # rounding make them differ, and persisting the request silently diverges the + # DB from the chain. data is only present when Gateway confirmed the tx, so + # the requested amounts remain the fallback for submitted-not-confirmed + # (reconciled later by the poller). + base_amount_added = data.get("baseTokenAmountAdded") + if base_amount_added is None: + base_amount_added = float(request.base_token_amount) if request.base_token_amount else 0 + quote_amount_added = data.get("quoteTokenAmountAdded") + if quote_amount_added is None: + quote_amount_added = float(request.quote_token_amount) if request.quote_token_amount else 0 + if not transaction_hash: raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") if not position_address: @@ -454,11 +488,11 @@ async def open_clmm_position( "percentage": percentage, "entry_price": entry_price, # Pool price when position opened "current_price": entry_price, # Same as entry at open time, updated by poller - "initial_base_token_amount": float(request.base_token_amount) if request.base_token_amount else 0, - "initial_quote_token_amount": float(request.quote_token_amount) if request.quote_token_amount else 0, + "initial_base_token_amount": float(base_amount_added), + "initial_quote_token_amount": float(quote_amount_added), "position_rent": float(position_rent) if position_rent else None, - "base_token_amount": float(request.base_token_amount) if request.base_token_amount else 0, - "quote_token_amount": float(request.quote_token_amount) if request.quote_token_amount else 0, + "base_token_amount": float(base_amount_added), + "quote_token_amount": float(quote_amount_added), "in_range": "UNKNOWN" # Will be updated by poller } @@ -470,8 +504,8 @@ async def open_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "OPEN", - "base_token_amount": float(request.base_token_amount) if request.base_token_amount else None, - "quote_token_amount": float(request.quote_token_amount) if request.quote_token_amount else None, + "base_token_amount": float(base_amount_added) if base_amount_added else None, + "quote_token_amount": float(quote_amount_added) if quote_amount_added else None, "gas_fee": float(gas_fee) if gas_fee else None, "gas_token": gas_token, "status": tx_status @@ -491,6 +525,9 @@ async def open_clmm_position( pool_address=request.pool_address, lower_price=request.lower_price, upper_price=request.upper_price, + base_token_amount_added=Decimal(str(base_amount_added)) if base_amount_added else None, + quote_token_amount_added=Decimal(str(quote_amount_added)) if quote_amount_added else None, + position_rent=Decimal(str(position_rent)) if position_rent else None, status="submitted" ) @@ -562,6 +599,16 @@ async def add_liquidity_to_clmm_position( gas_fee = data.get("fee") gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None + # Prefer the CONFIRMED on-chain amounts (data is only present when Gateway + # confirmed the tx); the requested amounts are the submitted-not-confirmed + # fallback, reconciled later by the poller. + base_amount_added = data.get("baseTokenAmountAdded") + if base_amount_added is None: + base_amount_added = float(request.base_token_amount) if request.base_token_amount else None + quote_amount_added = data.get("quoteTokenAmountAdded") + if quote_amount_added is None: + quote_amount_added = float(request.quote_token_amount) if request.quote_token_amount else None + # Store ADD_LIQUIDITY event in database try: async with db_manager.get_session_context() as session: @@ -574,8 +621,8 @@ async def add_liquidity_to_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "ADD_LIQUIDITY", - "base_token_amount": float(request.base_token_amount) if request.base_token_amount else None, - "quote_token_amount": float(request.quote_token_amount) if request.quote_token_amount else None, + "base_token_amount": float(base_amount_added) if base_amount_added else None, + "quote_token_amount": float(quote_amount_added) if quote_amount_added else None, "gas_fee": float(gas_fee) if gas_fee else None, "gas_token": gas_token, "status": tx_status @@ -589,6 +636,9 @@ async def add_liquidity_to_clmm_position( return { "transaction_hash": transaction_hash, "position_address": request.position_address, + "base_token_amount_added": base_amount_added, + "quote_token_amount_added": quote_amount_added, + "gas_fee": gas_fee, "status": "submitted" } @@ -656,6 +706,11 @@ async def remove_liquidity_from_clmm_position( gas_fee = data.get("fee") gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None + # The CONFIRMED on-chain amounts (data is only present when Gateway confirmed + # the tx). A percentage alone says nothing about what actually left the pool. + base_amount_removed = data.get("baseTokenAmountRemoved") + quote_amount_removed = data.get("quoteTokenAmountRemoved") + # Store REMOVE_LIQUIDITY event in database try: async with db_manager.get_session_context() as session: @@ -664,11 +719,15 @@ async def remove_liquidity_from_clmm_position( # Get position to link event position = await clmm_repo.get_position_by_address(request.position_address) if position: + # No "percentage" key: GatewayCLMMEvent has no such column, and the + # stray kwarg made create_event raise — silently losing every + # REMOVE_LIQUIDITY event to the log-and-continue handler below. event_data = { "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "REMOVE_LIQUIDITY", - "percentage": float(request.percentage), + "base_token_amount": float(base_amount_removed) if base_amount_removed is not None else None, + "quote_token_amount": float(quote_amount_removed) if quote_amount_removed is not None else None, "gas_fee": float(gas_fee) if gas_fee else None, "gas_token": gas_token, "status": tx_status @@ -683,6 +742,9 @@ async def remove_liquidity_from_clmm_position( "transaction_hash": transaction_hash, "position_address": request.position_address, "percentage": float(request.percentage), + "base_token_amount_removed": base_amount_removed, + "quote_token_amount_removed": quote_amount_removed, + "gas_fee": gas_fee, "status": "submitted" } @@ -697,7 +759,7 @@ async def remove_liquidity_from_clmm_position( raise HTTPException(status_code=500, detail=f"Error removing liquidity from CLMM position: {str(e)}") -@router.post("/clmm/close", response_model=CLMMCollectFeesResponse) +@router.post("/clmm/close", response_model=CLMMClosePositionResponse) async def close_clmm_position( request: CLMMClosePositionRequest, accounts_service: AccountsService = Depends(get_accounts_service), @@ -811,7 +873,16 @@ async def close_clmm_position( quote_fee_collected = (Decimal(str(quote_fee_from_response)) if quote_fee_from_response is not None else quote_fee_to_collect) - logger.info(f"Collected fees on close: base={base_fee_collected}, quote={quote_fee_collected}") + # Confirmed-close accounting: what actually left the pool, and the rent the + # chain refunded for the closed position account (tracked as locked at open + # via position_rent). None until the transaction confirms. + base_amount_removed = data.get("baseTokenAmountRemoved") + quote_amount_removed = data.get("quoteTokenAmountRemoved") + position_rent_refunded = data.get("positionRentRefunded") + + logger.info(f"Collected fees on close: base={base_fee_collected}, quote={quote_fee_collected}; " + f"removed base={base_amount_removed}, quote={quote_amount_removed}; " + f"rent refunded={position_rent_refunded}") # Store CLOSE event in database and update position try: @@ -826,6 +897,8 @@ async def close_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "CLOSE", + "base_token_amount": float(base_amount_removed) if base_amount_removed is not None else None, + "quote_token_amount": float(quote_amount_removed) if quote_amount_removed is not None else None, "base_fee_collected": float(base_fee_collected) if base_fee_collected else None, "quote_fee_collected": float(quote_fee_collected) if quote_fee_collected else None, "gas_fee": float(gas_fee) if gas_fee else None, @@ -891,11 +964,14 @@ async def close_clmm_position( except Exception as db_error: logger.error(f"Error recording CLOSE event: {db_error}", exc_info=True) - return CLMMCollectFeesResponse( + return CLMMClosePositionResponse( transaction_hash=transaction_hash, position_address=request.position_address, base_fee_collected=Decimal(str(base_fee_collected)) if base_fee_collected else None, quote_fee_collected=Decimal(str(quote_fee_collected)) if quote_fee_collected else None, + base_token_amount_removed=Decimal(str(base_amount_removed)) if base_amount_removed is not None else None, + quote_token_amount_removed=Decimal(str(quote_amount_removed)) if quote_amount_removed is not None else None, + position_rent_refunded=Decimal(str(position_rent_refunded)) if position_rent_refunded is not None else None, status="submitted" ) @@ -1157,6 +1233,8 @@ async def get_clmm_positions_owned( quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, lower_bin_id=pos.get("lowerBinId"), upper_bin_id=pos.get("upperBinId"), + reward_token_address=pos.get("rewardTokenAddress"), + reward_amount=Decimal(str(pos.get("rewardAmount"))) if pos.get("rewardAmount") is not None else None, in_range=in_range )) @@ -1173,6 +1251,162 @@ async def get_clmm_positions_owned( raise HTTPException(status_code=500, detail=f"Error getting CLMM positions owned: {str(e)}") +@router.post("/clmm/quote-position", response_model=CLMMQuotePositionResponse, response_model_by_alias=False) +async def quote_clmm_position( + request: CLMMQuotePositionRequest, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """ + Quote a candidate CLMM position before opening or adding liquidity. + + Mirrors Gateway's GET /trading/clmm/quote-position: returns the base/quote + split the pool would actually take for the given range and deposit amounts + (and which side limits it), without signing or submitting anything. + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + result = check_gateway_error(await accounts_service.gateway_client.clmm_quote_position( + connector=request.connector, + chain_network=request.network, + pool_address=request.pool_address, + lower_price=float(request.lower_price), + upper_price=float(request.upper_price), + base_token_amount=float(request.base_token_amount) if request.base_token_amount is not None else None, + quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount is not None else None, + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + )) + return CLMMQuotePositionResponse(**result) + + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error quoting CLMM position: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error quoting CLMM position: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error quoting CLMM position: {str(e)}") + + +@router.post("/clmm/create-pool", response_model=AMMCreatePoolResponse, response_model_by_alias=False) +async def create_clmm_pool( + request: CLMMCreatePoolRequest, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """ + Create a new (empty) CLMM pool — liquidity is added afterwards by opening positions. + + Mirrors Gateway's POST /trading/clmm/create-pool (which shares the AMM + create-pool response shape). Connector extras are sent only when provided. + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + chain, _ = accounts_service.gateway_client.parse_network_id(request.network) + wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( + chain=chain, + wallet_address=request.wallet_address + ) + + result = check_gateway_error(await accounts_service.gateway_client.clmm_create_pool( + connector=request.connector, + chain_network=request.network, + wallet_address=wallet_address, + base_token=request.base_token, + quote_token=request.quote_token, + initial_price=float(request.initial_price) if request.initial_price is not None else None, + bin_step=request.bin_step, + fee_bps=request.fee_bps, + amm_config_index=request.amm_config_index, + fee=float(request.fee) if request.fee is not None else None, + tick_spacing=request.tick_spacing, + amm_config=request.amm_config, + gas_price=float(request.gas_price) if request.gas_price is not None else None, + max_gas=request.max_gas, + )) + return AMMCreatePoolResponse(**result) + + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error creating CLMM pool: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error creating CLMM pool: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error creating CLMM pool: {str(e)}") + + +@router.get("/clmm/position-info", response_model=CLMMPositionInfo) +async def get_clmm_position_info( + connector: str, + network: str, + position_address: str, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """ + Get a single CLMM position by its address. + + Mirrors Gateway's GET /trading/clmm/position-info. Gateway reports a missing + or closed position as an error (500/404), surfaced here as 404. + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + pos = await accounts_service.gateway_client.clmm_position_info( + connector=connector, + chain_network=network, + position_address=position_address + ) + if isinstance(pos, dict) and "error" in pos: + status_code = pos.get("status") + if status_code in (404, 500): + raise HTTPException(status_code=404, detail=f"Position {position_address} not found or closed") + raise HTTPException(status_code=status_code or 502, detail=str(pos.get("error"))) + + base_token_address = pos.get("baseTokenAddress", "") + quote_token_address = pos.get("quoteTokenAddress", "") + base_token = base_token_address[-8:] if base_token_address else "" + quote_token = quote_token_address[-8:] if quote_token_address else "" + current_price = Decimal(str(pos.get("price", 0))) + lower_price = Decimal(str(pos.get("lowerPrice", 0))) if pos.get("lowerPrice") else Decimal("0") + upper_price = Decimal(str(pos.get("upperPrice", 0))) if pos.get("upperPrice") else Decimal("0") + in_range = bool(current_price > 0 and lower_price > 0 and upper_price > 0 + and lower_price <= current_price <= upper_price) + + return CLMMPositionInfo( + position_address=pos.get("address", position_address), + pool_address=pos.get("poolAddress", ""), + trading_pair=f"{base_token}-{quote_token}" if base_token and quote_token else "", + base_token=base_token, + quote_token=quote_token, + base_token_amount=Decimal(str(pos.get("baseTokenAmount", 0))), + quote_token_amount=Decimal(str(pos.get("quoteTokenAmount", 0))), + current_price=current_price, + lower_price=lower_price, + upper_price=upper_price, + base_fee_amount=Decimal(str(pos.get("baseFeeAmount", 0))) if pos.get("baseFeeAmount") else None, + quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, + lower_bin_id=pos.get("lowerBinId"), + upper_bin_id=pos.get("upperBinId"), + reward_token_address=pos.get("rewardTokenAddress"), + reward_amount=Decimal(str(pos.get("rewardAmount"))) if pos.get("rewardAmount") is not None else None, + in_range=in_range + ) + + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting CLMM position: {e}") + except Exception as e: + logger.error(f"Error getting CLMM position info: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error getting CLMM position info: {str(e)}") + + @router.get("/clmm/positions/{position_address}/events") async def get_clmm_position_events( position_address: str, diff --git a/services/gateway_client.py b/services/gateway_client.py index 3d983b3b..9235cc34 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -649,6 +649,79 @@ async def clmm_positions_owned( } return await self._request("GET", "trading/clmm/positions-owned", params=params) + async def clmm_quote_position( + self, + connector: str, + chain_network: str, + pool_address: str, + lower_price: float, + upper_price: float, + base_token_amount: Optional[float] = None, + quote_token_amount: Optional[float] = None, + slippage_pct: Optional[float] = None, + ) -> Dict: + """Quote the base/quote split a candidate position would take, without signing anything.""" + params = { + "connector": connector, + "chainNetwork": chain_network, + "poolAddress": pool_address, + "lowerPrice": lower_price, + "upperPrice": upper_price, + } + if base_token_amount is not None: + params["baseTokenAmount"] = base_token_amount + if quote_token_amount is not None: + params["quoteTokenAmount"] = quote_token_amount + if slippage_pct is not None: + params["slippagePct"] = slippage_pct + return await self._request("GET", "trading/clmm/quote-position", params=params) + + async def clmm_create_pool( + self, + connector: str, + chain_network: str, + wallet_address: str, + base_token: str, + quote_token: str, + initial_price: Optional[float] = None, + bin_step: Optional[int] = None, + fee_bps: Optional[int] = None, + amm_config_index: Optional[int] = None, + fee: Optional[float] = None, + tick_spacing: Optional[int] = None, + amm_config: Optional[str] = None, + gas_price: Optional[float] = None, + max_gas: Optional[int] = None, + ) -> Dict: + """Create a new (empty) CLMM pool. Connector extras are sent only when provided.""" + payload = { + "connector": connector, + "chainNetwork": chain_network, + "walletAddress": wallet_address, + "baseToken": base_token, + "quoteToken": quote_token, + } + if initial_price is not None: + payload["initialPrice"] = initial_price + # Connector-specific extras (each consumed only by its owning connector): + if bin_step is not None: + payload["binStep"] = bin_step + if fee_bps is not None: + payload["feeBps"] = fee_bps + if amm_config_index is not None: + payload["ammConfigIndex"] = amm_config_index + if fee is not None: + payload["fee"] = fee + if tick_spacing is not None: + payload["tickSpacing"] = tick_spacing + if amm_config is not None: + payload["ammConfig"] = amm_config + if gas_price is not None: + payload["gasPrice"] = gas_price + if max_gas is not None: + payload["maxGas"] = max_gas + return await self._request("POST", "trading/clmm/create-pool", json=payload) + async def clmm_collect_fees( self, connector: str, @@ -884,8 +957,10 @@ async def amm_create_pool( initial_price: Optional[float] = None, config_address: Optional[str] = None, fee_config_index: Optional[int] = None, + open_time: Optional[int] = None, gas_price: Optional[float] = None, max_gas: Optional[int] = None, + slippage_pct: Optional[float] = None, ) -> Dict: """Create and seed a new AMM pool. Connector extras are sent only when provided.""" payload = { @@ -906,10 +981,14 @@ async def amm_create_pool( payload["configAddress"] = config_address if fee_config_index is not None: payload["feeConfigIndex"] = fee_config_index + if open_time is not None: + payload["openTime"] = open_time if gas_price is not None: payload["gasPrice"] = gas_price if max_gas is not None: payload["maxGas"] = max_gas + if slippage_pct is not None: + payload["slippagePct"] = slippage_pct return await self._request("POST", "trading/amm/create-pool", json=payload) # ============================================ From 04adc98b30daacba9c257d2156578c10a4245519 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 16:20:40 -0700 Subject: [PATCH 08/18] refactor(gateway): standardize connector extras on extra_params; honest swap quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLMM and AMM create-pool drop their per-connector named fields; connector params ride extra_params under Gateway's own names (the clmm open contract), spread into the payload, with unknown keys rejected loudly — Gateway destructures a fixed set and silently ignores the rest. Meteora's required configAddress is enforced at the router. - CLMMPositionInfo drops reward_token_address/reward_amount: no Gateway connector populates them (the only assignments are commented out) and the schema fields are being removed from Gateway's trading responses. - CLMMPoolInfoRequest documents bin_count, mirroring Gateway's binCount. - SwapQuoteResponse mirrors what /trading/swap/quote actually returns: gains min_amount_out/max_amount_in/price_impact_pct/pool_address/ route_path, slippage_pct reflects Gateway's applied value, and the phantom gas_estimate (never returned by Gateway) and deprecated expected_amount are gone — nothing consumed either. - Fix the stale module docstring claiming AMM support was removed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- models/gateway_trading.py | 62 ++++++++++++++------------ routers/gateway_amm.py | 34 ++++++++++---- routers/gateway_clmm.py | 33 ++++++++------ routers/gateway_swap.py | 29 ++++++------ services/gateway_client.py | 66 +++++++++------------------- test/test_gateway_client_contract.py | 7 +-- 6 files changed, 118 insertions(+), 113 deletions(-) diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 3185c347..2d8cda30 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -1,8 +1,8 @@ """ -Models for Gateway DEX trading operations. -Supports swaps via routers (Jupiter, 0x) and CLMM liquidity positions (Meteora, Raydium, Uniswap V3). - -Note: AMM support has been removed. Use Router for simple swaps, CLMM for liquidity provision. +Models for Gateway DEX trading operations, mirroring Gateway's unified /trading routes: +swaps (routers like Jupiter and pool-scoped AMM swaps), CLMM liquidity positions +(Meteora, Raydium, Orca, Uniswap V3, PancakeSwap), and AMM liquidity/pool creation +(Meteora DAMM v2, Raydium CPMM, Uniswap V2). """ from decimal import Decimal from typing import Any, Dict, List, Optional @@ -25,7 +25,12 @@ class SwapQuoteRequest(BaseModel): class SwapQuoteResponse(BaseModel): - """Response with swap quote details""" + """Swap quote, re-framed from Gateway's token-flow response into trading-pair terms. + + Gateway's /trading/swap/quote speaks tokenIn/tokenOut; this keeps the base/quote + + side framing bots use and passes Gateway's execution-safety fields through in + snake_case. No gas estimate: Gateway's quote does not return one. + """ base: str = Field(description="Base token symbol") quote: str = Field(description="Quote token symbol") price: Decimal = Field(description="Quoted price (base/quote)") @@ -36,9 +41,15 @@ class SwapQuoteResponse(BaseModel): amount_out: Optional[Decimal] = Field( default=None, description="Actual output amount (BUY: base to receive, SELL: quote to receive)" ) - expected_amount: Optional[Decimal] = Field(default=None, description="Deprecated: use amount_out instead") - slippage_pct: Decimal = Field(description="Applied slippage percentage") - gas_estimate: Optional[Decimal] = Field(default=None, description="Estimated gas cost") + min_amount_out: Optional[Decimal] = Field( + default=None, description="Minimum output the transaction will accept after slippage") + max_amount_in: Optional[Decimal] = Field( + default=None, description="Maximum input the transaction will spend after slippage") + price_impact_pct: Optional[Decimal] = Field( + default=None, description="Price impact of this trade size on the route") + pool_address: Optional[str] = Field(default=None, description="Pool the quote was priced against") + route_path: Optional[str] = Field(default=None, description="Route taken (router connectors)") + slippage_pct: Decimal = Field(description="Slippage percentage Gateway applied to the quote") class SwapExecuteRequest(BaseModel): @@ -214,15 +225,11 @@ class CLMMCreatePoolRequest(BaseModel): initial_price: Optional[Decimal] = Field( default=None, description="Initial price (quote per base); market price when omitted") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") - # Connector-specific create-pool extras: - bin_step: Optional[int] = Field(default=None, description="Meteora DLMM bin step (bps)") - fee_bps: Optional[int] = Field(default=None, description="Meteora DLMM base fee (bps)") - amm_config_index: Optional[int] = Field(default=None, description="Raydium CLMM AMM config index (fee tier)") - fee: Optional[Decimal] = Field(default=None, description="Orca/EVM fee parameter (connector-specific)") - tick_spacing: Optional[int] = Field(default=None, description="Orca Whirlpool tick spacing (fee tier)") - amm_config: Optional[str] = Field(default=None, description="pancakeswap-sol CLMM amm_config account address") - gas_price: Optional[Decimal] = Field(default=None, description="EVM gas price in gwei (uniswap/pancakeswap)") - max_gas: Optional[int] = Field(default=None, description="EVM max gas limit (uniswap/pancakeswap)") + extra_params: Optional[Dict[str, Any]] = Field( + default=None, + description="Connector-specific create params, passed through to Gateway under its own " + "names: binStep/feeBps (meteora), ammConfigIndex (raydium), fee/tickSpacing (orca), " + "ammConfig (pancakeswap-sol), gasPrice/maxGas (EVM connectors). Unknown keys are rejected.") class CLMMPositionsOwnedRequest(BaseModel): @@ -253,10 +260,6 @@ class CLMMPositionInfo(BaseModel): quote_fee_amount: Optional[Decimal] = Field(default=None, description="Quote token uncollected fees") lower_bin_id: Optional[int] = Field(default=None, description="Lower bin ID (Meteora)") upper_bin_id: Optional[int] = Field(default=None, description="Upper bin ID (Meteora)") - reward_token_address: Optional[str] = Field( - default=None, description="Reward token contract address (farm rewards, where the connector reports them)") - reward_amount: Optional[Decimal] = Field( - default=None, description="Unclaimed reward-token amount (farm rewards)") in_range: bool = Field(description="Whether position is currently in range") @@ -272,6 +275,11 @@ class CLMMPoolInfoRequest(BaseModel): connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") pool_address: str = Field(description="Pool contract address") + bin_count: int = Field( + default=0, + description="If > 0, include the per-tick liquidity distribution (bins) around the active " + "price — Gateway's binCount. Meteora always returns its bins and ignores this; orca, " + "raydium, uniswap and pancakeswap compute them on request.") class CLMMPoolBin(BaseModel): @@ -481,13 +489,11 @@ class AMMCreatePoolRequest(BaseModel): quote_token_amount: Optional[Decimal] = Field(default=None, description="Amount of quote to seed (sets price if given)") initial_price: Optional[Decimal] = Field(default=None, description="Initial price (quote per base); overrides quote amount") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") - # Connector-specific create-pool extras (only consumed by their owning connector): - config_address: Optional[str] = Field(default=None, description="Meteora DAMM v2 config account (required for meteora)") - fee_config_index: Optional[int] = Field(default=None, description="Raydium CPMM fee config index (optional)") - open_time: Optional[int] = Field(default=None, description="Raydium CPMM pool open time (unix seconds; optional)") - gas_price: Optional[Decimal] = Field(default=None, description="Uniswap (EVM) gas price in gwei (optional)") - max_gas: Optional[int] = Field(default=None, description="Uniswap (EVM) max gas limit (optional)") - slippage_pct: Optional[Decimal] = Field(default=None, description="Uniswap seeding slippage percentage (optional)") + extra_params: Optional[Dict[str, Any]] = Field( + default=None, + description="Connector-specific create params, passed through to Gateway under its own " + "names: configAddress (meteora DAMM v2, required there), feeConfigIndex/openTime (raydium " + "CPMM), gasPrice/maxGas/slippagePct (uniswap/EVM). Unknown keys are rejected.") class AMMCreatePoolResponse(BaseModel): diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index 24130d90..5aeafa27 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -290,10 +290,33 @@ async def create_amm_pool( Create and seed a new AMM pool. Seed price priority: initial_price → quote_token_amount ratio → live market price (anti-snipe). - Connector extras are sent only when provided (config_address for meteora, fee_config_index for - raydium, gas_price/max_gas for uniswap). + Connector-specific params ride extra_params under Gateway's own names (configAddress for + meteora — required there, feeConfigIndex/openTime for raydium, gasPrice/maxGas/slippagePct + for uniswap) — the same contract as clmm open's extra_params. """ try: + # Gateway's unified create-pool destructures exactly these; anything else + # would be silently ignored there, so reject it loudly here. + supported_extra_params = { + "configAddress", "feeConfigIndex", "openTime", "gasPrice", "maxGas", "slippagePct", + } + extra_params = request.extra_params or {} + unknown = set(extra_params) - supported_extra_params + if unknown: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " + f"/trading/amm/create-pool honors only {sorted(supported_extra_params)}." + ) + ) + if request.connector == "meteora" and not extra_params.get("configAddress"): + raise HTTPException( + status_code=400, + detail="extra_params.configAddress is required for meteora create-pool " + "(DAMM v2 pools are created against a config account)." + ) + await _require_gateway(accounts_service) wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) result = check_gateway_error(await accounts_service.gateway_client.amm_create_pool( @@ -302,12 +325,7 @@ async def create_amm_pool( base_token_amount=float(request.base_token_amount), quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount is not None else None, initial_price=float(request.initial_price) if request.initial_price is not None else None, - config_address=request.config_address, - fee_config_index=request.fee_config_index, - open_time=request.open_time, - gas_price=float(request.gas_price) if request.gas_price is not None else None, - max_gas=request.max_gas, - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + extra_params=request.extra_params, )) return AMMCreatePoolResponse(**result) except HTTPException: diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 9526b360..eb5e6e3c 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -1233,8 +1233,6 @@ async def get_clmm_positions_owned( quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, lower_bin_id=pos.get("lowerBinId"), upper_bin_id=pos.get("upperBinId"), - reward_token_address=pos.get("rewardTokenAddress"), - reward_amount=Decimal(str(pos.get("rewardAmount"))) if pos.get("rewardAmount") is not None else None, in_range=in_range )) @@ -1299,9 +1297,27 @@ async def create_clmm_pool( Create a new (empty) CLMM pool — liquidity is added afterwards by opening positions. Mirrors Gateway's POST /trading/clmm/create-pool (which shares the AMM - create-pool response shape). Connector extras are sent only when provided. + create-pool response shape). Connector-specific params ride extra_params + under Gateway's own names — the same contract as open's extra_params. """ try: + # Gateway's unified create-pool destructures exactly these; anything else + # would be silently ignored there, so reject it loudly here. + supported_extra_params = { + "binStep", "feeBps", "ammConfigIndex", "fee", "tickSpacing", + "ammConfig", "gasPrice", "maxGas", + } + if request.extra_params: + unknown = set(request.extra_params) - supported_extra_params + if unknown: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " + f"/trading/clmm/create-pool honors only {sorted(supported_extra_params)}." + ) + ) + if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -1318,14 +1334,7 @@ async def create_clmm_pool( base_token=request.base_token, quote_token=request.quote_token, initial_price=float(request.initial_price) if request.initial_price is not None else None, - bin_step=request.bin_step, - fee_bps=request.fee_bps, - amm_config_index=request.amm_config_index, - fee=float(request.fee) if request.fee is not None else None, - tick_spacing=request.tick_spacing, - amm_config=request.amm_config, - gas_price=float(request.gas_price) if request.gas_price is not None else None, - max_gas=request.max_gas, + extra_params=request.extra_params, )) return AMMCreatePoolResponse(**result) @@ -1393,8 +1402,6 @@ async def get_clmm_position_info( quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, lower_bin_id=pos.get("lowerBinId"), upper_bin_id=pos.get("upperBinId"), - reward_token_address=pos.get("rewardTokenAddress"), - reward_amount=Decimal(str(pos.get("rewardAmount"))) if pos.get("rewardAmount") is not None else None, in_range=in_range ) diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 3a5816c6..96c6b406 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -81,27 +81,26 @@ async def get_swap_quote( slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 )) - # Extract amounts from Gateway response (snake_case for consistency) - amount_in_raw = result.get("amountIn") or result.get("amount_in") - amount_out_raw = result.get("amountOut") or result.get("amount_out") - - amount_in = Decimal(str(amount_in_raw)) if amount_in_raw else None - amount_out = Decimal(str(amount_out_raw)) if amount_out_raw else None - - # Extract gas estimate (try both camelCase and snake_case) - gas_estimate = result.get("gasEstimate") or result.get("gas_estimate") - gas_estimate_value = Decimal(str(gas_estimate)) if gas_estimate else None + # Re-frame Gateway's token-flow response (tokenIn/tokenOut) into pair terms, + # passing its execution-safety fields through in snake_case. + def _dec(key): + value = result.get(key) + return Decimal(str(value)) if value is not None else None return SwapQuoteResponse( base=base, quote=quote, price=Decimal(str(result.get("price", 0))), amount=request.amount, - amount_in=amount_in, - amount_out=amount_out, - expected_amount=amount_out, # Deprecated, kept for backward compatibility - slippage_pct=request.slippage_pct if request.slippage_pct is not None else Decimal("1.0"), - gas_estimate=gas_estimate_value + amount_in=_dec("amountIn"), + amount_out=_dec("amountOut"), + min_amount_out=_dec("minAmountOut"), + max_amount_in=_dec("maxAmountIn"), + price_impact_pct=_dec("priceImpactPct"), + pool_address=result.get("poolAddress"), + route_path=result.get("routePath"), + slippage_pct=(_dec("slippagePct") + or (request.slippage_pct if request.slippage_pct is not None else Decimal("1.0"))), ) except HTTPException: diff --git a/services/gateway_client.py b/services/gateway_client.py index 9235cc34..87ccdf68 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -684,16 +684,15 @@ async def clmm_create_pool( base_token: str, quote_token: str, initial_price: Optional[float] = None, - bin_step: Optional[int] = None, - fee_bps: Optional[int] = None, - amm_config_index: Optional[int] = None, - fee: Optional[float] = None, - tick_spacing: Optional[int] = None, - amm_config: Optional[str] = None, - gas_price: Optional[float] = None, - max_gas: Optional[int] = None, + extra_params: Optional[Dict] = None, ) -> Dict: - """Create a new (empty) CLMM pool. Connector extras are sent only when provided.""" + """Create a new (empty) CLMM pool. + + extra_params carries the connector-specific create params under Gateway's own + names (binStep, feeBps, ammConfigIndex, fee, tickSpacing, ammConfig, gasPrice, + maxGas) and is spread into the payload — the same contract as clmm open's + extra_params. The router validates keys before this is called. + """ payload = { "connector": connector, "chainNetwork": chain_network, @@ -703,23 +702,8 @@ async def clmm_create_pool( } if initial_price is not None: payload["initialPrice"] = initial_price - # Connector-specific extras (each consumed only by its owning connector): - if bin_step is not None: - payload["binStep"] = bin_step - if fee_bps is not None: - payload["feeBps"] = fee_bps - if amm_config_index is not None: - payload["ammConfigIndex"] = amm_config_index - if fee is not None: - payload["fee"] = fee - if tick_spacing is not None: - payload["tickSpacing"] = tick_spacing - if amm_config is not None: - payload["ammConfig"] = amm_config - if gas_price is not None: - payload["gasPrice"] = gas_price - if max_gas is not None: - payload["maxGas"] = max_gas + if extra_params: + payload.update(extra_params) return await self._request("POST", "trading/clmm/create-pool", json=payload) async def clmm_collect_fees( @@ -955,14 +939,15 @@ async def amm_create_pool( base_token_amount: float, quote_token_amount: Optional[float] = None, initial_price: Optional[float] = None, - config_address: Optional[str] = None, - fee_config_index: Optional[int] = None, - open_time: Optional[int] = None, - gas_price: Optional[float] = None, - max_gas: Optional[int] = None, - slippage_pct: Optional[float] = None, + extra_params: Optional[Dict] = None, ) -> Dict: - """Create and seed a new AMM pool. Connector extras are sent only when provided.""" + """Create and seed a new AMM pool. + + extra_params carries the connector-specific create params under Gateway's own + names (configAddress, feeConfigIndex, openTime, gasPrice, maxGas, slippagePct) + and is spread into the payload — the same contract as clmm open's extra_params. + The router validates keys before this is called. + """ payload = { "connector": connector, "chainNetwork": chain_network, @@ -976,19 +961,8 @@ async def amm_create_pool( payload["quoteTokenAmount"] = quote_token_amount if initial_price is not None: payload["initialPrice"] = initial_price - # Connector-specific extras (each consumed only by its owning connector): - if config_address is not None: - payload["configAddress"] = config_address - if fee_config_index is not None: - payload["feeConfigIndex"] = fee_config_index - if open_time is not None: - payload["openTime"] = open_time - if gas_price is not None: - payload["gasPrice"] = gas_price - if max_gas is not None: - payload["maxGas"] = max_gas - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct + if extra_params: + payload.update(extra_params) return await self._request("POST", "trading/amm/create-pool", json=payload) # ============================================ diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 38d2a803..0152f5cd 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -344,7 +344,7 @@ async def test_amm_create_pool_meteora_extras(client_and_calls): client, calls = client_and_calls await client.amm_create_pool(connector="meteora", chain_network=NET, wallet_address=WALLET, base_token="SOL", quote_token="USDC", base_token_amount=1.0, - config_address="CFG123") + extra_params={"configAddress": "CFG123"}) c = calls[0] assert (c["method"], c["path"]) == ("POST", "trading/amm/create-pool") assert c["json"]["configAddress"] == "CFG123" @@ -358,7 +358,7 @@ async def test_amm_create_pool_raydium_fee_config_index(client_and_calls): client, calls = client_and_calls await client.amm_create_pool(connector="raydium", chain_network=NET, wallet_address=WALLET, base_token="SOL", quote_token="USDC", base_token_amount=1.0, - fee_config_index=0, quote_token_amount=100.0) + extra_params={"feeConfigIndex": 0}, quote_token_amount=100.0) c = calls[0] assert c["json"]["feeConfigIndex"] == 0 assert c["json"]["quoteTokenAmount"] == 100.0 @@ -370,7 +370,8 @@ async def test_amm_create_pool_uniswap_gas_extras(client_and_calls): client, calls = client_and_calls await client.amm_create_pool(connector="uniswap", chain_network="ethereum-mainnet", wallet_address=WALLET, base_token="WETH", quote_token="USDC", base_token_amount=1.0, - initial_price=3000.0, gas_price=20.0, max_gas=500000) + initial_price=3000.0, + extra_params={"gasPrice": 20.0, "maxGas": 500000}) c = calls[0] assert c["json"]["gasPrice"] == 20.0 assert c["json"]["maxGas"] == 500000 From 1ef8ebe889082c552473b5803588a9583201ecb6 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 19:18:32 -0700 Subject: [PATCH 09/18] fix(gateway): align request contracts with Gateway's standardized trading routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop forcing slippage 1.0 everywhere: all request models default slippage_pct=None and the routers omit the key when unset, so Gateway applies the connector's configured slippagePct (the schema-default shadowing this used to cause was removed Gateway-side). The swap DB record and quote response echo the applied/requested value or None — SwapQuoteResponse.slippage_pct is now Optional and no longer backfills a fabricated 1.0 (also fixes the falsy-`or` that swallowed slippagePct=0). - extra_params convention extended to every surface with connector-specific params, matching the executor stack's LPExecutorConfig.extra_params: swap quote/execute gain approximateIfNoExactOut (Solana routers; query values stringified for aiohttp), clmm add gains strategyType (same contract as open), each guarded by loud unknown-key rejection since Gateway silently drops unrecognized keys. - CLMM remove exposes the standard slippage_pct field (Orca-only today). - create-pool guards pinned to what Gateway actually destructures: clmm {binStep, feeBps, ammConfigIndex}; amm {configAddress, ammConfigIndex} + first-class seeding slippage_pct. The phantom keys (fee/tickSpacing/ammConfig/gasPrice/maxGas/feeConfigIndex/openTime) passed the guard and were silently ignored by Gateway. - Fix /clmm/open crash: clmm_pool_info was called without its required pool_address since the pool-info signature change (TypeError → 500 on every open). - Contract tests updated and extended for all of the above. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- models/gateway_trading.py | 50 ++++++++++--- routers/gateway_amm.py | 9 +-- routers/gateway_clmm.py | 43 ++++++++--- routers/gateway_swap.py | 43 +++++++++-- services/gateway_client.py | 62 ++++++++++++---- test/test_gateway_client_contract.py | 106 ++++++++++++++++++++++++--- 6 files changed, 251 insertions(+), 62 deletions(-) diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 2d8cda30..748c89b1 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -21,7 +21,12 @@ class SwapQuoteRequest(BaseModel): trading_pair: str = Field(description="Trading pair in BASE-QUOTE format (e.g., 'SOL-USDC')") side: str = Field(description="Trade side: 'BUY' or 'SELL'") amount: Decimal = Field(description="Amount to swap (in base token for SELL, quote token for BUY)") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") + extra_params: Optional[Dict[str, Any]] = Field( + default=None, + description="Connector-specific params passed through to Gateway under its own names: " + "approximateIfNoExactOut (Solana routers). Unknown keys are rejected.") class SwapQuoteResponse(BaseModel): @@ -49,7 +54,9 @@ class SwapQuoteResponse(BaseModel): default=None, description="Price impact of this trade size on the route") pool_address: Optional[str] = Field(default=None, description="Pool the quote was priced against") route_path: Optional[str] = Field(default=None, description="Route taken (router connectors)") - slippage_pct: Decimal = Field(description="Slippage percentage Gateway applied to the quote") + slippage_pct: Optional[Decimal] = Field( + default=None, + description="Slippage percentage Gateway applied to the quote (the request value when Gateway omits it)") class SwapExecuteRequest(BaseModel): @@ -59,8 +66,13 @@ class SwapExecuteRequest(BaseModel): trading_pair: str = Field(description="Trading pair (e.g., 'SOL-USDC')") side: str = Field(description="Trade side: 'BUY' or 'SELL'") amount: Decimal = Field(description="Amount to swap") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") + extra_params: Optional[Dict[str, Any]] = Field( + default=None, + description="Connector-specific params passed through to Gateway under its own names: " + "approximateIfNoExactOut (Solana routers). Unknown keys are rejected.") class SwapExecuteResponse(BaseModel): @@ -89,7 +101,8 @@ class CLMMOpenPositionRequest(BaseModel): # Initial liquidity base_token_amount: Optional[Decimal] = Field(default=None, description="Amount of base token to add") quote_token_amount: Optional[Decimal] = Field(default=None, description="Amount of quote token to add") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") # Connector-specific parameters (e.g., strategyType for Meteora) @@ -122,9 +135,13 @@ class CLMMAddLiquidityRequest(BaseModel): position_address: str = Field(description="Existing position address to add liquidity to") base_token_amount: Optional[Decimal] = Field(default=None, description="Amount of base token to add") quote_token_amount: Optional[Decimal] = Field(default=None, description="Amount of quote token to add") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") + # Connector-specific parameters (e.g., strategyType for Meteora) + extra_params: Optional[Dict[str, Any]] = Field(default=None, description="Additional connector-specific parameters") + class CLMMRemoveLiquidityRequest(BaseModel): """Request to remove SOME liquidity from a CLMM position (partial removal)""" @@ -132,6 +149,10 @@ class CLMMRemoveLiquidityRequest(BaseModel): network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") position_address: str = Field(description="Position address to remove liquidity from") percentage: Decimal = Field(description="Percentage of liquidity to remove (0-100)") + slippage_pct: Optional[Decimal] = Field( + default=None, + description="Maximum slippage percentage. Only honored by the Orca connector; " + "omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") @@ -228,8 +249,9 @@ class CLMMCreatePoolRequest(BaseModel): extra_params: Optional[Dict[str, Any]] = Field( default=None, description="Connector-specific create params, passed through to Gateway under its own " - "names: binStep/feeBps (meteora), ammConfigIndex (raydium), fee/tickSpacing (orca), " - "ammConfig (pancakeswap-sol), gasPrice/maxGas (EVM connectors). Unknown keys are rejected.") + "names: binStep (meteora, orca), feeBps (meteora; required for uniswap/pancakeswap — the " + "V3 fee tier in basis points), ammConfigIndex (raydium, pancakeswap-sol). " + "Unknown keys are rejected.") class CLMMPositionsOwnedRequest(BaseModel): @@ -420,7 +442,8 @@ class AMMExecuteSwapRequest(BaseModel): base_token: str = Field(description="Token that defines the swap direction (symbol or address)") side: str = Field(description="Trade direction: BUY or SELL") amount: Decimal = Field(description="Amount to swap") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") @@ -461,7 +484,8 @@ class AMMAddLiquidityRequest(BaseModel): pool_address: str = Field(description="Pool contract address") base_token_amount: Decimal = Field(description="Amount of base token to add") quote_token_amount: Decimal = Field(description="Amount of quote token to add") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") # Meteora DAMM v2: add to this specific NFT position; omit to open a NEW position. Ignored by fungible-LP AMMs. position_address: Optional[str] = Field(default=None, description="Meteora position to add to (omit = new position)") @@ -488,12 +512,16 @@ class AMMCreatePoolRequest(BaseModel): base_token_amount: Decimal = Field(description="Amount of base token to seed the pool with") quote_token_amount: Optional[Decimal] = Field(default=None, description="Amount of quote to seed (sets price if given)") initial_price: Optional[Decimal] = Field(default=None, description="Initial price (quote per base); overrides quote amount") + slippage_pct: Optional[Decimal] = Field( + default=None, + description="Seeding slippage percentage (uniswap/pancakeswap only); " + "omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") extra_params: Optional[Dict[str, Any]] = Field( default=None, description="Connector-specific create params, passed through to Gateway under its own " - "names: configAddress (meteora DAMM v2, required there), feeConfigIndex/openTime (raydium " - "CPMM), gasPrice/maxGas/slippagePct (uniswap/EVM). Unknown keys are rejected.") + "names: configAddress (meteora DAMM v2, required there), ammConfigIndex (raydium CPMM). " + "Unknown keys are rejected.") class AMMCreatePoolResponse(BaseModel): diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index 5aeafa27..a290179d 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -291,15 +291,13 @@ async def create_amm_pool( Seed price priority: initial_price → quote_token_amount ratio → live market price (anti-snipe). Connector-specific params ride extra_params under Gateway's own names (configAddress for - meteora — required there, feeConfigIndex/openTime for raydium, gasPrice/maxGas/slippagePct - for uniswap) — the same contract as clmm open's extra_params. + meteora — required there, ammConfigIndex for raydium) — the same contract as clmm open's + extra_params. Seeding slippage for uniswap/pancakeswap is the standard slippage_pct field. """ try: # Gateway's unified create-pool destructures exactly these; anything else # would be silently ignored there, so reject it loudly here. - supported_extra_params = { - "configAddress", "feeConfigIndex", "openTime", "gasPrice", "maxGas", "slippagePct", - } + supported_extra_params = {"configAddress", "ammConfigIndex"} extra_params = request.extra_params or {} unknown = set(extra_params) - supported_extra_params if unknown: @@ -325,6 +323,7 @@ async def create_amm_pool( base_token_amount=float(request.base_token_amount), quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount is not None else None, initial_price=float(request.initial_price) if request.initial_price is not None else None, + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, extra_params=request.extra_params, )) return AMMCreatePoolResponse(**result) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index eb5e6e3c..290a2b56 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -353,7 +353,7 @@ async def open_clmm_position( upper_price: 250 base_token_amount: 0.01 quote_token_amount: 2 - slippage_pct: 1 + slippage_pct: 1 (optional; omit to use the connector's configured slippagePct) wallet_address: (optional) extra_params: {"strategyType": 0} # Meteora-specific @@ -394,7 +394,8 @@ async def open_clmm_position( # opening a position without knowing its tokens would corrupt the position record. pool_info = check_gateway_error(await accounts_service.gateway_client.clmm_pool_info( connector=request.connector, - chain_network=request.network + chain_network=request.network, + pool_address=request.pool_address )) # Extract tokens from pool info @@ -421,7 +422,7 @@ async def open_clmm_position( upper_price=float(request.upper_price), base_token_amount=float(request.base_token_amount) if request.base_token_amount else None, quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount else None, - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0, + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, extra_params=request.extra_params )) @@ -557,13 +558,29 @@ async def add_liquidity_to_clmm_position( position_address: '...' base_token_amount: 0.5 quote_token_amount: 50.0 - slippage_pct: 1 + slippage_pct: 1 (optional; omit to use the connector's configured slippagePct) wallet_address: (optional) + extra_params: {"strategyType": 0} # Meteora-specific Returns: Transaction hash """ try: + # Same contract as /clmm/open: Gateway's unified add destructures ONLY + # strategyType from the body and silently drops any other key. + supported_extra_params = {"strategyType"} + if request.extra_params: + unknown = set(request.extra_params) - supported_extra_params + if unknown: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " + f"/trading/clmm/add honors only {sorted(supported_extra_params)} " + "and silently ignores everything else." + ) + ) + if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -584,7 +601,8 @@ async def add_liquidity_to_clmm_position( position_address=request.position_address, base_token_amount=float(request.base_token_amount) if request.base_token_amount else None, quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount else None, - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + extra_params=request.extra_params )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") @@ -667,6 +685,7 @@ async def remove_liquidity_from_clmm_position( network: 'solana-mainnet-beta' position_address: '...' percentage: 50 + slippage_pct: 1 (optional; Orca only — other connectors ignore it) wallet_address: (optional) Returns: @@ -691,7 +710,8 @@ async def remove_liquidity_from_clmm_position( chain_network=request.network, wallet_address=wallet_address, position_address=request.position_address, - percentage=float(request.percentage) + percentage=float(request.percentage), + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") @@ -1301,12 +1321,11 @@ async def create_clmm_pool( under Gateway's own names — the same contract as open's extra_params. """ try: - # Gateway's unified create-pool destructures exactly these; anything else - # would be silently ignored there, so reject it loudly here. - supported_extra_params = { - "binStep", "feeBps", "ammConfigIndex", "fee", "tickSpacing", - "ammConfig", "gasPrice", "maxGas", - } + # Gateway's unified create-pool destructures exactly these (binStep for + # meteora/orca, feeBps for meteora + EVM V3 fee tier, ammConfigIndex for + # raydium/pancakeswap-sol); anything else would be silently ignored there, + # so reject it loudly here. + supported_extra_params = {"binStep", "feeBps", "ammConfigIndex"} if request.extra_params: unknown = set(request.extra_params) - supported_extra_params if unknown: diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 96c6b406..588bc674 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -44,6 +44,25 @@ def get_transaction_status_from_response(gateway_response: dict) -> str: return "SUBMITTED" +# Gateway's unified /trading/swap routes destructure a fixed set of connector-specific +# keys from the request and silently ignore anything else. Reject unknown extra_params +# here so a typo fails loudly instead of quoting/trading with the parameter dropped. +SUPPORTED_SWAP_EXTRA_PARAMS = {"approximateIfNoExactOut"} + + +def validate_swap_extra_params(extra_params: Optional[dict]) -> None: + unknown = set(extra_params or {}) - SUPPORTED_SWAP_EXTRA_PARAMS + if unknown: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " + f"/trading/swap routes honor only {sorted(SUPPORTED_SWAP_EXTRA_PARAMS)} " + "and silently ignore everything else." + ) + ) + + @router.post("/swap/quote", response_model=SwapQuoteResponse) async def get_swap_quote( request: SwapQuoteRequest, @@ -58,12 +77,15 @@ async def get_swap_quote( trading_pair: 'SOL-USDC' side: 'BUY' amount: 1 - slippage_pct: 1 + slippage_pct: 1 (optional; omit to use the connector's configured slippagePct) + extra_params: {"approximateIfNoExactOut": false} # Solana routers Returns: - Quote with price, expected output amount, and gas estimate + Quote with price, expected output amount, and execution-safety fields """ try: + validate_swap_extra_params(request.extra_params) + if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -78,7 +100,8 @@ async def get_swap_quote( quote_asset=quote, amount=float(request.amount), side=request.side, - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + extra_params=request.extra_params )) # Re-frame Gateway's token-flow response (tokenIn/tokenOut) into pair terms, @@ -99,8 +122,8 @@ def _dec(key): price_impact_pct=_dec("priceImpactPct"), pool_address=result.get("poolAddress"), route_path=result.get("routePath"), - slippage_pct=(_dec("slippagePct") - or (request.slippage_pct if request.slippage_pct is not None else Decimal("1.0"))), + slippage_pct=(_dec("slippagePct") if result.get("slippagePct") is not None + else request.slippage_pct), ) except HTTPException: @@ -129,13 +152,16 @@ async def execute_swap( trading_pair: 'SOL-USDC' side: 'BUY' amount: 1 - slippage_pct: 1 + slippage_pct: 1 (optional; omit to use the connector's configured slippagePct) wallet_address: (optional, uses default if not provided) + extra_params: {"approximateIfNoExactOut": false} # Solana routers Returns: Transaction hash and swap details """ try: + validate_swap_extra_params(request.extra_params) + if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -160,7 +186,8 @@ async def execute_swap( quote_asset=quote, amount=float(request.amount), side=request.side, - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + extra_params=request.extra_params )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") if not transaction_hash: @@ -200,7 +227,7 @@ async def execute_swap( "input_amount": float(input_amount), "output_amount": float(output_amount), "price": float(price), - "slippage_pct": float(request.slippage_pct) if request.slippage_pct is not None else 1.0, + "slippage_pct": float(request.slippage_pct) if request.slippage_pct is not None else None, "status": tx_status, "pool_address": result.get("poolAddress") or result.get("pool_address") } diff --git a/services/gateway_client.py b/services/gateway_client.py index 87ccdf68..58936393 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -440,7 +440,8 @@ async def quote_swap( quote_asset: str, amount: float, side: str, - slippage_pct: Optional[float] = None + slippage_pct: Optional[float] = None, + extra_params: Optional[Dict] = None ) -> Dict: """ Get a swap quote via Gateway's unified /trading/swap/quote endpoint. @@ -450,6 +451,8 @@ async def quote_swap( ('jupiter/router', 'raydium/amm', 'meteora/clmm'). chain_network: 'chain-network' format (e.g. 'solana-mainnet-beta'). For amm/clmm providers Gateway resolves the pool from its pool list. + extra_params: Connector-specific query params under Gateway's own names + (e.g. approximateIfNoExactOut). The router validates keys first. """ params = { "chainNetwork": chain_network, @@ -461,6 +464,11 @@ async def quote_swap( } if slippage_pct is not None: params["slippagePct"] = str(slippage_pct) + if extra_params: + # Query params must be strings for aiohttp; Gateway's schema coerces + # "true"/"false" back to booleans. + for key, value in extra_params.items(): + params[key] = str(value).lower() if isinstance(value, bool) else str(value) return await self._request("GET", "trading/swap/quote", params=params) @@ -473,9 +481,14 @@ async def execute_swap( quote_asset: str, amount: float, side: str, - slippage_pct: Optional[float] = None + slippage_pct: Optional[float] = None, + extra_params: Optional[Dict] = None ) -> Dict: - """Execute a swap via Gateway's unified /trading/swap/execute endpoint.""" + """Execute a swap via Gateway's unified /trading/swap/execute endpoint. + + extra_params carries connector-specific params under Gateway's own names + (e.g. approximateIfNoExactOut). The router validates keys first. + """ payload = { "chainNetwork": chain_network, "connector": self.normalize_swap_connector(connector), @@ -487,6 +500,8 @@ async def execute_swap( } if slippage_pct is not None: payload["slippagePct"] = slippage_pct + if extra_params: + payload.update(extra_params) return await self._request("POST", "trading/swap/execute", json=payload) @@ -537,7 +552,8 @@ async def clmm_add_liquidity( position_address: str, base_token_amount: Optional[float] = None, quote_token_amount: Optional[float] = None, - slippage_pct: Optional[float] = None + slippage_pct: Optional[float] = None, + extra_params: Optional[Dict] = None ) -> Dict: """Add more liquidity to an existing CLMM position""" payload = { @@ -553,6 +569,10 @@ async def clmm_add_liquidity( if slippage_pct is not None: payload["slippagePct"] = slippage_pct + # Connector-specific parameters (e.g. Meteora's strategyType) + if extra_params: + payload.update(extra_params) + return await self._request("POST", "trading/clmm/add", json=payload) async def clmm_close_position( @@ -576,16 +596,24 @@ async def clmm_remove_liquidity( chain_network: str, wallet_address: str, position_address: str, - percentage: float + percentage: float, + slippage_pct: Optional[float] = None ) -> Dict: - """Remove liquidity from a CLMM position (partial)""" - return await self._request("POST", "trading/clmm/remove", json={ + """Remove liquidity from a CLMM position (partial). + + slippage_pct is only honored by the Orca connector; others ignore it. + """ + payload = { "connector": connector, "chainNetwork": chain_network, "walletAddress": wallet_address, "positionAddress": position_address, "percentageToRemove": percentage - }) + } + if slippage_pct is not None: + payload["slippagePct"] = slippage_pct + + return await self._request("POST", "trading/clmm/remove", json=payload) async def clmm_position_info( self, @@ -689,9 +717,9 @@ async def clmm_create_pool( """Create a new (empty) CLMM pool. extra_params carries the connector-specific create params under Gateway's own - names (binStep, feeBps, ammConfigIndex, fee, tickSpacing, ammConfig, gasPrice, - maxGas) and is spread into the payload — the same contract as clmm open's - extra_params. The router validates keys before this is called. + names (binStep, feeBps, ammConfigIndex) and is spread into the payload — the + same contract as clmm open's extra_params. The router validates keys before + this is called. """ payload = { "connector": connector, @@ -939,14 +967,16 @@ async def amm_create_pool( base_token_amount: float, quote_token_amount: Optional[float] = None, initial_price: Optional[float] = None, + slippage_pct: Optional[float] = None, extra_params: Optional[Dict] = None, ) -> Dict: """Create and seed a new AMM pool. - extra_params carries the connector-specific create params under Gateway's own - names (configAddress, feeConfigIndex, openTime, gasPrice, maxGas, slippagePct) - and is spread into the payload — the same contract as clmm open's extra_params. - The router validates keys before this is called. + slippage_pct is the seeding slippage (uniswap/pancakeswap only); omitted, the + connector's configured slippagePct applies. extra_params carries the + connector-specific create params under Gateway's own names (configAddress, + ammConfigIndex) and is spread into the payload — the same contract as clmm + open's extra_params. The router validates keys before this is called. """ payload = { "connector": connector, @@ -961,6 +991,8 @@ async def amm_create_pool( payload["quoteTokenAmount"] = quote_token_amount if initial_price is not None: payload["initialPrice"] = initial_price + if slippage_pct is not None: + payload["slippagePct"] = slippage_pct if extra_params: payload.update(extra_params) return await self._request("POST", "trading/amm/create-pool", json=payload) diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 0152f5cd..c2b8e305 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -110,6 +110,45 @@ async def test_quote_swap_zero_slippage_is_sent(client_and_calls): assert calls[0]["params"]["slippagePct"] == "0" +@pytest.mark.asyncio +async def test_quote_swap_omits_slippage_when_unset(client_and_calls): + """No slippage_pct => omit the key so Gateway applies the connector's configured default.""" + client, calls = client_and_calls + await client.quote_swap( + connector="jupiter", chain_network="solana-mainnet-beta", + base_asset="SOL", quote_asset="USDC", amount=1, side="SELL", + ) + assert "slippagePct" not in calls[0]["params"] + + +@pytest.mark.asyncio +async def test_quote_swap_extra_params_bool_as_query_string(client_and_calls): + """approximateIfNoExactOut rides extra_params; aiohttp needs query values as strings, + and Gateway's schema coerces 'false' back to boolean.""" + client, calls = client_and_calls + await client.quote_swap( + connector="jupiter", chain_network="solana-mainnet-beta", + base_asset="SOL", quote_asset="USDC", amount=1, side="BUY", + extra_params={"approximateIfNoExactOut": False}, + ) + assert calls[0]["params"]["approximateIfNoExactOut"] == "false" + + +@pytest.mark.asyncio +async def test_execute_swap_extra_params_and_slippage_omission(client_and_calls): + """extra_params keys land in the JSON body under Gateway's own names (booleans + intact); unset slippage_pct is omitted so the connector default applies.""" + client, calls = client_and_calls + await client.execute_swap( + connector="jupiter/router", chain_network="solana-mainnet-beta", + wallet_address="WALLET", base_asset="SOL", quote_asset="USDC", + amount=0.1, side="BUY", extra_params={"approximateIfNoExactOut": False}, + ) + body = calls[0]["json"] + assert body["approximateIfNoExactOut"] is False + assert "slippagePct" not in body + + # ============================================ # CLMM paths and payloads (unified /trading/clmm) # ============================================ @@ -151,6 +190,22 @@ async def test_clmm_add_liquidity_path_and_keys(client_and_calls): assert call["json"]["chainNetwork"] == "solana-mainnet-beta" +@pytest.mark.asyncio +async def test_clmm_add_liquidity_extra_params_and_slippage_omission(client_and_calls): + """strategyType rides extra_params into the body under Gateway's name; unset + slippage_pct is omitted so the connector default applies.""" + client, calls = client_and_calls + await client.clmm_add_liquidity( + connector="meteora", chain_network="solana-mainnet-beta", + wallet_address="WALLET", position_address="POS", + base_token_amount=0.5, quote_token_amount=50.0, + extra_params={"strategyType": 0}, + ) + body = calls[0]["json"] + assert body["strategyType"] == 0 + assert "slippagePct" not in body + + @pytest.mark.asyncio async def test_clmm_remove_liquidity_uses_percentage_to_remove(client_and_calls): client, calls = client_and_calls @@ -162,6 +217,20 @@ async def test_clmm_remove_liquidity_uses_percentage_to_remove(client_and_calls) assert (call["method"], call["path"]) == ("POST", "trading/clmm/remove") assert call["json"]["percentageToRemove"] == 50.0 assert "percentage" not in call["json"] + # No slippage_pct given => omitted (Orca falls back to its configured default) + assert "slippagePct" not in call["json"] + + +@pytest.mark.asyncio +async def test_clmm_remove_liquidity_sends_slippage_when_set(client_and_calls): + """Orca honors slippagePct on remove; the client must forward it.""" + client, calls = client_and_calls + await client.clmm_remove_liquidity( + connector="orca", chain_network="solana-mainnet-beta", + wallet_address="WALLET", position_address="POS", percentage=100.0, + slippage_pct=0.5, + ) + assert calls[0]["json"]["slippagePct"] == 0.5 @pytest.mark.asyncio @@ -348,32 +417,47 @@ async def test_amm_create_pool_meteora_extras(client_and_calls): c = calls[0] assert (c["method"], c["path"]) == ("POST", "trading/amm/create-pool") assert c["json"]["configAddress"] == "CFG123" - # Raydium/Uniswap extras and seed-price fields omitted when unset - for k in ("feeConfigIndex", "gasPrice", "maxGas", "quoteTokenAmount", "initialPrice"): + # Raydium extras, seeding slippage and seed-price fields omitted when unset + for k in ("ammConfigIndex", "slippagePct", "quoteTokenAmount", "initialPrice"): assert k not in c["json"] @pytest.mark.asyncio -async def test_amm_create_pool_raydium_fee_config_index(client_and_calls): +async def test_amm_create_pool_raydium_amm_config_index(client_and_calls): client, calls = client_and_calls await client.amm_create_pool(connector="raydium", chain_network=NET, wallet_address=WALLET, base_token="SOL", quote_token="USDC", base_token_amount=1.0, - extra_params={"feeConfigIndex": 0}, quote_token_amount=100.0) + extra_params={"ammConfigIndex": 0}, quote_token_amount=100.0) c = calls[0] - assert c["json"]["feeConfigIndex"] == 0 + assert c["json"]["ammConfigIndex"] == 0 assert c["json"]["quoteTokenAmount"] == 100.0 assert "configAddress" not in c["json"] @pytest.mark.asyncio -async def test_amm_create_pool_uniswap_gas_extras(client_and_calls): +async def test_amm_create_pool_uniswap_seeding_slippage(client_and_calls): + """EVM seeding slippage is the standard slippagePct field, not an extra param.""" client, calls = client_and_calls await client.amm_create_pool(connector="uniswap", chain_network="ethereum-mainnet", wallet_address=WALLET, base_token="WETH", quote_token="USDC", base_token_amount=1.0, - initial_price=3000.0, - extra_params={"gasPrice": 20.0, "maxGas": 500000}) + initial_price=3000.0, slippage_pct=0.5) c = calls[0] - assert c["json"]["gasPrice"] == 20.0 - assert c["json"]["maxGas"] == 500000 + assert c["json"]["slippagePct"] == 0.5 assert c["json"]["initialPrice"] == 3000.0 - assert "configAddress" not in c["json"] and "feeConfigIndex" not in c["json"] + assert "configAddress" not in c["json"] and "ammConfigIndex" not in c["json"] + + +@pytest.mark.asyncio +async def test_clmm_create_pool_meteora_extras(client_and_calls): + """CLMM create-pool extras ride extra_params under Gateway's names + (binStep/feeBps/ammConfigIndex — no gas keys, those don't exist on the route).""" + client, calls = client_and_calls + await client.clmm_create_pool(connector="meteora", chain_network=NET, wallet_address=WALLET, + base_token="SOL", quote_token="USDC", initial_price=100.0, + extra_params={"binStep": 20, "feeBps": 20}) + c = calls[0] + assert (c["method"], c["path"]) == ("POST", "trading/clmm/create-pool") + assert c["json"]["binStep"] == 20 + assert c["json"]["feeBps"] == 20 + assert c["json"]["initialPrice"] == 100.0 + assert "ammConfigIndex" not in c["json"] From cd3cab79586429572eacf5413a284f1d43f37ba0 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 20:21:49 -0700 Subject: [PATCH 10/18] fix(gateway): close the audit findings on the trading-route contracts - Pending CLMM open no longer 500s with an orphaned on-chain position: Gateway's OpenPositionResponse carries positionAddress only inside the confirmed-only data object (the schema strips everything else, so the old top-level fallback keys could never arrive). A submitted-not- confirmed open now returns 200 with position_address=None and the signature to poll; the poller's discovery sweep records the position once it lands. A confirmed response without an address is still a loud 500, and the confirmed path now reports status "confirmed". - extra_params validation hardened via a shared routers/gateway_extras helper: unknown keys, keys sent to a connector that ignores them, and wrong-typed values (incl. null, and bool-vs-int subclass traps) all 400 locally instead of being silently dropped or reaching Gateway as the string "None". - ROUTER_CONNECTORS gains dflow/okx/titan so bare names route to /router instead of misrouting to /clmm and 404ing. - Swap DB record: price is quote-per-base for BOTH sides (BUY was inverted), the pending fallback keeps tokenIn/tokenOut denominations (BUY no longer stores a base amount in the quote-denominated input column), side is normalized to uppercase, and the dead poolAddress read (never in the execute response schema) is an explicit None. - clmm_fetch_pools speaks each connector's real schema: meteora page/includeUnverified + "field:direction" sortBy; orca sortBy/sortDirection/verifiedOnly, with page>0 rejected loudly for orca instead of a silent no-op that echoed the requested page. - AMM quote/execute swap uppercase side like the unified path. - Honesty fixes: swap amount documented as base-denominated for BUY (ExactOut), poll docstring documents txStatus -2 NOT_FOUND as terminal, activeBinId is not meteora-only, and swap listings report a recorded slippage of 0 as 0 instead of null. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../repositories/gateway_swap_repository.py | 3 +- models/gateway_trading.py | 15 +- routers/gateway_amm.py | 23 ++- routers/gateway_clmm.py | 179 ++++++++++-------- routers/gateway_extras.py | 53 ++++++ routers/gateway_swap.py | 63 +++--- services/gateway_client.py | 34 ++-- test/test_extra_params_validation.py | 84 ++++++++ test/test_gateway_client_contract.py | 35 +++- 9 files changed, 344 insertions(+), 145 deletions(-) create mode 100644 routers/gateway_extras.py create mode 100644 test/test_extra_params_validation.py diff --git a/database/repositories/gateway_swap_repository.py b/database/repositories/gateway_swap_repository.py index c5aea52f..b0a7d263 100644 --- a/database/repositories/gateway_swap_repository.py +++ b/database/repositories/gateway_swap_repository.py @@ -157,7 +157,8 @@ def to_dict(self, swap: GatewaySwap) -> Dict: "input_amount": float(swap.input_amount), "output_amount": float(swap.output_amount), "price": float(swap.price), - "slippage_pct": float(swap.slippage_pct) if swap.slippage_pct else None, + # `is not None`: a recorded slippage of 0 is a real value, not "unset" + "slippage_pct": float(swap.slippage_pct) if swap.slippage_pct is not None else None, "gas_fee": float(swap.gas_fee) if swap.gas_fee else None, "gas_token": swap.gas_token, "status": swap.status, diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 748c89b1..ab944766 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -20,7 +20,9 @@ class SwapQuoteRequest(BaseModel): network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta', 'ethereum-mainnet')") trading_pair: str = Field(description="Trading pair in BASE-QUOTE format (e.g., 'SOL-USDC')") side: str = Field(description="Trade side: 'BUY' or 'SELL'") - amount: Decimal = Field(description="Amount to swap (in base token for SELL, quote token for BUY)") + amount: Decimal = Field( + description="Amount denominated in the BASE token (SELL: base to sell; BUY: base to receive — " + "Gateway quotes BUY as ExactOut)") slippage_pct: Optional[Decimal] = Field( default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") extra_params: Optional[Dict[str, Any]] = Field( @@ -65,7 +67,8 @@ class SwapExecuteRequest(BaseModel): network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") trading_pair: str = Field(description="Trading pair (e.g., 'SOL-USDC')") side: str = Field(description="Trade side: 'BUY' or 'SELL'") - amount: Decimal = Field(description="Amount to swap") + amount: Decimal = Field( + description="Amount denominated in the BASE token (SELL: base to sell; BUY: base to receive)") slippage_pct: Optional[Decimal] = Field( default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") @@ -112,7 +115,11 @@ class CLMMOpenPositionRequest(BaseModel): class CLMMOpenPositionResponse(BaseModel): """Response after opening a new CLMM position""" transaction_hash: str = Field(description="Transaction hash") - position_address: str = Field(description="Address of the newly created position") + position_address: Optional[str] = Field( + default=None, + description="Address of the newly created position. None when the transaction was " + "submitted but not yet confirmed (Gateway only knows the address once the tx lands) — " + "poll the transaction; the poller records the position once it appears on-chain") trading_pair: str = Field(description="Trading pair") pool_address: str = Field(description="Pool address") lower_price: Decimal = Field(description="Lower price bound") @@ -334,7 +341,7 @@ class CLMMPoolInfoResponse(BaseModel): price: Decimal = Field(description="Current pool price") base_token_amount: Decimal = Field(alias="baseTokenAmount", description="Total base token liquidity") quote_token_amount: Decimal = Field(alias="quoteTokenAmount", description="Total quote token liquidity") - active_bin_id: Optional[int] = Field(None, alias="activeBinId", description="Currently active bin ID (Meteora DLMM only)") + active_bin_id: Optional[int] = Field(None, alias="activeBinId", description="Currently active bin/tick ID") # No dynamicFeePct/minBinId/maxBinId: those are Meteora connector extensions that # Gateway's unified /trading/clmm/pool-info response schema strips before serialization, # so they can never arrive here — and nothing downstream consumes them. diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index a290179d..92ad1133 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -32,6 +32,7 @@ AMMRemoveLiquidityRequest, AMMTransactionResponse, ) +from routers.gateway_extras import ExtraParamsSpec, validate_extra_params from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error @@ -39,6 +40,14 @@ router = APIRouter(tags=["Gateway AMM"], prefix="/gateway") +# Gateway's unified create-pool destructure, per consuming connector: +# configAddress (meteora DAMM v2 — required there), ammConfigIndex (raydium CPMM). +# EVM seeding slippage is the standard slippage_pct field, not an extra param. +AMM_CREATE_POOL_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { + "configAddress": ((str,), {"meteora"}), + "ammConfigIndex": ((int,), {"raydium"}), +} + async def _require_gateway(accounts_service: AccountsService) -> None: if not await accounts_service.gateway_client.ping(): @@ -295,19 +304,9 @@ async def create_amm_pool( extra_params. Seeding slippage for uniswap/pancakeswap is the standard slippage_pct field. """ try: - # Gateway's unified create-pool destructures exactly these; anything else - # would be silently ignored there, so reject it loudly here. - supported_extra_params = {"configAddress", "ammConfigIndex"} + validate_extra_params(request.extra_params, AMM_CREATE_POOL_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/amm/create-pool") extra_params = request.extra_params or {} - unknown = set(extra_params) - supported_extra_params - if unknown: - raise HTTPException( - status_code=400, - detail=( - f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " - f"/trading/amm/create-pool honors only {sorted(supported_extra_params)}." - ) - ) if request.connector == "meteora" and not extra_params.get("configAddress"): raise HTTPException( status_code=400, diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 290a2b56..7a69a233 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -31,6 +31,7 @@ CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, ) +from routers.gateway_extras import ExtraParamsSpec, validate_extra_params from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error @@ -38,6 +39,21 @@ router = APIRouter(tags=["Gateway CLMM"], prefix="/gateway") +# Gateway's unified open/add destructure ONLY strategyType, and only Meteora +# consumes it (Spot=0 / Curve=1). +CLMM_LIQUIDITY_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { + "strategyType": ((int,), {"meteora"}), +} + +# Gateway's unified create-pool destructure, per consuming connector: binStep +# (meteora bin step / orca tick spacing), feeBps (meteora; required V3 fee tier +# for uniswap/pancakeswap), ammConfigIndex (raydium, pancakeswap-sol). +CLMM_CREATE_POOL_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { + "binStep": ((int,), {"meteora", "orca"}), + "feeBps": ((int, float), {"meteora", "uniswap", "pancakeswap"}), + "ammConfigIndex": ((int,), {"raydium", "pancakeswap-sol"}), +} + def get_transaction_status_from_response(gateway_response: dict) -> str: """ @@ -275,25 +291,37 @@ async def get_clmm_pools( logger.info(f"Fetching pools from Gateway ({connector}, page={page}, limit={limit}, query={search_term})") - # Build sort_by for Gateway (connector-specific format) - sort_by = None - if sort_key: - if connector.lower() == "meteora": - time_suffix = "_24h" if sort_key in ["volume", "fees"] else "" - direction = order_by if order_by else "desc" - sort_by = f"{sort_key}{time_suffix}:{direction}" - else: # orca - sort_by = sort_key - - gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( - connector=connector.lower(), - network="mainnet-beta", - page=page, - limit=limit, - query=search_term, - sort_by=sort_by, - include_unverified=include_unknown - )) + # The two fetch-pools routes take different params: meteora paginates and + # filters via page/includeUnverified with "field:direction" sortBy; orca does + # not paginate and uses sortBy + sortDirection + verifiedOnly. + if connector.lower() == "meteora": + time_suffix = "_24h" if sort_key in ["volume", "fees"] else "" + direction = order_by if order_by else "desc" + gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( + connector="meteora", + network="mainnet-beta", + limit=limit, + query=search_term, + sort_by=f"{sort_key}{time_suffix}:{direction}" if sort_key else None, + page=page, + include_unverified=include_unknown + )) + else: # orca + if page > 0: + raise HTTPException( + status_code=400, + detail="Orca's pool listing does not paginate; page is meteora-only. " + "Raise limit instead (max 100)." + ) + gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( + connector="orca", + network="mainnet-beta", + limit=limit, + query=search_term, + sort_by=sort_key, + sort_direction=order_by, + verified_only=not include_unknown + )) # Transform Gateway response to our format # Both Meteora and Orca now return same format: {pools: [...], total, page, pageSize} @@ -358,25 +386,13 @@ async def open_clmm_position( extra_params: {"strategyType": 0} # Meteora-specific Returns: - Transaction hash and position address + Transaction hash and position address. position_address is None when the + transaction was submitted but not yet confirmed — poll the transaction; the + poller's discovery sweep records the position once it lands on-chain. """ try: - # Gateway's unified open destructures ONLY strategyType from the body; any other - # extra_params key is silently dropped there. Reject unknown keys here so a typo - # (or a connector param the unified route does not carry) fails loudly instead of - # opening a position with the parameter ignored. - supported_extra_params = {"strategyType"} - if request.extra_params: - unknown = set(request.extra_params) - supported_extra_params - if unknown: - raise HTTPException( - status_code=400, - detail=( - f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " - f"/trading/clmm/open honors only {sorted(supported_extra_params)} " - "and silently ignores everything else." - ) - ) + validate_extra_params(request.extra_params, CLMM_LIQUIDITY_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/clmm/open") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -426,23 +442,51 @@ async def open_clmm_position( extra_params=request.extra_params )) - transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") + transaction_hash = result.get("signature") + if not transaction_hash: + raise HTTPException(status_code=500, detail="No transaction signature returned from Gateway") - # Position address can be at root level or nested in data object - data = result.get("data", {}) - position_address = (result.get("positionAddress") or result.get("position") - or data.get("positionAddress") or data.get("position")) + # Gateway's OpenPositionResponse carries position details only inside `data`, + # which is present only for CONFIRMED transactions (the response schema strips + # any other key, so there is no top-level fallback to read). + data = result.get("data") or {} + position_address = data.get("positionAddress") + tx_status = get_transaction_status_from_response(result) + + if not position_address: + if tx_status == "CONFIRMED": + raise HTTPException( + status_code=500, + detail="Gateway confirmed the open but returned no position address") + # Submitted-not-confirmed: the position address is unknowable until the tx + # lands. The tx IS in flight, so failing here would report a false failure + # and orphan the position; instead return the signature for the caller to + # poll — the transaction poller's discovery sweep records the position in + # the database once it appears on-chain. + logger.warning( + f"CLMM open submitted but not confirmed ({transaction_hash}); position address " + "unknown — the poller's discovery sweep will record the position once it lands") + return CLMMOpenPositionResponse( + transaction_hash=transaction_hash, + position_address=None, + trading_pair=trading_pair, + pool_address=request.pool_address, + lower_price=request.lower_price, + upper_price=request.upper_price, + base_token_amount_added=request.base_token_amount, + quote_token_amount_added=request.quote_token_amount, + position_rent=None, + status="submitted", + ) # Extract position rent (SOL locked for position NFT) position_rent = data.get("positionRent") if position_rent: logger.info(f"Position rent: {position_rent} SOL") - # Prefer the CONFIRMED on-chain amounts over the requested ones: slippage and - # rounding make them differ, and persisting the request silently diverges the - # DB from the chain. data is only present when Gateway confirmed the tx, so - # the requested amounts remain the fallback for submitted-not-confirmed - # (reconciled later by the poller). + # CONFIRMED path: prefer the on-chain amounts over the requested ones — + # slippage and rounding make them differ, and persisting the request + # silently diverges the DB from the chain. base_amount_added = data.get("baseTokenAmountAdded") if base_amount_added is None: base_amount_added = float(request.base_token_amount) if request.base_token_amount else 0 @@ -450,20 +494,12 @@ async def open_clmm_position( if quote_amount_added is None: quote_amount_added = float(request.quote_token_amount) if request.quote_token_amount else 0 - if not transaction_hash: - raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") - if not position_address: - raise HTTPException(status_code=500, detail="No position address returned from Gateway") - # Calculate percentage: (upper_price - lower_price) / lower_price percentage = None if request.lower_price and request.upper_price and request.lower_price > 0: percentage = float((request.upper_price - request.lower_price) / request.lower_price) logger.info(f"Position price range percentage: {percentage:.4f} ({percentage*100:.2f}%)") - # Get transaction status from Gateway response - tx_status = get_transaction_status_from_response(result) - # Extract gas fee from Gateway response gas_fee = data.get("fee") gas_token = get_native_gas_token(chain) @@ -529,7 +565,7 @@ async def open_clmm_position( base_token_amount_added=Decimal(str(base_amount_added)) if base_amount_added else None, quote_token_amount_added=Decimal(str(quote_amount_added)) if quote_amount_added else None, position_rent=Decimal(str(position_rent)) if position_rent else None, - status="submitted" + status="confirmed" ) except HTTPException: @@ -566,20 +602,8 @@ async def add_liquidity_to_clmm_position( Transaction hash """ try: - # Same contract as /clmm/open: Gateway's unified add destructures ONLY - # strategyType from the body and silently drops any other key. - supported_extra_params = {"strategyType"} - if request.extra_params: - unknown = set(request.extra_params) - supported_extra_params - if unknown: - raise HTTPException( - status_code=400, - detail=( - f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " - f"/trading/clmm/add honors only {sorted(supported_extra_params)} " - "and silently ignores everything else." - ) - ) + validate_extra_params(request.extra_params, CLMM_LIQUIDITY_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/clmm/add") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -1321,21 +1345,8 @@ async def create_clmm_pool( under Gateway's own names — the same contract as open's extra_params. """ try: - # Gateway's unified create-pool destructures exactly these (binStep for - # meteora/orca, feeBps for meteora + EVM V3 fee tier, ammConfigIndex for - # raydium/pancakeswap-sol); anything else would be silently ignored there, - # so reject it loudly here. - supported_extra_params = {"binStep", "feeBps", "ammConfigIndex"} - if request.extra_params: - unknown = set(request.extra_params) - supported_extra_params - if unknown: - raise HTTPException( - status_code=400, - detail=( - f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " - f"/trading/clmm/create-pool honors only {sorted(supported_extra_params)}." - ) - ) + validate_extra_params(request.extra_params, CLMM_CREATE_POOL_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/clmm/create-pool") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") diff --git a/routers/gateway_extras.py b/routers/gateway_extras.py new file mode 100644 index 00000000..94148d42 --- /dev/null +++ b/routers/gateway_extras.py @@ -0,0 +1,53 @@ +""" +Shared validation for connector-specific extra_params forwarded to Gateway's +unified /trading routes. + +Gateway destructures a fixed set of connector-specific keys from each request +body and silently ignores everything else, so hapi rejects loudly instead of +letting a typo'd key, a key sent to a connector that ignores it, or a value of +the wrong type get silently dropped or misparsed downstream. +""" +from typing import Any, Dict, Optional, Set, Tuple + +from fastapi import HTTPException + +# Spec entry: key -> (allowed value types, connectors that honor the key). +ExtraParamsSpec = Dict[str, Tuple[Tuple[type, ...], Set[str]]] + + +def validate_extra_params( + extra_params: Optional[Dict[str, Any]], + spec: ExtraParamsSpec, + connector: str, + route: str, +) -> None: + """Reject extra_params Gateway's `route` would silently drop or misparse. + + Typed swap providers like 'jupiter/router' validate on their base name. + """ + for key, value in (extra_params or {}).items(): + if key not in spec: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params key '{key}': Gateway's {route} honors " + f"only {sorted(spec)} and silently ignores everything else." + ), + ) + allowed_types, connectors = spec[key] + if connector.split("/")[0] not in connectors: + raise HTTPException( + status_code=400, + detail=( + f"extra_params.{key} only applies to {sorted(connectors)}; " + f"'{connector}' would silently ignore it." + ), + ) + # bool subclasses int: check it explicitly so True never passes as an int. + type_ok = (bool in allowed_types) if isinstance(value, bool) else isinstance(value, allowed_types) + if not type_ok: + expected = "/".join(t.__name__ for t in allowed_types) + raise HTTPException( + status_code=400, + detail=f"extra_params.{key} must be {expected}, got {type(value).__name__} ({value!r}).", + ) diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 588bc674..0183357b 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -14,6 +14,7 @@ from database.repositories import GatewaySwapRepository from deps import get_accounts_service, get_database_manager from models import SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, SwapQuoteResponse +from routers.gateway_extras import ExtraParamsSpec, validate_extra_params from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error @@ -44,23 +45,11 @@ def get_transaction_status_from_response(gateway_response: dict) -> str: return "SUBMITTED" -# Gateway's unified /trading/swap routes destructure a fixed set of connector-specific -# keys from the request and silently ignore anything else. Reject unknown extra_params -# here so a typo fails loudly instead of quoting/trading with the parameter dropped. -SUPPORTED_SWAP_EXTRA_PARAMS = {"approximateIfNoExactOut"} - - -def validate_swap_extra_params(extra_params: Optional[dict]) -> None: - unknown = set(extra_params or {}) - SUPPORTED_SWAP_EXTRA_PARAMS - if unknown: - raise HTTPException( - status_code=400, - detail=( - f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " - f"/trading/swap routes honor only {sorted(SUPPORTED_SWAP_EXTRA_PARAMS)} " - "and silently ignore everything else." - ) - ) +# Gateway's unified /trading/swap routes pass approximateIfNoExactOut only to the +# Solana router connectors' quote path; every other provider silently ignores it. +SWAP_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { + "approximateIfNoExactOut": ((bool,), {"jupiter", "dflow", "okx", "titan"}), +} @router.post("/swap/quote", response_model=SwapQuoteResponse) @@ -84,7 +73,8 @@ async def get_swap_quote( Quote with price, expected output amount, and execution-safety fields """ try: - validate_swap_extra_params(request.extra_params) + validate_extra_params(request.extra_params, SWAP_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/swap/quote") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -160,7 +150,8 @@ async def execute_swap( Transaction hash and swap details """ try: - validate_swap_extra_params(request.extra_params) + validate_extra_params(request.extra_params, SWAP_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/swap/execute") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -193,19 +184,30 @@ async def execute_swap( if not transaction_hash: raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") - # Extract swap data from Gateway response - # Gateway returns amounts nested under 'data' object + # Gateway's `data` (present only when it confirmed the tx) speaks token flow: + # amountIn is the tokenIn amount — quote for BUY, base for SELL — and the DB + # columns keep that tokenIn/tokenOut denomination. data = result.get("data", {}) amount_in_raw = data.get("amountIn") amount_out_raw = data.get("amountOut") - # Use amounts from Gateway response, fallback to request amount if not available - input_amount = Decimal(str(amount_in_raw)) if amount_in_raw is not None else request.amount - output_amount = Decimal(str(amount_out_raw)) if amount_out_raw is not None else Decimal("0") - - # Calculate price from actual swap amounts - # Price = output / input (how much quote you get/pay per base) - price = output_amount / input_amount if input_amount > 0 else Decimal("0") + side = request.side.upper() + if amount_in_raw is not None and amount_out_raw is not None: + input_amount = Decimal(str(amount_in_raw)) + output_amount = Decimal(str(amount_out_raw)) + # Price in quote-per-base for both sides: SELL flows base->quote (out/in), + # BUY flows quote->base (in/out). + if side == "SELL": + price = output_amount / input_amount if input_amount > 0 else Decimal("0") + else: + price = input_amount / output_amount if output_amount > 0 else Decimal("0") + else: + # Submitted-not-confirmed: only the request leg of the flow is known — + # request.amount is base-denominated (SELL: base in, BUY: base out). + # The unknown leg and price stay 0 placeholders. + input_amount = request.amount if side == "SELL" else Decimal("0") + output_amount = request.amount if side == "BUY" else Decimal("0") + price = Decimal("0") # Get transaction status from Gateway response tx_status = get_transaction_status_from_response(result) @@ -223,13 +225,14 @@ async def execute_swap( "trading_pair": request.trading_pair, "base_token": base, "quote_token": quote, - "side": request.side, + "side": side, "input_amount": float(input_amount), "output_amount": float(output_amount), "price": float(price), "slippage_pct": float(request.slippage_pct) if request.slippage_pct is not None else None, "status": tx_status, - "pool_address": result.get("poolAddress") or result.get("pool_address") + # Gateway's execute response schema carries no pool information + "pool_address": None } await swap_repo.create_swap(swap_data) diff --git a/services/gateway_client.py b/services/gateway_client.py index 58936393..5e0eb8e2 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -8,7 +8,7 @@ # Connectors whose bare name maps to a router-type swap provider on Gateway. # All other connectors default to their CLMM route (meteora, orca, raydium, pancakeswap-sol). -ROUTER_CONNECTORS = {"jupiter", "0x", "uniswap", "pancakeswap"} +ROUTER_CONNECTORS = {"jupiter", "0x", "uniswap", "pancakeswap", "dflow", "okx", "titan"} class GatewayError(Exception): @@ -775,30 +775,40 @@ async def clmm_fetch_pools( self, connector: str, network: str, - page: int = 0, limit: int = 50, query: Optional[str] = None, sort_by: Optional[str] = None, - include_unverified: bool = True + page: Optional[int] = None, + include_unverified: Optional[bool] = None, + sort_direction: Optional[str] = None, + verified_only: Optional[bool] = None ) -> Dict: """ Discover CLMM pools from the connector's own listing API (meteora, orca). This is a per-connector Gateway route (no unified equivalent): it proxies the - DEX's pool-discovery API rather than Gateway's saved pool list. + DEX's pool-discovery API rather than Gateway's saved pool list. The schemas + differ per connector — meteora takes page/includeUnverified and a + "field:direction" sortBy; orca takes sortDirection/verifiedOnly and does not + paginate. Only keys the caller sets are sent; AJV strips unknown keys + Gateway-side, so sending the wrong connector's knob would be a silent no-op. """ params = { "network": network, "limit": limit, } - if page > 0: - params["page"] = page if query: params["query"] = query if sort_by: params["sortBy"] = sort_by - if not include_unverified: - params["includeUnverified"] = "false" + if page is not None and page > 0: + params["page"] = page + if include_unverified is not None: + params["includeUnverified"] = "true" if include_unverified else "false" + if sort_direction: + params["sortDirection"] = sort_direction + if verified_only is not None: + params["verifiedOnly"] = "true" if verified_only else "false" return await self._request("GET", f"connectors/{connector}/clmm/fetch-pools", params=params) @@ -854,7 +864,7 @@ async def amm_quote_swap( "chainNetwork": chain_network, "poolAddress": pool_address, "baseToken": base_token, - "side": side, + "side": side.upper(), "amount": amount, } if slippage_pct is not None: @@ -879,7 +889,7 @@ async def amm_execute_swap( "walletAddress": wallet_address, "poolAddress": pool_address, "baseToken": base_token, - "side": side, + "side": side.upper(), "amount": amount, } if slippage_pct is not None: @@ -1015,7 +1025,9 @@ async def poll_transaction( Returns: Transaction status dict with fields: - - txStatus: 1 for confirmed, 0 for pending, -1 for failed + - txStatus: 1 confirmed, 0 pending, -1 failed, -2 not found + (-2 is terminal on Solana once the blockhash expires — treat it + as dropped, not merely pending) - fee: Transaction fee amount - error: Parsed error message if transaction failed (e.g., "SLIPPAGE_EXCEEDED (0x1771): ...") - txData: Full transaction data including meta.err diff --git a/test/test_extra_params_validation.py b/test/test_extra_params_validation.py new file mode 100644 index 00000000..6d1bdf50 --- /dev/null +++ b/test/test_extra_params_validation.py @@ -0,0 +1,84 @@ +""" +Unit tests for routers.gateway_extras.validate_extra_params — the shared guard +that keeps connector-specific extra_params from being silently dropped or +misparsed by Gateway's unified /trading routes. +""" +import pytest +from fastapi import HTTPException + +from routers.gateway_extras import validate_extra_params + +SPEC = { + "approximateIfNoExactOut": ((bool,), {"jupiter", "dflow", "okx", "titan"}), + "strategyType": ((int,), {"meteora"}), + "configAddress": ((str,), {"meteora"}), + "feeBps": ((int, float), {"meteora", "uniswap"}), +} + + +def _detail(exc_info): + return exc_info.value.detail + + +def test_none_and_empty_pass(): + validate_extra_params(None, SPEC, "jupiter", "route") + validate_extra_params({}, SPEC, "jupiter", "route") + + +def test_valid_params_pass(): + validate_extra_params({"approximateIfNoExactOut": False}, SPEC, "jupiter", "route") + validate_extra_params({"strategyType": 0}, SPEC, "meteora", "route") + validate_extra_params({"feeBps": 0.25}, SPEC, "uniswap", "route") + + +def test_typed_provider_validates_on_base_name(): + validate_extra_params({"approximateIfNoExactOut": True}, SPEC, "jupiter/router", "route") + + +def test_unknown_key_rejected(): + with pytest.raises(HTTPException) as exc: + validate_extra_params({"gasPrice": 20}, SPEC, "uniswap", "the route") + assert exc.value.status_code == 400 + assert "gasPrice" in _detail(exc) + + +def test_wrong_connector_rejected(): + """A key the connector would silently ignore must 400, not pass.""" + with pytest.raises(HTTPException) as exc: + validate_extra_params({"strategyType": 0}, SPEC, "orca", "route") + assert exc.value.status_code == 400 + assert "orca" in _detail(exc) + + +def test_wrong_connector_rejected_for_typed_provider(): + with pytest.raises(HTTPException) as exc: + validate_extra_params({"approximateIfNoExactOut": True}, SPEC, "meteora/clmm", "route") + assert exc.value.status_code == 400 + + +def test_none_value_rejected(): + """A null value would reach Gateway as the string 'None' on GET paths.""" + with pytest.raises(HTTPException) as exc: + validate_extra_params({"approximateIfNoExactOut": None}, SPEC, "jupiter", "route") + assert exc.value.status_code == 400 + assert "bool" in _detail(exc) + + +def test_wrong_type_rejected(): + with pytest.raises(HTTPException) as exc: + validate_extra_params({"approximateIfNoExactOut": "yes"}, SPEC, "jupiter", "route") + assert exc.value.status_code == 400 + + +def test_bool_does_not_pass_as_int(): + """bool subclasses int — True must not satisfy an int-typed key.""" + with pytest.raises(HTTPException) as exc: + validate_extra_params({"strategyType": True}, SPEC, "meteora", "route") + assert exc.value.status_code == 400 + assert "int" in _detail(exc) + + +def test_int_does_not_pass_as_bool(): + with pytest.raises(HTTPException) as exc: + validate_extra_params({"approximateIfNoExactOut": 1}, SPEC, "jupiter", "route") + assert exc.value.status_code == 400 diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index c2b8e305..de32b138 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -42,6 +42,11 @@ async def fake_request(method, path, params=None, json=None): ("0x", "0x/router"), ("uniswap", "uniswap/router"), ("pancakeswap", "pancakeswap/router"), + # The full Solana router roster Gateway routes as first-class providers — + # a bare name missing here would misroute to /clmm and 404. + ("dflow", "dflow/router"), + ("okx", "okx/router"), + ("titan", "titan/router"), ("meteora", "meteora/clmm"), ("orca", "orca/clmm"), ("raydium", "raydium/clmm"), @@ -269,11 +274,33 @@ async def test_clmm_pool_info_uses_unified_endpoint(client_and_calls): @pytest.mark.asyncio -async def test_clmm_fetch_pools_path(client_and_calls): +async def test_clmm_fetch_pools_meteora_params(client_and_calls): + """Meteora's fetch-pools paginates and filters via page/includeUnverified.""" client, calls = client_and_calls - await client.clmm_fetch_pools(connector="meteora", network="mainnet-beta", limit=10) + await client.clmm_fetch_pools(connector="meteora", network="mainnet-beta", limit=10, + sort_by="volume_24h:desc", page=2, include_unverified=False) call = calls[0] assert (call["method"], call["path"]) == ("GET", "connectors/meteora/clmm/fetch-pools") + assert call["params"]["page"] == 2 + assert call["params"]["includeUnverified"] == "false" + assert call["params"]["sortBy"] == "volume_24h:desc" + for orca_only in ("sortDirection", "verifiedOnly"): + assert orca_only not in call["params"] + + +@pytest.mark.asyncio +async def test_clmm_fetch_pools_orca_params(client_and_calls): + """Orca's fetch-pools takes sortDirection/verifiedOnly and has no pagination — + sending meteora's knobs would be silently stripped by Gateway's AJV.""" + client, calls = client_and_calls + await client.clmm_fetch_pools(connector="orca", network="mainnet-beta", limit=10, + sort_by="volume", sort_direction="desc", verified_only=True) + call = calls[0] + assert (call["method"], call["path"]) == ("GET", "connectors/orca/clmm/fetch-pools") + assert call["params"]["sortDirection"] == "desc" + assert call["params"]["verifiedOnly"] == "true" + for meteora_only in ("page", "includeUnverified"): + assert meteora_only not in call["params"] # ============================================ @@ -362,12 +389,14 @@ async def test_amm_quote_swap_path_and_slippage_omitted(client_and_calls): async def test_amm_execute_swap_path(client_and_calls): client, calls = client_and_calls await client.amm_execute_swap(connector="uniswap", chain_network="ethereum-mainnet", wallet_address=WALLET, - pool_address=POOL, base_token="WETH", side="BUY", amount=1.0, slippage_pct=0.5) + pool_address=POOL, base_token="WETH", side="buy", amount=1.0, slippage_pct=0.5) c = calls[0] assert (c["method"], c["path"]) == ("POST", "trading/amm/execute-swap") assert c["json"]["walletAddress"] == WALLET assert c["json"]["chainNetwork"] == "ethereum-mainnet" assert c["json"]["slippagePct"] == 0.5 + # Gateway's schema enum-rejects lowercase; the client normalizes like the unified swap path + assert c["json"]["side"] == "BUY" @pytest.mark.asyncio From 09c7df7af633a2ec9f8cf7540691be5d67186453 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 20:36:03 -0700 Subject: [PATCH 11/18] fix(gateway): make the pending-open reconciliation real; stop reporting failed txs as submitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the re-audit of cd3cab7: - The position discovery sweep now covers every Solana CLMM connector whose open can return submitted-not-confirmed (meteora, raydium, pancakeswap-sol, plus orca for externally-created positions) — previously meteora-only, so the pending-open path's "the poller records it once it lands" contract was false for raydium/pancakeswap-sol and those positions were permanently orphaned from the DB. - get_transaction_status_from_response (both routers) maps Gateway's negative statuses to FAILED instead of folding them into SUBMITTED: a failed EVM swap (status -1, zeroed amounts) is now recorded and returned as failed, and the swap execute response reports confirmed/submitted/failed honestly instead of a hardcoded "submitted". - The CLMM open pending branch rejects the EVM late-revert shape (data present without a position address, or negative status) with a loud 500 instead of returning 200 "submitted" for a tx that definitively failed on-chain. - gas_fee falsy-zero in swap listings fixed the same way as slippage_pct one line above it (a recorded 0 must not report as null). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../repositories/gateway_swap_repository.py | 2 +- routers/gateway_clmm.py | 27 ++++++++++--------- routers/gateway_swap.py | 25 ++++++++--------- services/gateway_transaction_poller.py | 14 +++++++--- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/database/repositories/gateway_swap_repository.py b/database/repositories/gateway_swap_repository.py index b0a7d263..51c75ed6 100644 --- a/database/repositories/gateway_swap_repository.py +++ b/database/repositories/gateway_swap_repository.py @@ -159,7 +159,7 @@ def to_dict(self, swap: GatewaySwap) -> Dict: "price": float(swap.price), # `is not None`: a recorded slippage of 0 is a real value, not "unset" "slippage_pct": float(swap.slippage_pct) if swap.slippage_pct is not None else None, - "gas_fee": float(swap.gas_fee) if swap.gas_fee else None, + "gas_fee": float(swap.gas_fee) if swap.gas_fee is not None else None, "gas_token": swap.gas_token, "status": swap.status, "pool_address": swap.pool_address, diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 7a69a233..0e11a2b4 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -57,23 +57,18 @@ def get_transaction_status_from_response(gateway_response: dict) -> str: """ - Determine transaction status from Gateway response. - - Gateway returns status field in the response: - - status: 1 = confirmed - - status: 0 = pending/submitted - - Returns: - "CONFIRMED" if status == 1 - "SUBMITTED" if status == 0 or not present + Determine transaction status from Gateway response: + status 1 -> CONFIRMED, negative (-1 failed, -2 dropped) -> FAILED, + 0 or missing -> SUBMITTED. """ status = gateway_response.get("status") - # Status 1 means transaction is confirmed on-chain if status == 1: return "CONFIRMED" - - # Status 0 or missing means submitted but not confirmed yet + # Gateway's TransactionStatus uses negative values for terminal failures + # (e.g. an EVM revert surfaced through the extended-poll path). + if isinstance(status, (int, float)) and status < 0: + return "FAILED" return "SUBMITTED" @@ -458,6 +453,14 @@ async def open_clmm_position( raise HTTPException( status_code=500, detail="Gateway confirmed the open but returned no position address") + # data present without a position address is the EVM revert shape + # (uniswap/pancakeswap return the receipt with an empty address when the + # tx landed but reverted); a negative status is a terminal failure on any + # chain. Both are definitive failures, not pending submissions. + if tx_status == "FAILED" or result.get("data") is not None: + raise HTTPException( + status_code=500, + detail=f"Open position transaction failed on-chain ({transaction_hash})") # Submitted-not-confirmed: the position address is unknowable until the tx # lands. The tx IS in flight, so failing here would report a false failure # and orphan the position; instead return the signature for the caller to diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 0183357b..6df96c4c 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -25,23 +25,18 @@ def get_transaction_status_from_response(gateway_response: dict) -> str: """ - Determine transaction status from Gateway response. - - Gateway returns status field in the response: - - status: 1 = confirmed - - status: 0 = pending/submitted - - Returns: - "CONFIRMED" if status == 1 - "SUBMITTED" if status == 0 or not present + Determine transaction status from Gateway response: + status 1 -> CONFIRMED, negative (-1 failed, -2 dropped) -> FAILED, + 0 or missing -> SUBMITTED. """ status = gateway_response.get("status") - # Status 1 means transaction is confirmed on-chain if status == 1: return "CONFIRMED" - - # Status 0 or missing means submitted but not confirmed yet + # Gateway's TransactionStatus uses negative values for terminal failures + # (e.g. a failed EVM swap returns status -1 with zeroed amounts). + if isinstance(status, (int, float)) and status < 0: + return "FAILED" return "SUBMITTED" @@ -244,9 +239,11 @@ async def execute_swap( return SwapExecuteResponse( transaction_hash=transaction_hash, trading_pair=request.trading_pair, - side=request.side, + side=side, amount=request.amount, - status="submitted" + # "confirmed" / "submitted" / "failed" — a failed EVM swap comes back as + # status -1 with zeroed amounts, which must not read as in-flight. + status=tx_status.lower() ) except HTTPException: diff --git a/services/gateway_transaction_poller.py b/services/gateway_transaction_poller.py index ba6e254f..0bf5902f 100644 --- a/services/gateway_transaction_poller.py +++ b/services/gateway_transaction_poller.py @@ -379,11 +379,19 @@ async def poll_transaction_once(self, tx_hash: str, network_id: str) -> Optional # Position State Polling & Discovery # ============================================ - # Supported CLMM connectors and their default networks + # Supported CLMM connectors and their default networks. The discovery sweep is + # also the reconciliation path for opens that returned submitted-not-confirmed + # (position_address unknown at open time), so every Solana connector whose open + # can pend (meteora, raydium, pancakeswap-sol) must be listed; orca rides along + # to reconcile externally-created positions. All four speak the unified + # /trading/clmm/positions-owned schema. SUPPORTED_CLMM_CONFIGS = [ {"connector": "meteora", "chain": "solana", "network": "mainnet-beta"}, - # Add more connectors as they become supported: - # {"connector": "raydium", "chain": "solana", "network": "mainnet-beta"}, + {"connector": "raydium", "chain": "solana", "network": "mainnet-beta"}, + {"connector": "pancakeswap-sol", "chain": "solana", "network": "mainnet-beta"}, + {"connector": "orca", "chain": "solana", "network": "mainnet-beta"}, + # EVM CLMM opens never return the pending shape (data always present), so + # discovery is not load-bearing there: # {"connector": "uniswap", "chain": "ethereum", "network": "mainnet"}, ] From b4d7f2ca4094e4235bcfbabf5ff624f089a237c2 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 21:22:14 -0700 Subject: [PATCH 12/18] fix(gateway): remediate the complete-audit findings; document accepted residuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Poller (the load-bearing cluster): - _check_transaction_status classifies on txStatus ALONE: Gateway returns txStatus 0 WITH an error message for transient poll failures ("poll again, don't give up") — the error field no longer promotes a pending tx to FAILED (one RPC hiccup used to permanently fail in-flight swaps and close events). -2 NOT_FOUND is DROPPED (terminal after a 180s blockhash grace) instead of polled for an hour and mislabeled timeout; 0 is an explicit PENDING distinct from None (no information). - The age timeout fires only after a successful poll says pending — never while Gateway is unreachable (an outage used to mass-FAIL everything over an hour old, including confirmed txs). One availability gate per cycle replaces per-call pings. - Position close detection: a single position-info 404/500 no longer closes a position — 3 consecutive misses required (mirrors the lp_executor gate); the router refresh no longer closes on absence from one positions-owned read; discovery skips reopening positions closed within a 300s grace so a lagging listing can't flap CLOSED->OPEN. - Failed txs record their gas fee; fee of exactly 0 survives. Fee bookkeeping (double-count / phantom-fee cluster): - close/collect endpoints mutate the position ONLY when Gateway confirmed inline; submitted txs are booked once by the poller's confirm path (which now books close fees before closing); failed txs mutate nothing. Previously every pending collect double-counted and every failed close permanently inflated *_fee_collected. - ADD_LIQUIDITY raises the PnL baseline (initial amounts) on confirm — pnl_summary no longer counts added capital as profit. Honesty and contract: - add/remove/close/collect responses report confirmed/submitted/failed instead of hardcoded "submitted"; writes on unrecorded positions log loudly instead of silently dropping the event. - Swap summary: quote-denominated volume per quote token (was summing the base leg across mixed pairs while claiming quote); status filter case-insensitive; tz-aware time filters; 10k-row cap logged. - CLMM remove renames percentage -> percentage_to_remove (matches AMM and Gateway; position.percentage still means range width). - close/collect honor an explicit request wallet (same precedence as open/add) and drop the required-but-unused pool_address 400. - get_native_gas_token single-sourced in gateway_client (three drifted copies produced MATIC/None/UNKNOWN for one chain); status mapping single-sourced in gateway_extras. - Wallet placeholder check matches Gateway's real "" template; unreachable Gateway raises 503 instead of "No wallet configured" 400 or "'error' in None" crashes; hardware wallet addresses included in discovery/balance sweeps; deprecated /pools maps Solana routers to solana; position-info 503s on connection error. - Dead code removed per convention: unused request models, legacy _poll_open_positions wrapper, poll_transaction_once, unused repo helpers. DISCOVERED event type documented everywhere event types are enumerated. Accepted residuals documented in place, NOT fixed (by decision): pending-tx amounts/price never backfilled from txData; discovery-time entry price/synthetic history for pending opens; int-only extra_params strictness; legacy lowercase side rows. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../conf/.password_verification | 1 + .../conf/conf_client.yml | 179 ++++++ .../conf/conf_fee_overrides.yml | 526 ++++++++++++++++++ .../conf/connectors/.dockerignore | 1 + .../conf/connectors/.gitignore | 0 .../conf/connectors/xrpl.yml | 20 + .../controllers/001_pmm_xrpl_USDC-XRP.yml | 37 ++ .../conf/hummingbot_logs.yml | 83 +++ .../pmm_USDC-XRP_20260807-20260807-145233.yml | 3 + ...m_USDC-XRP_20260807-20260807-145233.sqlite | Bin 0 -> 245760 bytes .../conf/.password_verification | 1 + .../conf/conf_client.yml | 179 ++++++ .../conf/conf_fee_overrides.yml | 526 ++++++++++++++++++ .../conf/connectors/.dockerignore | 1 + .../conf/connectors/.gitignore | 0 .../conf/connectors/gate_io_perpetual.yml | 11 + .../conf/controllers/sndk_gate_hft_mm.yml | 33 ++ .../conf/hummingbot_logs.yml | 83 +++ .../sndk-gate-hft-mm-20260818-030708.yml | 4 + .../sndk-gate-hft-mm-20260818-030708.sqlite | Bin 0 -> 204800 bytes .../conf/.password_verification | 1 + .../conf/conf_client.yml | 179 ++++++ .../conf/conf_fee_overrides.yml | 526 ++++++++++++++++++ .../conf/connectors/.dockerignore | 1 + .../conf/connectors/.gitignore | 0 .../conf/connectors/gate_io_perpetual.yml | 11 + .../conf/controllers/sndk_gate_hft_mm.yml | 33 ++ .../conf/hummingbot_logs.yml | 83 +++ .../sndk_gate_hft_mm-20260818-032452.yml | 5 + .../sndk_gate_hft_mm-20260818-032452.sqlite | Bin 0 -> 262144 bytes .../conf/.password_verification | 1 + .../conf/conf_client.yml | 179 ++++++ .../conf/conf_fee_overrides.yml | 526 ++++++++++++++++++ .../conf/connectors/.dockerignore | 1 + .../conf/connectors/.gitignore | 0 .../conf/connectors/xrpl.yml | 20 + .../controllers/001_pmm_xrpl_USDC-XRP.yml | 37 ++ .../conf/hummingbot_logs.yml | 83 +++ .../xrpl-pmm-usdc-xrp-20260807-171512.yml | 3 + .../xrpl-pmm-usdc-xrp-20260807-171512.sqlite | Bin 0 -> 212992 bytes .../conf/.password_verification | 1 + .../conf/conf_client.yml | 179 ++++++ .../conf/conf_fee_overrides.yml | 526 ++++++++++++++++++ .../conf/connectors/.dockerignore | 1 + .../conf/connectors/.gitignore | 0 .../conf/connectors/xrpl.yml | 20 + .../conf/controllers/xrpl_xrp_usdc_mm.yml | 39 ++ .../conf/hummingbot_logs.yml | 83 +++ .../xrpl-xrp-usdc-mm-20260807-173803.yml | 4 + .../xrpl-xrp-usdc-mm-20260807-173803.sqlite | Bin 0 -> 475136 bytes .../conf/.password_verification | 1 + .../conf/conf_client.yml | 179 ++++++ .../conf/conf_fee_overrides.yml | 526 ++++++++++++++++++ .../conf/connectors/.dockerignore | 1 + .../conf/connectors/.gitignore | 0 .../conf/connectors/xrpl.yml | 20 + .../conf/controllers/xrpl_xrp_usdc_mm.yml | 39 ++ .../conf/hummingbot_logs.yml | 83 +++ .../xrpl-xrp-usdc-mm-20260807-185151.yml | 4 + .../xrpl-xrp-usdc-mm-20260807-185151.sqlite | Bin 0 -> 413696 bytes .../generic/lp_rebalancer/lp_rebalancer.py | 24 +- database/models.py | 3 +- .../repositories/gateway_clmm_repository.py | 111 ++-- .../repositories/gateway_swap_repository.py | 38 +- models/__init__.py | 10 - models/gateway_trading.py | 81 +-- routers/gateway.py | 8 +- routers/gateway_clmm.py | 372 ++++++------- routers/gateway_extras.py | 29 +- routers/gateway_swap.py | 19 +- services/gateway_client.py | 53 +- services/gateway_transaction_poller.py | 328 +++++++---- services/gateway_wallet_service.py | 19 +- test/test_gateway_client_contract.py | 4 +- test/test_gateway_error_masking.py | 34 +- 75 files changed, 5705 insertions(+), 511 deletions(-) create mode 100644 bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/.password_verification create mode 100644 bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/conf_client.yml create mode 100644 bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/conf_fee_overrides.yml create mode 100644 bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/.dockerignore create mode 100644 bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/.gitignore create mode 100644 bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/xrpl.yml create mode 100644 bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/controllers/001_pmm_xrpl_USDC-XRP.yml create mode 100755 bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/hummingbot_logs.yml create mode 100644 bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/scripts/pmm_USDC-XRP_20260807-20260807-145233.yml create mode 100644 bots/archived/pmm_USDC-XRP_20260807-20260807-145233/data/pmm_USDC-XRP_20260807-20260807-145233.sqlite create mode 100644 bots/archived/sndk-gate-hft-mm-20260818-030708/conf/.password_verification create mode 100644 bots/archived/sndk-gate-hft-mm-20260818-030708/conf/conf_client.yml create mode 100644 bots/archived/sndk-gate-hft-mm-20260818-030708/conf/conf_fee_overrides.yml create mode 100644 bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/.dockerignore create mode 100644 bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/.gitignore create mode 100644 bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/gate_io_perpetual.yml create mode 100644 bots/archived/sndk-gate-hft-mm-20260818-030708/conf/controllers/sndk_gate_hft_mm.yml create mode 100755 bots/archived/sndk-gate-hft-mm-20260818-030708/conf/hummingbot_logs.yml create mode 100644 bots/archived/sndk-gate-hft-mm-20260818-030708/conf/scripts/sndk-gate-hft-mm-20260818-030708.yml create mode 100644 bots/archived/sndk-gate-hft-mm-20260818-030708/data/sndk-gate-hft-mm-20260818-030708.sqlite create mode 100644 bots/archived/sndk_gate_hft_mm-20260818-032452/conf/.password_verification create mode 100644 bots/archived/sndk_gate_hft_mm-20260818-032452/conf/conf_client.yml create mode 100644 bots/archived/sndk_gate_hft_mm-20260818-032452/conf/conf_fee_overrides.yml create mode 100644 bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/.dockerignore create mode 100644 bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/.gitignore create mode 100644 bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/gate_io_perpetual.yml create mode 100644 bots/archived/sndk_gate_hft_mm-20260818-032452/conf/controllers/sndk_gate_hft_mm.yml create mode 100755 bots/archived/sndk_gate_hft_mm-20260818-032452/conf/hummingbot_logs.yml create mode 100644 bots/archived/sndk_gate_hft_mm-20260818-032452/conf/scripts/sndk_gate_hft_mm-20260818-032452.yml create mode 100644 bots/archived/sndk_gate_hft_mm-20260818-032452/data/sndk_gate_hft_mm-20260818-032452.sqlite create mode 100644 bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/.password_verification create mode 100644 bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/conf_client.yml create mode 100644 bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/conf_fee_overrides.yml create mode 100644 bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/.dockerignore create mode 100644 bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/.gitignore create mode 100644 bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/xrpl.yml create mode 100644 bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/controllers/001_pmm_xrpl_USDC-XRP.yml create mode 100755 bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/hummingbot_logs.yml create mode 100644 bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/scripts/xrpl-pmm-usdc-xrp-20260807-171512.yml create mode 100644 bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/data/xrpl-pmm-usdc-xrp-20260807-171512.sqlite create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-173803/conf/.password_verification create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-173803/conf/conf_client.yml create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-173803/conf/conf_fee_overrides.yml create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-173803/conf/connectors/.dockerignore create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-173803/conf/connectors/.gitignore create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-173803/conf/connectors/xrpl.yml create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-173803/conf/controllers/xrpl_xrp_usdc_mm.yml create mode 100755 bots/archived/xrpl-xrp-usdc-mm-20260807-173803/conf/hummingbot_logs.yml create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-173803/conf/scripts/xrpl-xrp-usdc-mm-20260807-173803.yml create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-173803/data/xrpl-xrp-usdc-mm-20260807-173803.sqlite create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/.password_verification create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/conf_client.yml create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/conf_fee_overrides.yml create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/.dockerignore create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/.gitignore create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/xrpl.yml create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/controllers/xrpl_xrp_usdc_mm.yml create mode 100755 bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/hummingbot_logs.yml create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/scripts/xrpl-xrp-usdc-mm-20260807-185151.yml create mode 100644 bots/archived/xrpl-xrp-usdc-mm-20260807-185151/data/xrpl-xrp-usdc-mm-20260807-185151.sqlite diff --git a/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/.password_verification b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/.password_verification new file mode 100644 index 00000000..d1a274b6 --- /dev/null +++ b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/.password_verification @@ -0,0 +1 @@ +7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20226463613937613132666365383432623664323437613830393966313638373662227d2c202263697068657274657874223a20223537393331373032636666343037396439623430222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20226536376664366338373033386336613439653066373938316366656138656539227d2c20226d6163223a202233366134323163646334303464393433343264656166613037623235373866363734383930653961393765303037393231333132326532333464303666323034227d2c202276657273696f6e223a20332c2022616c696173223a2022227d \ No newline at end of file diff --git a/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/conf_client.yml b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/conf_client.yml new file mode 100644 index 00000000..37833163 --- /dev/null +++ b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/conf_client.yml @@ -0,0 +1,179 @@ +#################################### +### client_config_map config ### +#################################### + +instance_id: pmm_USDC-XRP_20260807-20260807-145233 + +# Fetch trading pairs from all exchanges if True, otherwise fetch only from connected exchanges. +fetch_pairs_from_all_exchanges: false + +log_level: INFO + +debug_console: false + +strategy_report_interval: 900.0 + +logger_override_whitelist: +- hummingbot.strategy.arbitrage +- hummingbot.strategy.cross_exchange_market_making +- conf + +log_file_path: /home/hummingbot/logs + +kill_switch_mode: {} + +# What to auto-fill in the prompt after each import command (start/config) +autofill_import: disabled + +# MQTT Bridge configuration. +mqtt_bridge: + mqtt_host: localhost + mqtt_port: 1883 + mqtt_username: '' + mqtt_password: '' + mqtt_namespace: hbot + mqtt_ssl: false + mqtt_logger: true + mqtt_notifier: true + mqtt_commands: true + mqtt_events: true + mqtt_external_events: true + mqtt_autostart: true + +# Error log sharing +send_error_logs: true + +# Advanced database options, currently supports SQLAlchemy's included dialects +# Reference: https://docs.sqlalchemy.org/en/13/dialects/ +# To use an instance of SQLite DB the required configuration is +# db_engine: sqlite +# To use a DBMS the required configuration is +# db_host: 127.0.0.1 +# db_port: 3306 +# db_username: username +# db_password: password +# db_name: dbname +db_mode: + db_engine: sqlite + +# Balance Limit Configurations +# e.g. Setting USDT and BTC limits on Binance. +# balance_asset_limit: +# binance: +# BTC: 0.1 +# USDT: 1000 +balance_asset_limit: + ascend_ex: {} + ascend_ex_paper_trade: {} + binance: {} + binance_paper_trade: {} + binance_us: {} + bitfinex: {} + bitmart: {} + bitmex: {} + bitmex_testnet: {} + btc_markets: {} + bybit: {} + bybit_testnet: {} + coinbase_pro: {} + foxbit: {} + gate_io: {} + gate_io_paper_trade: {} + hitbtc: {} + huobi: {} + injective_v2: {} + kraken: {} + kucoin: {} + kucoin_paper_trade: {} + mexc: {} + mock_paper_exchange: {} + ndax: {} + ndax_testnet: {} + okx: {} + polkadex: {} + vertex: {} + vertex_testnet: {} + woo_x: {} + woo_x_testnet: {} + +# Fixed gas price (in Gwei) for Ethereum transactions +manual_gas_price: 50.0 + +# Gateway API Configurations +# default host to only use localhost +# Port need to match the final installation port for Gateway +gateway: + gateway_api_host: localhost + gateway_api_port: '15888' + gateway_use_ssl: true + +# Whether to enable aggregated order and trade data collection +anonymized_metrics_mode: + anonymized_metrics_interval_min: 15.0 + +# A source for rate oracle, currently binance, coin_gecko, coin_cap, dexalot, evedex_perpetual, decibel_perpetual, kucoin, gate_io, backpack, coinbase_advanced_trade, hyperliquid, hyperliquid_perpetual, architect_perpetual, derive, mexc +rate_oracle_source: + name: gate_io + +# A universal token which to display tokens values in, e.g. USD,EUR,BTC +global_token: + global_token_name: USDT + global_token_symbol: $ + usd_equivalent_tokens: + - USD + +# Percentage of API rate limits (on any exchange and any end point) allocated to this bot instance. +# Enter 50 to indicate 50%. E.g. if the API rate limit is 100 calls per second, and you allocate +# 50% to this setting, the bot will have a maximum (limit) of 50 calls per second +rate_limits_share_pct: 100.0 + +commands_timeout: + create_command_timeout: 10.0 + other_commands_timeout: 30.0 + +# Tabulate table format style (https://github.com/astanin/python-tabulate#table-format) +tables_format: psql + +paper_trade: + paper_trade_exchanges: + - binance + - kucoin + - ascend_ex + - gate_io + paper_trade_account_balance: + BTC: 1.0 + DAI: 1000.0 + ETH: 10.0 + ONE: 1000.0 + TUSD: 1000.0 + USDC: 1000.0 + USDQ: 1000.0 + USDT: 1000.0 + WETH: 10.0 + +color: + top_pane: '#000000' + bottom_pane: '#000000' + output_pane: '#262626' + input_pane: '#1C1C1C' + logs_pane: '#121212' + terminal_primary: '#5FFFD7' + primary_label: '#5FFFD7' + secondary_label: '#FFFFFF' + success_label: '#5FFFD7' + warning_label: '#FFFF00' + info_label: '#5FD7FF' + error_label: '#FF0000' + gold_label: '#FFD700' + silver_label: '#C0C0C0' + bronze_label: '#CD7F32' + +# The tick size is the frequency with which the clock notifies the time iterators by calling the +# c_tick() method, that means for example that if the tick size is 1, the logic of the strategy +# will run every second. +tick_size: 1.0 + +market_data_collection: + market_data_collection_enabled: false + market_data_collection_interval: 60 + market_data_collection_depth: 20 diff --git a/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/conf_fee_overrides.yml b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/conf_fee_overrides.yml new file mode 100644 index 00000000..8dc9d57e --- /dev/null +++ b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/conf_fee_overrides.yml @@ -0,0 +1,526 @@ +######################################## +### Fee overrides configurations ### +######################################## + +# For more detailed information: https://docs.hummingbot.io +template_version: 14 + +# Example of the fields that can be specified to override the `TradeFeeFactory` default settings. +# If the field is missing or the value is left blank, the default value will be used. +# The percentage values are specified as 0.1 for 0.1%. +# +# [exchange name]_percent_fee_token: +# [exchange name]_maker_percent_fee: +# [exchange name]_taker_percent_fee: +# [exchange name]_buy_percent_fee_deducted_from_returns: # if False, the buy fee is added to the order costs +# [exchange name]_maker_fixed_fees: # a list of lists of token-fee pairs (e.g. [["ETH", 1]]) +# [exchange name]_taker_fixed_fees: # a list of lists of token-fee pairs (e.g. [["ETH", 1]]) + +binance_percent_fee_token: # BNB +binance_maker_percent_fee: # 0.75 +binance_taker_percent_fee: # 0.75 +binance_buy_percent_fee_deducted_from_returns: # True + +# List of supported Exchanges for which the user's conf/conf_fee_override.yml +# will work. This file currently needs to be in sync with hummingbot list of +# supported exchanges +ascend_ex_buy_percent_fee_deducted_from_returns: +ascend_ex_maker_fixed_fees: +ascend_ex_maker_percent_fee: +ascend_ex_percent_fee_token: +ascend_ex_taker_fixed_fees: +ascend_ex_taker_percent_fee: +binance_maker_fixed_fees: +binance_perpetual_buy_percent_fee_deducted_from_returns: +binance_perpetual_maker_fixed_fees: +binance_perpetual_maker_percent_fee: +binance_perpetual_percent_fee_token: +binance_perpetual_taker_fixed_fees: +binance_perpetual_taker_percent_fee: +binance_perpetual_testnet_buy_percent_fee_deducted_from_returns: +binance_perpetual_testnet_maker_fixed_fees: +binance_perpetual_testnet_maker_percent_fee: +binance_perpetual_testnet_percent_fee_token: +binance_perpetual_testnet_taker_fixed_fees: +binance_perpetual_testnet_taker_percent_fee: +binance_taker_fixed_fees: +binance_us_buy_percent_fee_deducted_from_returns: +binance_us_maker_fixed_fees: +binance_us_maker_percent_fee: +binance_us_percent_fee_token: +binance_us_taker_fixed_fees: +binance_us_taker_percent_fee: +bitfinex_buy_percent_fee_deducted_from_returns: +bitfinex_maker_fixed_fees: +bitfinex_maker_percent_fee: +bitfinex_percent_fee_token: +bitfinex_taker_fixed_fees: +bitfinex_taker_percent_fee: +bitmart_buy_percent_fee_deducted_from_returns: +bitmart_maker_fixed_fees: +bitmart_maker_percent_fee: +bitmart_percent_fee_token: +bitmart_taker_fixed_fees: +bitmart_taker_percent_fee: +btc_markets_percent_fee_token: +btc_markets_maker_percent_fee: +btc_markets_taker_percent_fee: +btc_markets_buy_percent_fee_deducted_from_returns: +bybit_perpetual_buy_percent_fee_deducted_from_returns: +bybit_perpetual_maker_fixed_fees: +bybit_perpetual_maker_percent_fee: +bybit_perpetual_percent_fee_token: +bybit_perpetual_taker_fixed_fees: +bybit_perpetual_taker_percent_fee: +bybit_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bybit_perpetual_testnet_maker_fixed_fees: +bybit_perpetual_testnet_maker_percent_fee: +bybit_perpetual_testnet_percent_fee_token: +bybit_perpetual_testnet_taker_fixed_fees: +bybit_perpetual_testnet_taker_percent_fee: +coinbase_pro_buy_percent_fee_deducted_from_returns: +coinbase_pro_maker_fixed_fees: +coinbase_pro_maker_percent_fee: +coinbase_pro_percent_fee_token: +coinbase_pro_taker_fixed_fees: +coinbase_pro_taker_percent_fee: +dydx_perpetual_buy_percent_fee_deducted_from_returns: +dydx_perpetual_maker_fixed_fees: +dydx_perpetual_maker_percent_fee: +dydx_perpetual_percent_fee_token: +dydx_perpetual_taker_fixed_fees: +dydx_perpetual_taker_percent_fee: +gate_io_buy_percent_fee_deducted_from_returns: +gate_io_maker_fixed_fees: +gate_io_maker_percent_fee: +gate_io_percent_fee_token: +gate_io_taker_fixed_fees: +gate_io_taker_percent_fee: +hitbtc_buy_percent_fee_deducted_from_returns: +hitbtc_maker_fixed_fees: +hitbtc_maker_percent_fee: +hitbtc_percent_fee_token: +hitbtc_taker_fixed_fees: +hitbtc_taker_percent_fee: +huobi_buy_percent_fee_deducted_from_returns: +huobi_maker_fixed_fees: +huobi_maker_percent_fee: +huobi_percent_fee_token: +huobi_taker_fixed_fees: +huobi_taker_percent_fee: +kraken_buy_percent_fee_deducted_from_returns: +kraken_maker_fixed_fees: +kraken_maker_percent_fee: +kraken_percent_fee_token: +kraken_taker_fixed_fees: +kraken_taker_percent_fee: +kucoin_buy_percent_fee_deducted_from_returns: +kucoin_maker_fixed_fees: +kucoin_maker_percent_fee: +kucoin_percent_fee_token: +kucoin_taker_fixed_fees: +kucoin_taker_percent_fee: +mexc_buy_percent_fee_deducted_from_returns: +mexc_maker_fixed_fees: +mexc_maker_percent_fee: +mexc_percent_fee_token: +mexc_taker_fixed_fees: +mexc_taker_percent_fee: +ndax_buy_percent_fee_deducted_from_returns: +ndax_maker_fixed_fees: +ndax_maker_percent_fee: +ndax_percent_fee_token: +ndax_taker_fixed_fees: +ndax_taker_percent_fee: +ndax_testnet_buy_percent_fee_deducted_from_returns: +ndax_testnet_maker_fixed_fees: +ndax_testnet_maker_percent_fee: +ndax_testnet_percent_fee_token: +ndax_testnet_taker_fixed_fees: +ndax_testnet_taker_percent_fee: +okx_buy_percent_fee_deducted_from_returns: +okx_maker_fixed_fees: +okx_maker_percent_fee: +okx_percent_fee_token: +okx_taker_fixed_fees: +okx_taker_percent_fee: +phemex_perpetual_percent_fee_token: +phemex_perpetual_maker_percent_fee: +phemex_perpetual_taker_percent_fee: +phemex_perpetual_buy_percent_fee_deducted_from_returns: +phemex_perpetual_maker_fixed_fees: +phemex_perpetual_taker_fixed_fees: +phemex_perpetual_testnet_percent_fee_token: +phemex_perpetual_testnet_maker_percent_fee: +phemex_perpetual_testnet_taker_percent_fee: +phemex_perpetual_testnet_buy_percent_fee_deducted_from_returns: +phemex_perpetual_testnet_maker_fixed_fees: +phemex_perpetual_testnet_taker_fixed_fees: +injective_v2_perpetual_percent_fee_token: +injective_v2_perpetual_maker_percent_fee: +injective_v2_perpetual_taker_percent_fee: +injective_v2_perpetual_buy_percent_fee_deducted_from_returns: +injective_v2_perpetual_maker_fixed_fees: +injective_v2_perpetual_taker_fixed_fees: +bitget_perpetual_percent_fee_token: +bitget_perpetual_maker_percent_fee: +bitget_perpetual_taker_percent_fee: +bitget_perpetual_buy_percent_fee_deducted_from_returns: +bitget_perpetual_maker_fixed_fees: +bitget_perpetual_taker_fixed_fees: +bit_com_perpetual_percent_fee_token: +bit_com_perpetual_maker_percent_fee: +bit_com_perpetual_taker_percent_fee: +bit_com_perpetual_buy_percent_fee_deducted_from_returns: +bit_com_perpetual_maker_fixed_fees: +bit_com_perpetual_taker_fixed_fees: +bit_com_perpetual_testnet_percent_fee_token: +bit_com_perpetual_testnet_maker_percent_fee: +bit_com_perpetual_testnet_taker_percent_fee: +bit_com_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bit_com_perpetual_testnet_maker_fixed_fees: +bit_com_perpetual_testnet_taker_fixed_fees: +gate_io_perpetual_percent_fee_token: +gate_io_perpetual_maker_percent_fee: +gate_io_perpetual_taker_percent_fee: +gate_io_perpetual_buy_percent_fee_deducted_from_returns: +gate_io_perpetual_maker_fixed_fees: +gate_io_perpetual_taker_fixed_fees: +bitmex_perpetual_percent_fee_token: +bitmex_perpetual_maker_percent_fee: +bitmex_perpetual_taker_percent_fee: +bitmex_perpetual_buy_percent_fee_deducted_from_returns: +bitmex_perpetual_maker_fixed_fees: +bitmex_perpetual_taker_fixed_fees: +bitmex_perpetual_testnet_percent_fee_token: +bitmex_perpetual_testnet_maker_percent_fee: +bitmex_perpetual_testnet_taker_percent_fee: +bitmex_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bitmex_perpetual_testnet_maker_fixed_fees: +bitmex_perpetual_testnet_taker_fixed_fees: +kucoin_perpetual_percent_fee_token: +kucoin_perpetual_maker_percent_fee: +kucoin_perpetual_taker_percent_fee: +kucoin_perpetual_buy_percent_fee_deducted_from_returns: +kucoin_perpetual_maker_fixed_fees: +kucoin_perpetual_taker_fixed_fees: +bitmex_percent_fee_token: +bitmex_maker_percent_fee: +bitmex_taker_percent_fee: +bitmex_buy_percent_fee_deducted_from_returns: +bitmex_maker_fixed_fees: +bitmex_taker_fixed_fees: +bitmex_testnet_percent_fee_token: +bitmex_testnet_maker_percent_fee: +bitmex_testnet_taker_percent_fee: +bitmex_testnet_buy_percent_fee_deducted_from_returns: +bitmex_testnet_maker_fixed_fees: +bitmex_testnet_taker_fixed_fees: +btc_markets_maker_fixed_fees: +btc_markets_taker_fixed_fees: +woo_x_percent_fee_token: +woo_x_maker_percent_fee: +woo_x_taker_percent_fee: +woo_x_buy_percent_fee_deducted_from_returns: +woo_x_maker_fixed_fees: +woo_x_taker_fixed_fees: +woo_x_testnet_percent_fee_token: +woo_x_testnet_maker_percent_fee: +woo_x_testnet_taker_percent_fee: +woo_x_testnet_buy_percent_fee_deducted_from_returns: +woo_x_testnet_maker_fixed_fees: +woo_x_testnet_taker_fixed_fees: +polkadex_percent_fee_token: +polkadex_maker_percent_fee: +polkadex_taker_percent_fee: +polkadex_buy_percent_fee_deducted_from_returns: +polkadex_maker_fixed_fees: +polkadex_taker_fixed_fees: +bybit_percent_fee_token: +bybit_maker_percent_fee: +bybit_taker_percent_fee: +bybit_buy_percent_fee_deducted_from_returns: +bybit_maker_fixed_fees: +bybit_taker_fixed_fees: +bybit_testnet_percent_fee_token: +bybit_testnet_maker_percent_fee: +bybit_testnet_taker_percent_fee: +bybit_testnet_buy_percent_fee_deducted_from_returns: +bybit_testnet_maker_fixed_fees: +bybit_testnet_taker_fixed_fees: +injective_v2_percent_fee_token: +injective_v2_maker_percent_fee: +injective_v2_taker_percent_fee: +injective_v2_buy_percent_fee_deducted_from_returns: +injective_v2_maker_fixed_fees: +injective_v2_taker_fixed_fees: +vertex_percent_fee_token: +vertex_maker_percent_fee: +vertex_taker_percent_fee: +vertex_buy_percent_fee_deducted_from_returns: +vertex_maker_fixed_fees: +vertex_taker_fixed_fees: +vertex_testnet_percent_fee_token: +vertex_testnet_maker_percent_fee: +vertex_testnet_taker_percent_fee: +vertex_testnet_buy_percent_fee_deducted_from_returns: +vertex_testnet_maker_fixed_fees: +vertex_testnet_taker_fixed_fees: +mock_paper_exchange_percent_fee_token: +mock_paper_exchange_maker_percent_fee: +mock_paper_exchange_taker_percent_fee: +mock_paper_exchange_buy_percent_fee_deducted_from_returns: +mock_paper_exchange_maker_fixed_fees: +mock_paper_exchange_taker_fixed_fees: +kucoin_perpetual_testnet_percent_fee_token: +kucoin_perpetual_testnet_maker_percent_fee: +kucoin_perpetual_testnet_taker_percent_fee: +kucoin_perpetual_testnet_buy_percent_fee_deducted_from_returns: +kucoin_perpetual_testnet_maker_fixed_fees: +kucoin_perpetual_testnet_taker_fixed_fees: +foxbit_percent_fee_token: +foxbit_maker_percent_fee: +foxbit_taker_percent_fee: +foxbit_buy_percent_fee_deducted_from_returns: +foxbit_maker_fixed_fees: +foxbit_taker_fixed_fees: +hyperliquid_perpetual_percent_fee_token: +hyperliquid_perpetual_maker_percent_fee: +hyperliquid_perpetual_taker_percent_fee: +hyperliquid_perpetual_buy_percent_fee_deducted_from_returns: +hyperliquid_perpetual_maker_fixed_fees: +hyperliquid_perpetual_taker_fixed_fees: +hyperliquid_perpetual_testnet_percent_fee_token: +hyperliquid_perpetual_testnet_maker_percent_fee: +hyperliquid_perpetual_testnet_taker_percent_fee: +hyperliquid_perpetual_testnet_buy_percent_fee_deducted_from_returns: +hyperliquid_perpetual_testnet_maker_fixed_fees: +hyperliquid_perpetual_testnet_taker_fixed_fees: +bitmart_perpetual_percent_fee_token: +bitmart_perpetual_maker_percent_fee: +bitmart_perpetual_taker_percent_fee: +bitmart_perpetual_buy_percent_fee_deducted_from_returns: +bitmart_perpetual_maker_fixed_fees: +bitmart_perpetual_taker_fixed_fees: +architect_perpetual_percent_fee_token: +architect_perpetual_maker_percent_fee: +architect_perpetual_taker_percent_fee: +architect_perpetual_buy_percent_fee_deducted_from_returns: +architect_perpetual_maker_fixed_fees: +architect_perpetual_taker_fixed_fees: +architect_perpetual_sandbox_percent_fee_token: +architect_perpetual_sandbox_maker_percent_fee: +architect_perpetual_sandbox_taker_percent_fee: +architect_perpetual_sandbox_buy_percent_fee_deducted_from_returns: +architect_perpetual_sandbox_maker_fixed_fees: +architect_perpetual_sandbox_taker_fixed_fees: +pacifica_perpetual_percent_fee_token: +pacifica_perpetual_maker_percent_fee: +pacifica_perpetual_taker_percent_fee: +pacifica_perpetual_buy_percent_fee_deducted_from_returns: +pacifica_perpetual_maker_fixed_fees: +pacifica_perpetual_taker_fixed_fees: +pacifica_perpetual_testnet_percent_fee_token: +pacifica_perpetual_testnet_maker_percent_fee: +pacifica_perpetual_testnet_taker_percent_fee: +pacifica_perpetual_testnet_buy_percent_fee_deducted_from_returns: +pacifica_perpetual_testnet_maker_fixed_fees: +pacifica_perpetual_testnet_taker_fixed_fees: +lighter_perpetual_percent_fee_token: +lighter_perpetual_maker_percent_fee: +lighter_perpetual_taker_percent_fee: +lighter_perpetual_buy_percent_fee_deducted_from_returns: +lighter_perpetual_maker_fixed_fees: +lighter_perpetual_taker_fixed_fees: +lighter_perpetual_testnet_percent_fee_token: +lighter_perpetual_testnet_maker_percent_fee: +lighter_perpetual_testnet_taker_percent_fee: +lighter_perpetual_testnet_buy_percent_fee_deducted_from_returns: +lighter_perpetual_testnet_maker_fixed_fees: +lighter_perpetual_testnet_taker_fixed_fees: +derive_perpetual_percent_fee_token: +derive_perpetual_maker_percent_fee: +derive_perpetual_taker_percent_fee: +derive_perpetual_buy_percent_fee_deducted_from_returns: +derive_perpetual_maker_fixed_fees: +derive_perpetual_taker_fixed_fees: +derive_perpetual_testnet_percent_fee_token: +derive_perpetual_testnet_maker_percent_fee: +derive_perpetual_testnet_taker_percent_fee: +derive_perpetual_testnet_buy_percent_fee_deducted_from_returns: +derive_perpetual_testnet_maker_fixed_fees: +derive_perpetual_testnet_taker_fixed_fees: +aevo_perpetual_percent_fee_token: +aevo_perpetual_maker_percent_fee: +aevo_perpetual_taker_percent_fee: +aevo_perpetual_buy_percent_fee_deducted_from_returns: +aevo_perpetual_maker_fixed_fees: +aevo_perpetual_taker_fixed_fees: +aevo_perpetual_testnet_percent_fee_token: +aevo_perpetual_testnet_maker_percent_fee: +aevo_perpetual_testnet_taker_percent_fee: +aevo_perpetual_testnet_buy_percent_fee_deducted_from_returns: +aevo_perpetual_testnet_maker_fixed_fees: +aevo_perpetual_testnet_taker_fixed_fees: +dydx_v4_perpetual_percent_fee_token: +dydx_v4_perpetual_maker_percent_fee: +dydx_v4_perpetual_taker_percent_fee: +dydx_v4_perpetual_buy_percent_fee_deducted_from_returns: +dydx_v4_perpetual_maker_fixed_fees: +dydx_v4_perpetual_taker_fixed_fees: +okx_perpetual_percent_fee_token: +okx_perpetual_maker_percent_fee: +okx_perpetual_taker_percent_fee: +okx_perpetual_buy_percent_fee_deducted_from_returns: +okx_perpetual_maker_fixed_fees: +okx_perpetual_taker_fixed_fees: +grvt_perpetual_percent_fee_token: +grvt_perpetual_maker_percent_fee: +grvt_perpetual_taker_percent_fee: +grvt_perpetual_buy_percent_fee_deducted_from_returns: +grvt_perpetual_maker_fixed_fees: +grvt_perpetual_taker_fixed_fees: +grvt_perpetual_testnet_percent_fee_token: +grvt_perpetual_testnet_maker_percent_fee: +grvt_perpetual_testnet_taker_percent_fee: +grvt_perpetual_testnet_buy_percent_fee_deducted_from_returns: +grvt_perpetual_testnet_maker_fixed_fees: +grvt_perpetual_testnet_taker_fixed_fees: +evedex_perpetual_percent_fee_token: +evedex_perpetual_maker_percent_fee: +evedex_perpetual_taker_percent_fee: +evedex_perpetual_buy_percent_fee_deducted_from_returns: +evedex_perpetual_maker_fixed_fees: +evedex_perpetual_taker_fixed_fees: +backpack_perpetual_percent_fee_token: +backpack_perpetual_maker_percent_fee: +backpack_perpetual_taker_percent_fee: +backpack_perpetual_buy_percent_fee_deducted_from_returns: +backpack_perpetual_maker_fixed_fees: +backpack_perpetual_taker_fixed_fees: +decibel_perpetual_percent_fee_token: +decibel_perpetual_maker_percent_fee: +decibel_perpetual_taker_percent_fee: +decibel_perpetual_buy_percent_fee_deducted_from_returns: +decibel_perpetual_maker_fixed_fees: +decibel_perpetual_taker_fixed_fees: +decibel_perpetual_testnet_percent_fee_token: +decibel_perpetual_testnet_maker_percent_fee: +decibel_perpetual_testnet_taker_percent_fee: +decibel_perpetual_testnet_buy_percent_fee_deducted_from_returns: +decibel_perpetual_testnet_maker_fixed_fees: +decibel_perpetual_testnet_taker_fixed_fees: +derive_percent_fee_token: +derive_maker_percent_fee: +derive_taker_percent_fee: +derive_buy_percent_fee_deducted_from_returns: +derive_maker_fixed_fees: +derive_taker_fixed_fees: +derive_testnet_percent_fee_token: +derive_testnet_maker_percent_fee: +derive_testnet_taker_percent_fee: +derive_testnet_buy_percent_fee_deducted_from_returns: +derive_testnet_maker_fixed_fees: +derive_testnet_taker_fixed_fees: +bitrue_percent_fee_token: +bitrue_maker_percent_fee: +bitrue_taker_percent_fee: +bitrue_buy_percent_fee_deducted_from_returns: +bitrue_maker_fixed_fees: +bitrue_taker_fixed_fees: +lambdaplex_percent_fee_token: +lambdaplex_maker_percent_fee: +lambdaplex_taker_percent_fee: +lambdaplex_buy_percent_fee_deducted_from_returns: +lambdaplex_maker_fixed_fees: +lambdaplex_taker_fixed_fees: +dexalot_percent_fee_token: +dexalot_maker_percent_fee: +dexalot_taker_percent_fee: +dexalot_buy_percent_fee_deducted_from_returns: +dexalot_maker_fixed_fees: +dexalot_taker_fixed_fees: +dexalot_testnet_percent_fee_token: +dexalot_testnet_maker_percent_fee: +dexalot_testnet_taker_percent_fee: +dexalot_testnet_buy_percent_fee_deducted_from_returns: +dexalot_testnet_maker_fixed_fees: +dexalot_testnet_taker_fixed_fees: +bing_x_percent_fee_token: +bing_x_maker_percent_fee: +bing_x_taker_percent_fee: +bing_x_buy_percent_fee_deducted_from_returns: +bing_x_maker_fixed_fees: +bing_x_taker_fixed_fees: +kucoin_hft_percent_fee_token: +kucoin_hft_maker_percent_fee: +kucoin_hft_taker_percent_fee: +kucoin_hft_buy_percent_fee_deducted_from_returns: +kucoin_hft_maker_fixed_fees: +kucoin_hft_taker_fixed_fees: +bitstamp_percent_fee_token: +bitstamp_maker_percent_fee: +bitstamp_taker_percent_fee: +bitstamp_buy_percent_fee_deducted_from_returns: +bitstamp_maker_fixed_fees: +bitstamp_taker_fixed_fees: +gemini_percent_fee_token: +gemini_maker_percent_fee: +gemini_taker_percent_fee: +gemini_buy_percent_fee_deducted_from_returns: +gemini_maker_fixed_fees: +gemini_taker_fixed_fees: +backpack_percent_fee_token: +backpack_maker_percent_fee: +backpack_taker_percent_fee: +backpack_buy_percent_fee_deducted_from_returns: +backpack_maker_fixed_fees: +backpack_taker_fixed_fees: +htx_percent_fee_token: +htx_maker_percent_fee: +htx_taker_percent_fee: +htx_buy_percent_fee_deducted_from_returns: +htx_maker_fixed_fees: +htx_taker_fixed_fees: +lighter_percent_fee_token: +lighter_maker_percent_fee: +lighter_taker_percent_fee: +lighter_buy_percent_fee_deducted_from_returns: +lighter_maker_fixed_fees: +lighter_taker_fixed_fees: +lighter_testnet_percent_fee_token: +lighter_testnet_maker_percent_fee: +lighter_testnet_taker_percent_fee: +lighter_testnet_buy_percent_fee_deducted_from_returns: +lighter_testnet_maker_fixed_fees: +lighter_testnet_taker_fixed_fees: +hyperliquid_percent_fee_token: +hyperliquid_maker_percent_fee: +hyperliquid_taker_percent_fee: +hyperliquid_buy_percent_fee_deducted_from_returns: +hyperliquid_maker_fixed_fees: +hyperliquid_taker_fixed_fees: +hyperliquid_testnet_percent_fee_token: +hyperliquid_testnet_maker_percent_fee: +hyperliquid_testnet_taker_percent_fee: +hyperliquid_testnet_buy_percent_fee_deducted_from_returns: +hyperliquid_testnet_maker_fixed_fees: +hyperliquid_testnet_taker_fixed_fees: +coinbase_advanced_trade_percent_fee_token: +coinbase_advanced_trade_maker_percent_fee: +coinbase_advanced_trade_taker_percent_fee: +coinbase_advanced_trade_buy_percent_fee_deducted_from_returns: +coinbase_advanced_trade_maker_fixed_fees: +coinbase_advanced_trade_taker_fixed_fees: +xrpl_percent_fee_token: +xrpl_maker_percent_fee: +xrpl_taker_percent_fee: +xrpl_buy_percent_fee_deducted_from_returns: +xrpl_maker_fixed_fees: +xrpl_taker_fixed_fees: +bitget_percent_fee_token: +bitget_maker_percent_fee: +bitget_taker_percent_fee: +bitget_buy_percent_fee_deducted_from_returns: +bitget_maker_fixed_fees: +bitget_taker_fixed_fees: diff --git a/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/.dockerignore b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/.dockerignore new file mode 100644 index 00000000..ad30bfec --- /dev/null +++ b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/.dockerignore @@ -0,0 +1 @@ +*.yml \ No newline at end of file diff --git a/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/.gitignore b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/xrpl.yml b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/xrpl.yml new file mode 100644 index 00000000..69ba6c49 --- /dev/null +++ b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/connectors/xrpl.yml @@ -0,0 +1,20 @@ +####################### +### xrpl config ### +####################### + +connector: xrpl + +xrpl_secret_key: 7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20226638643562313934656635386162626362646132313563636232626235636438227d2c202263697068657274657874223a20223763373032376666636337643732366464616462346439613365366431333764303639333238333934633739363330613166373135633138663762366539222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20223364353731366534366339393762613430666163336534623935366461643761227d2c20226d6163223a202266623862393637326439356234323361393930366565343336386633626235623434666535383739383061636537653730383538626564343439376261306466227d2c202276657273696f6e223a20332c2022616c696173223a2022227d + +wss_node_urls: +- wss://alien-magical-sky.xrp-mainnet.quiknode.pro/cdeda141d5c67711f061b73b1c4bf70eb163c6a8/ + +custom_markets: + SOLO-XRP: + base: SOLO + quote: XRP + base_issuer: rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz + quote_issuer: '' + trading_pair_symbol: null + +max_request_per_minute: 60 diff --git a/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/controllers/001_pmm_xrpl_USDC-XRP.yml b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/controllers/001_pmm_xrpl_USDC-XRP.yml new file mode 100644 index 00000000..368f76c5 --- /dev/null +++ b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/controllers/001_pmm_xrpl_USDC-XRP.yml @@ -0,0 +1,37 @@ +buy_amounts_pct: 1,1 +buy_cooldown_time: 60 +buy_position_effectivization_time: 300 +buy_spreads: 0.003,0.007 +connector_name: xrpl +controller_name: pmm_mister +controller_type: generic +executor_refresh_time: 60 +global_pnl_reference: position +global_sl_activation_from: target_base +global_sl_enabled: true +global_stop_loss: 0.04 +global_take_profit: 0.03 +global_tp_activation_from: min_base +global_tp_enabled: true +id: 001_pmm_xrpl_USDC-XRP +leverage: 1 +max_active_executors_by_level: 2 +max_base_pct: 0.7 +min_base_pct: 0.4 +min_skew: 2 +open_order_type: 3 +portfolio_allocation: 0.1 +position_profit_protection: true +price_distance_tolerance: 0.001 +refresh_tolerance: 0.001 +sell_amounts_pct: 1,1 +sell_cooldown_time: 180 +sell_position_effectivization_time: 300 +sell_spreads: 0.005,0.010 +take_profit: 0.0015 +take_profit_order_type: 3 +target_base_pct: 0.55 +tick_mode: false +tolerance_scaling: 1.3 +total_amount_quote: 50 +trading_pair: USDC-XRP diff --git a/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/hummingbot_logs.yml b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/hummingbot_logs.yml new file mode 100755 index 00000000..8e65271c --- /dev/null +++ b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/hummingbot_logs.yml @@ -0,0 +1,83 @@ +--- +version: 1 +template_version: 12 + +formatters: + simple: + format: "%(asctime)s - %(process)d - %(name)s - %(levelname)s - %(message)s" + +handlers: + console: + class: hummingbot.logger.cli_handler.CLIHandler + level: DEBUG + formatter: simple + stream: ext://sys.stdout + console_warning: + class: hummingbot.logger.cli_handler.CLIHandler + level: WARNING + formatter: simple + stream: ext://sys.stdout + console_info: + class: hummingbot.logger.cli_handler.CLIHandler + level: INFO + formatter: simple + stream: ext://sys.stdout + file_handler: + class: logging.handlers.TimedRotatingFileHandler + level: DEBUG + formatter: simple + filename: $PROJECT_DIR/logs/logs_$STRATEGY_FILE_PATH.log + encoding: utf8 + when: "D" + interval: 1 + backupCount: 7 + "null": + class: logging.NullHandler + level: DEBUG + +loggers: + hummingbot.core.utils.eth_gas_station_lookup: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.logger.log_server_client: + level: WARNING + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.logger.reporting_proxy_handler: + level: WARNING + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.strategy: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.connector: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.client: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.core.event.event_reporter: + level: EVENT_LOG + propagate: false + handlers: [file_handler] + mqtt: false + conf: + level: NETWORK + handlers: ["null"] + propagate: false + mqtt: false + +root: + level: INFO + handlers: [console, file_handler] + mqtt: true diff --git a/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/scripts/pmm_USDC-XRP_20260807-20260807-145233.yml b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/scripts/pmm_USDC-XRP_20260807-20260807-145233.yml new file mode 100644 index 00000000..67c21f58 --- /dev/null +++ b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/conf/scripts/pmm_USDC-XRP_20260807-20260807-145233.yml @@ -0,0 +1,3 @@ +controllers_config: +- 001_pmm_xrpl_USDC-XRP.yml +script_file_name: v2_with_controllers.py diff --git a/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/data/pmm_USDC-XRP_20260807-20260807-145233.sqlite b/bots/archived/pmm_USDC-XRP_20260807-20260807-145233/data/pmm_USDC-XRP_20260807-20260807-145233.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..f4a2c5bc003bdddad57a94f0b8b425bc0d6f3c76 GIT binary patch literal 245760 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 literal 0 HcmV?d00001 diff --git a/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/.password_verification b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/.password_verification new file mode 100644 index 00000000..d1a274b6 --- /dev/null +++ b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/.password_verification @@ -0,0 +1 @@ +7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20226463613937613132666365383432623664323437613830393966313638373662227d2c202263697068657274657874223a20223537393331373032636666343037396439623430222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20226536376664366338373033386336613439653066373938316366656138656539227d2c20226d6163223a202233366134323163646334303464393433343264656166613037623235373866363734383930653961393765303037393231333132326532333464303666323034227d2c202276657273696f6e223a20332c2022616c696173223a2022227d \ No newline at end of file diff --git a/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/conf_client.yml b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/conf_client.yml new file mode 100644 index 00000000..8d905b52 --- /dev/null +++ b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/conf_client.yml @@ -0,0 +1,179 @@ +#################################### +### client_config_map config ### +#################################### + +instance_id: sndk-gate-hft-mm-20260818-030708 + +# Fetch trading pairs from all exchanges if True, otherwise fetch only from connected exchanges. +fetch_pairs_from_all_exchanges: false + +log_level: INFO + +debug_console: false + +strategy_report_interval: 900.0 + +logger_override_whitelist: +- hummingbot.strategy.arbitrage +- hummingbot.strategy.cross_exchange_market_making +- conf + +log_file_path: /home/hummingbot/logs + +kill_switch_mode: {} + +# What to auto-fill in the prompt after each import command (start/config) +autofill_import: disabled + +# MQTT Bridge configuration. +mqtt_bridge: + mqtt_host: localhost + mqtt_port: 1883 + mqtt_username: '' + mqtt_password: '' + mqtt_namespace: hbot + mqtt_ssl: false + mqtt_logger: true + mqtt_notifier: true + mqtt_commands: true + mqtt_events: true + mqtt_external_events: true + mqtt_autostart: true + +# Error log sharing +send_error_logs: true + +# Advanced database options, currently supports SQLAlchemy's included dialects +# Reference: https://docs.sqlalchemy.org/en/13/dialects/ +# To use an instance of SQLite DB the required configuration is +# db_engine: sqlite +# To use a DBMS the required configuration is +# db_host: 127.0.0.1 +# db_port: 3306 +# db_username: username +# db_password: password +# db_name: dbname +db_mode: + db_engine: sqlite + +# Balance Limit Configurations +# e.g. Setting USDT and BTC limits on Binance. +# balance_asset_limit: +# binance: +# BTC: 0.1 +# USDT: 1000 +balance_asset_limit: + ascend_ex: {} + ascend_ex_paper_trade: {} + binance: {} + binance_paper_trade: {} + binance_us: {} + bitfinex: {} + bitmart: {} + bitmex: {} + bitmex_testnet: {} + btc_markets: {} + bybit: {} + bybit_testnet: {} + coinbase_pro: {} + foxbit: {} + gate_io: {} + gate_io_paper_trade: {} + hitbtc: {} + huobi: {} + injective_v2: {} + kraken: {} + kucoin: {} + kucoin_paper_trade: {} + mexc: {} + mock_paper_exchange: {} + ndax: {} + ndax_testnet: {} + okx: {} + polkadex: {} + vertex: {} + vertex_testnet: {} + woo_x: {} + woo_x_testnet: {} + +# Fixed gas price (in Gwei) for Ethereum transactions +manual_gas_price: 50.0 + +# Gateway API Configurations +# default host to only use localhost +# Port need to match the final installation port for Gateway +gateway: + gateway_api_host: localhost + gateway_api_port: '15888' + gateway_use_ssl: true + +# Whether to enable aggregated order and trade data collection +anonymized_metrics_mode: + anonymized_metrics_interval_min: 15.0 + +# A source for rate oracle, currently binance, coin_gecko, coin_cap, dexalot, evedex_perpetual, decibel_perpetual, kucoin, gate_io, backpack, coinbase_advanced_trade, hyperliquid, hyperliquid_perpetual, architect_perpetual, derive, mexc +rate_oracle_source: + name: gate_io + +# A universal token which to display tokens values in, e.g. USD,EUR,BTC +global_token: + global_token_name: USDT + global_token_symbol: $ + usd_equivalent_tokens: + - USD + +# Percentage of API rate limits (on any exchange and any end point) allocated to this bot instance. +# Enter 50 to indicate 50%. E.g. if the API rate limit is 100 calls per second, and you allocate +# 50% to this setting, the bot will have a maximum (limit) of 50 calls per second +rate_limits_share_pct: 100.0 + +commands_timeout: + create_command_timeout: 10.0 + other_commands_timeout: 30.0 + +# Tabulate table format style (https://github.com/astanin/python-tabulate#table-format) +tables_format: psql + +paper_trade: + paper_trade_exchanges: + - binance + - kucoin + - ascend_ex + - gate_io + paper_trade_account_balance: + BTC: 1.0 + DAI: 1000.0 + ETH: 10.0 + ONE: 1000.0 + TUSD: 1000.0 + USDC: 1000.0 + USDQ: 1000.0 + USDT: 1000.0 + WETH: 10.0 + +color: + top_pane: '#000000' + bottom_pane: '#000000' + output_pane: '#262626' + input_pane: '#1C1C1C' + logs_pane: '#121212' + terminal_primary: '#5FFFD7' + primary_label: '#5FFFD7' + secondary_label: '#FFFFFF' + success_label: '#5FFFD7' + warning_label: '#FFFF00' + info_label: '#5FD7FF' + error_label: '#FF0000' + gold_label: '#FFD700' + silver_label: '#C0C0C0' + bronze_label: '#CD7F32' + +# The tick size is the frequency with which the clock notifies the time iterators by calling the +# c_tick() method, that means for example that if the tick size is 1, the logic of the strategy +# will run every second. +tick_size: 1.0 + +market_data_collection: + market_data_collection_enabled: false + market_data_collection_interval: 60 + market_data_collection_depth: 20 diff --git a/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/conf_fee_overrides.yml b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/conf_fee_overrides.yml new file mode 100644 index 00000000..8dc9d57e --- /dev/null +++ b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/conf_fee_overrides.yml @@ -0,0 +1,526 @@ +######################################## +### Fee overrides configurations ### +######################################## + +# For more detailed information: https://docs.hummingbot.io +template_version: 14 + +# Example of the fields that can be specified to override the `TradeFeeFactory` default settings. +# If the field is missing or the value is left blank, the default value will be used. +# The percentage values are specified as 0.1 for 0.1%. +# +# [exchange name]_percent_fee_token: +# [exchange name]_maker_percent_fee: +# [exchange name]_taker_percent_fee: +# [exchange name]_buy_percent_fee_deducted_from_returns: # if False, the buy fee is added to the order costs +# [exchange name]_maker_fixed_fees: # a list of lists of token-fee pairs (e.g. [["ETH", 1]]) +# [exchange name]_taker_fixed_fees: # a list of lists of token-fee pairs (e.g. [["ETH", 1]]) + +binance_percent_fee_token: # BNB +binance_maker_percent_fee: # 0.75 +binance_taker_percent_fee: # 0.75 +binance_buy_percent_fee_deducted_from_returns: # True + +# List of supported Exchanges for which the user's conf/conf_fee_override.yml +# will work. This file currently needs to be in sync with hummingbot list of +# supported exchanges +ascend_ex_buy_percent_fee_deducted_from_returns: +ascend_ex_maker_fixed_fees: +ascend_ex_maker_percent_fee: +ascend_ex_percent_fee_token: +ascend_ex_taker_fixed_fees: +ascend_ex_taker_percent_fee: +binance_maker_fixed_fees: +binance_perpetual_buy_percent_fee_deducted_from_returns: +binance_perpetual_maker_fixed_fees: +binance_perpetual_maker_percent_fee: +binance_perpetual_percent_fee_token: +binance_perpetual_taker_fixed_fees: +binance_perpetual_taker_percent_fee: +binance_perpetual_testnet_buy_percent_fee_deducted_from_returns: +binance_perpetual_testnet_maker_fixed_fees: +binance_perpetual_testnet_maker_percent_fee: +binance_perpetual_testnet_percent_fee_token: +binance_perpetual_testnet_taker_fixed_fees: +binance_perpetual_testnet_taker_percent_fee: +binance_taker_fixed_fees: +binance_us_buy_percent_fee_deducted_from_returns: +binance_us_maker_fixed_fees: +binance_us_maker_percent_fee: +binance_us_percent_fee_token: +binance_us_taker_fixed_fees: +binance_us_taker_percent_fee: +bitfinex_buy_percent_fee_deducted_from_returns: +bitfinex_maker_fixed_fees: +bitfinex_maker_percent_fee: +bitfinex_percent_fee_token: +bitfinex_taker_fixed_fees: +bitfinex_taker_percent_fee: +bitmart_buy_percent_fee_deducted_from_returns: +bitmart_maker_fixed_fees: +bitmart_maker_percent_fee: +bitmart_percent_fee_token: +bitmart_taker_fixed_fees: +bitmart_taker_percent_fee: +btc_markets_percent_fee_token: +btc_markets_maker_percent_fee: +btc_markets_taker_percent_fee: +btc_markets_buy_percent_fee_deducted_from_returns: +bybit_perpetual_buy_percent_fee_deducted_from_returns: +bybit_perpetual_maker_fixed_fees: +bybit_perpetual_maker_percent_fee: +bybit_perpetual_percent_fee_token: +bybit_perpetual_taker_fixed_fees: +bybit_perpetual_taker_percent_fee: +bybit_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bybit_perpetual_testnet_maker_fixed_fees: +bybit_perpetual_testnet_maker_percent_fee: +bybit_perpetual_testnet_percent_fee_token: +bybit_perpetual_testnet_taker_fixed_fees: +bybit_perpetual_testnet_taker_percent_fee: +coinbase_pro_buy_percent_fee_deducted_from_returns: +coinbase_pro_maker_fixed_fees: +coinbase_pro_maker_percent_fee: +coinbase_pro_percent_fee_token: +coinbase_pro_taker_fixed_fees: +coinbase_pro_taker_percent_fee: +dydx_perpetual_buy_percent_fee_deducted_from_returns: +dydx_perpetual_maker_fixed_fees: +dydx_perpetual_maker_percent_fee: +dydx_perpetual_percent_fee_token: +dydx_perpetual_taker_fixed_fees: +dydx_perpetual_taker_percent_fee: +gate_io_buy_percent_fee_deducted_from_returns: +gate_io_maker_fixed_fees: +gate_io_maker_percent_fee: +gate_io_percent_fee_token: +gate_io_taker_fixed_fees: +gate_io_taker_percent_fee: +hitbtc_buy_percent_fee_deducted_from_returns: +hitbtc_maker_fixed_fees: +hitbtc_maker_percent_fee: +hitbtc_percent_fee_token: +hitbtc_taker_fixed_fees: +hitbtc_taker_percent_fee: +huobi_buy_percent_fee_deducted_from_returns: +huobi_maker_fixed_fees: +huobi_maker_percent_fee: +huobi_percent_fee_token: +huobi_taker_fixed_fees: +huobi_taker_percent_fee: +kraken_buy_percent_fee_deducted_from_returns: +kraken_maker_fixed_fees: +kraken_maker_percent_fee: +kraken_percent_fee_token: +kraken_taker_fixed_fees: +kraken_taker_percent_fee: +kucoin_buy_percent_fee_deducted_from_returns: +kucoin_maker_fixed_fees: +kucoin_maker_percent_fee: +kucoin_percent_fee_token: +kucoin_taker_fixed_fees: +kucoin_taker_percent_fee: +mexc_buy_percent_fee_deducted_from_returns: +mexc_maker_fixed_fees: +mexc_maker_percent_fee: +mexc_percent_fee_token: +mexc_taker_fixed_fees: +mexc_taker_percent_fee: +ndax_buy_percent_fee_deducted_from_returns: +ndax_maker_fixed_fees: +ndax_maker_percent_fee: +ndax_percent_fee_token: +ndax_taker_fixed_fees: +ndax_taker_percent_fee: +ndax_testnet_buy_percent_fee_deducted_from_returns: +ndax_testnet_maker_fixed_fees: +ndax_testnet_maker_percent_fee: +ndax_testnet_percent_fee_token: +ndax_testnet_taker_fixed_fees: +ndax_testnet_taker_percent_fee: +okx_buy_percent_fee_deducted_from_returns: +okx_maker_fixed_fees: +okx_maker_percent_fee: +okx_percent_fee_token: +okx_taker_fixed_fees: +okx_taker_percent_fee: +phemex_perpetual_percent_fee_token: +phemex_perpetual_maker_percent_fee: +phemex_perpetual_taker_percent_fee: +phemex_perpetual_buy_percent_fee_deducted_from_returns: +phemex_perpetual_maker_fixed_fees: +phemex_perpetual_taker_fixed_fees: +phemex_perpetual_testnet_percent_fee_token: +phemex_perpetual_testnet_maker_percent_fee: +phemex_perpetual_testnet_taker_percent_fee: +phemex_perpetual_testnet_buy_percent_fee_deducted_from_returns: +phemex_perpetual_testnet_maker_fixed_fees: +phemex_perpetual_testnet_taker_fixed_fees: +injective_v2_perpetual_percent_fee_token: +injective_v2_perpetual_maker_percent_fee: +injective_v2_perpetual_taker_percent_fee: +injective_v2_perpetual_buy_percent_fee_deducted_from_returns: +injective_v2_perpetual_maker_fixed_fees: +injective_v2_perpetual_taker_fixed_fees: +bitget_perpetual_percent_fee_token: +bitget_perpetual_maker_percent_fee: +bitget_perpetual_taker_percent_fee: +bitget_perpetual_buy_percent_fee_deducted_from_returns: +bitget_perpetual_maker_fixed_fees: +bitget_perpetual_taker_fixed_fees: +bit_com_perpetual_percent_fee_token: +bit_com_perpetual_maker_percent_fee: +bit_com_perpetual_taker_percent_fee: +bit_com_perpetual_buy_percent_fee_deducted_from_returns: +bit_com_perpetual_maker_fixed_fees: +bit_com_perpetual_taker_fixed_fees: +bit_com_perpetual_testnet_percent_fee_token: +bit_com_perpetual_testnet_maker_percent_fee: +bit_com_perpetual_testnet_taker_percent_fee: +bit_com_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bit_com_perpetual_testnet_maker_fixed_fees: +bit_com_perpetual_testnet_taker_fixed_fees: +gate_io_perpetual_percent_fee_token: +gate_io_perpetual_maker_percent_fee: +gate_io_perpetual_taker_percent_fee: +gate_io_perpetual_buy_percent_fee_deducted_from_returns: +gate_io_perpetual_maker_fixed_fees: +gate_io_perpetual_taker_fixed_fees: +bitmex_perpetual_percent_fee_token: +bitmex_perpetual_maker_percent_fee: +bitmex_perpetual_taker_percent_fee: +bitmex_perpetual_buy_percent_fee_deducted_from_returns: +bitmex_perpetual_maker_fixed_fees: +bitmex_perpetual_taker_fixed_fees: +bitmex_perpetual_testnet_percent_fee_token: +bitmex_perpetual_testnet_maker_percent_fee: +bitmex_perpetual_testnet_taker_percent_fee: +bitmex_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bitmex_perpetual_testnet_maker_fixed_fees: +bitmex_perpetual_testnet_taker_fixed_fees: +kucoin_perpetual_percent_fee_token: +kucoin_perpetual_maker_percent_fee: +kucoin_perpetual_taker_percent_fee: +kucoin_perpetual_buy_percent_fee_deducted_from_returns: +kucoin_perpetual_maker_fixed_fees: +kucoin_perpetual_taker_fixed_fees: +bitmex_percent_fee_token: +bitmex_maker_percent_fee: +bitmex_taker_percent_fee: +bitmex_buy_percent_fee_deducted_from_returns: +bitmex_maker_fixed_fees: +bitmex_taker_fixed_fees: +bitmex_testnet_percent_fee_token: +bitmex_testnet_maker_percent_fee: +bitmex_testnet_taker_percent_fee: +bitmex_testnet_buy_percent_fee_deducted_from_returns: +bitmex_testnet_maker_fixed_fees: +bitmex_testnet_taker_fixed_fees: +btc_markets_maker_fixed_fees: +btc_markets_taker_fixed_fees: +woo_x_percent_fee_token: +woo_x_maker_percent_fee: +woo_x_taker_percent_fee: +woo_x_buy_percent_fee_deducted_from_returns: +woo_x_maker_fixed_fees: +woo_x_taker_fixed_fees: +woo_x_testnet_percent_fee_token: +woo_x_testnet_maker_percent_fee: +woo_x_testnet_taker_percent_fee: +woo_x_testnet_buy_percent_fee_deducted_from_returns: +woo_x_testnet_maker_fixed_fees: +woo_x_testnet_taker_fixed_fees: +polkadex_percent_fee_token: +polkadex_maker_percent_fee: +polkadex_taker_percent_fee: +polkadex_buy_percent_fee_deducted_from_returns: +polkadex_maker_fixed_fees: +polkadex_taker_fixed_fees: +bybit_percent_fee_token: +bybit_maker_percent_fee: +bybit_taker_percent_fee: +bybit_buy_percent_fee_deducted_from_returns: +bybit_maker_fixed_fees: +bybit_taker_fixed_fees: +bybit_testnet_percent_fee_token: +bybit_testnet_maker_percent_fee: +bybit_testnet_taker_percent_fee: +bybit_testnet_buy_percent_fee_deducted_from_returns: +bybit_testnet_maker_fixed_fees: +bybit_testnet_taker_fixed_fees: +injective_v2_percent_fee_token: +injective_v2_maker_percent_fee: +injective_v2_taker_percent_fee: +injective_v2_buy_percent_fee_deducted_from_returns: +injective_v2_maker_fixed_fees: +injective_v2_taker_fixed_fees: +vertex_percent_fee_token: +vertex_maker_percent_fee: +vertex_taker_percent_fee: +vertex_buy_percent_fee_deducted_from_returns: +vertex_maker_fixed_fees: +vertex_taker_fixed_fees: +vertex_testnet_percent_fee_token: +vertex_testnet_maker_percent_fee: +vertex_testnet_taker_percent_fee: +vertex_testnet_buy_percent_fee_deducted_from_returns: +vertex_testnet_maker_fixed_fees: +vertex_testnet_taker_fixed_fees: +mock_paper_exchange_percent_fee_token: +mock_paper_exchange_maker_percent_fee: +mock_paper_exchange_taker_percent_fee: +mock_paper_exchange_buy_percent_fee_deducted_from_returns: +mock_paper_exchange_maker_fixed_fees: +mock_paper_exchange_taker_fixed_fees: +kucoin_perpetual_testnet_percent_fee_token: +kucoin_perpetual_testnet_maker_percent_fee: +kucoin_perpetual_testnet_taker_percent_fee: +kucoin_perpetual_testnet_buy_percent_fee_deducted_from_returns: +kucoin_perpetual_testnet_maker_fixed_fees: +kucoin_perpetual_testnet_taker_fixed_fees: +foxbit_percent_fee_token: +foxbit_maker_percent_fee: +foxbit_taker_percent_fee: +foxbit_buy_percent_fee_deducted_from_returns: +foxbit_maker_fixed_fees: +foxbit_taker_fixed_fees: +hyperliquid_perpetual_percent_fee_token: +hyperliquid_perpetual_maker_percent_fee: +hyperliquid_perpetual_taker_percent_fee: +hyperliquid_perpetual_buy_percent_fee_deducted_from_returns: +hyperliquid_perpetual_maker_fixed_fees: +hyperliquid_perpetual_taker_fixed_fees: +hyperliquid_perpetual_testnet_percent_fee_token: +hyperliquid_perpetual_testnet_maker_percent_fee: +hyperliquid_perpetual_testnet_taker_percent_fee: +hyperliquid_perpetual_testnet_buy_percent_fee_deducted_from_returns: +hyperliquid_perpetual_testnet_maker_fixed_fees: +hyperliquid_perpetual_testnet_taker_fixed_fees: +bitmart_perpetual_percent_fee_token: +bitmart_perpetual_maker_percent_fee: +bitmart_perpetual_taker_percent_fee: +bitmart_perpetual_buy_percent_fee_deducted_from_returns: +bitmart_perpetual_maker_fixed_fees: +bitmart_perpetual_taker_fixed_fees: +architect_perpetual_percent_fee_token: +architect_perpetual_maker_percent_fee: +architect_perpetual_taker_percent_fee: +architect_perpetual_buy_percent_fee_deducted_from_returns: +architect_perpetual_maker_fixed_fees: +architect_perpetual_taker_fixed_fees: +architect_perpetual_sandbox_percent_fee_token: +architect_perpetual_sandbox_maker_percent_fee: +architect_perpetual_sandbox_taker_percent_fee: +architect_perpetual_sandbox_buy_percent_fee_deducted_from_returns: +architect_perpetual_sandbox_maker_fixed_fees: +architect_perpetual_sandbox_taker_fixed_fees: +pacifica_perpetual_percent_fee_token: +pacifica_perpetual_maker_percent_fee: +pacifica_perpetual_taker_percent_fee: +pacifica_perpetual_buy_percent_fee_deducted_from_returns: +pacifica_perpetual_maker_fixed_fees: +pacifica_perpetual_taker_fixed_fees: +pacifica_perpetual_testnet_percent_fee_token: +pacifica_perpetual_testnet_maker_percent_fee: +pacifica_perpetual_testnet_taker_percent_fee: +pacifica_perpetual_testnet_buy_percent_fee_deducted_from_returns: +pacifica_perpetual_testnet_maker_fixed_fees: +pacifica_perpetual_testnet_taker_fixed_fees: +lighter_perpetual_percent_fee_token: +lighter_perpetual_maker_percent_fee: +lighter_perpetual_taker_percent_fee: +lighter_perpetual_buy_percent_fee_deducted_from_returns: +lighter_perpetual_maker_fixed_fees: +lighter_perpetual_taker_fixed_fees: +lighter_perpetual_testnet_percent_fee_token: +lighter_perpetual_testnet_maker_percent_fee: +lighter_perpetual_testnet_taker_percent_fee: +lighter_perpetual_testnet_buy_percent_fee_deducted_from_returns: +lighter_perpetual_testnet_maker_fixed_fees: +lighter_perpetual_testnet_taker_fixed_fees: +derive_perpetual_percent_fee_token: +derive_perpetual_maker_percent_fee: +derive_perpetual_taker_percent_fee: +derive_perpetual_buy_percent_fee_deducted_from_returns: +derive_perpetual_maker_fixed_fees: +derive_perpetual_taker_fixed_fees: +derive_perpetual_testnet_percent_fee_token: +derive_perpetual_testnet_maker_percent_fee: +derive_perpetual_testnet_taker_percent_fee: +derive_perpetual_testnet_buy_percent_fee_deducted_from_returns: +derive_perpetual_testnet_maker_fixed_fees: +derive_perpetual_testnet_taker_fixed_fees: +aevo_perpetual_percent_fee_token: +aevo_perpetual_maker_percent_fee: +aevo_perpetual_taker_percent_fee: +aevo_perpetual_buy_percent_fee_deducted_from_returns: +aevo_perpetual_maker_fixed_fees: +aevo_perpetual_taker_fixed_fees: +aevo_perpetual_testnet_percent_fee_token: +aevo_perpetual_testnet_maker_percent_fee: +aevo_perpetual_testnet_taker_percent_fee: +aevo_perpetual_testnet_buy_percent_fee_deducted_from_returns: +aevo_perpetual_testnet_maker_fixed_fees: +aevo_perpetual_testnet_taker_fixed_fees: +dydx_v4_perpetual_percent_fee_token: +dydx_v4_perpetual_maker_percent_fee: +dydx_v4_perpetual_taker_percent_fee: +dydx_v4_perpetual_buy_percent_fee_deducted_from_returns: +dydx_v4_perpetual_maker_fixed_fees: +dydx_v4_perpetual_taker_fixed_fees: +okx_perpetual_percent_fee_token: +okx_perpetual_maker_percent_fee: +okx_perpetual_taker_percent_fee: +okx_perpetual_buy_percent_fee_deducted_from_returns: +okx_perpetual_maker_fixed_fees: +okx_perpetual_taker_fixed_fees: +grvt_perpetual_percent_fee_token: +grvt_perpetual_maker_percent_fee: +grvt_perpetual_taker_percent_fee: +grvt_perpetual_buy_percent_fee_deducted_from_returns: +grvt_perpetual_maker_fixed_fees: +grvt_perpetual_taker_fixed_fees: +grvt_perpetual_testnet_percent_fee_token: +grvt_perpetual_testnet_maker_percent_fee: +grvt_perpetual_testnet_taker_percent_fee: +grvt_perpetual_testnet_buy_percent_fee_deducted_from_returns: +grvt_perpetual_testnet_maker_fixed_fees: +grvt_perpetual_testnet_taker_fixed_fees: +evedex_perpetual_percent_fee_token: +evedex_perpetual_maker_percent_fee: +evedex_perpetual_taker_percent_fee: +evedex_perpetual_buy_percent_fee_deducted_from_returns: +evedex_perpetual_maker_fixed_fees: +evedex_perpetual_taker_fixed_fees: +backpack_perpetual_percent_fee_token: +backpack_perpetual_maker_percent_fee: +backpack_perpetual_taker_percent_fee: +backpack_perpetual_buy_percent_fee_deducted_from_returns: +backpack_perpetual_maker_fixed_fees: +backpack_perpetual_taker_fixed_fees: +decibel_perpetual_percent_fee_token: +decibel_perpetual_maker_percent_fee: +decibel_perpetual_taker_percent_fee: +decibel_perpetual_buy_percent_fee_deducted_from_returns: +decibel_perpetual_maker_fixed_fees: +decibel_perpetual_taker_fixed_fees: +decibel_perpetual_testnet_percent_fee_token: +decibel_perpetual_testnet_maker_percent_fee: +decibel_perpetual_testnet_taker_percent_fee: +decibel_perpetual_testnet_buy_percent_fee_deducted_from_returns: +decibel_perpetual_testnet_maker_fixed_fees: +decibel_perpetual_testnet_taker_fixed_fees: +derive_percent_fee_token: +derive_maker_percent_fee: +derive_taker_percent_fee: +derive_buy_percent_fee_deducted_from_returns: +derive_maker_fixed_fees: +derive_taker_fixed_fees: +derive_testnet_percent_fee_token: +derive_testnet_maker_percent_fee: +derive_testnet_taker_percent_fee: +derive_testnet_buy_percent_fee_deducted_from_returns: +derive_testnet_maker_fixed_fees: +derive_testnet_taker_fixed_fees: +bitrue_percent_fee_token: +bitrue_maker_percent_fee: +bitrue_taker_percent_fee: +bitrue_buy_percent_fee_deducted_from_returns: +bitrue_maker_fixed_fees: +bitrue_taker_fixed_fees: +lambdaplex_percent_fee_token: +lambdaplex_maker_percent_fee: +lambdaplex_taker_percent_fee: +lambdaplex_buy_percent_fee_deducted_from_returns: +lambdaplex_maker_fixed_fees: +lambdaplex_taker_fixed_fees: +dexalot_percent_fee_token: +dexalot_maker_percent_fee: +dexalot_taker_percent_fee: +dexalot_buy_percent_fee_deducted_from_returns: +dexalot_maker_fixed_fees: +dexalot_taker_fixed_fees: +dexalot_testnet_percent_fee_token: +dexalot_testnet_maker_percent_fee: +dexalot_testnet_taker_percent_fee: +dexalot_testnet_buy_percent_fee_deducted_from_returns: +dexalot_testnet_maker_fixed_fees: +dexalot_testnet_taker_fixed_fees: +bing_x_percent_fee_token: +bing_x_maker_percent_fee: +bing_x_taker_percent_fee: +bing_x_buy_percent_fee_deducted_from_returns: +bing_x_maker_fixed_fees: +bing_x_taker_fixed_fees: +kucoin_hft_percent_fee_token: +kucoin_hft_maker_percent_fee: +kucoin_hft_taker_percent_fee: +kucoin_hft_buy_percent_fee_deducted_from_returns: +kucoin_hft_maker_fixed_fees: +kucoin_hft_taker_fixed_fees: +bitstamp_percent_fee_token: +bitstamp_maker_percent_fee: +bitstamp_taker_percent_fee: +bitstamp_buy_percent_fee_deducted_from_returns: +bitstamp_maker_fixed_fees: +bitstamp_taker_fixed_fees: +gemini_percent_fee_token: +gemini_maker_percent_fee: +gemini_taker_percent_fee: +gemini_buy_percent_fee_deducted_from_returns: +gemini_maker_fixed_fees: +gemini_taker_fixed_fees: +backpack_percent_fee_token: +backpack_maker_percent_fee: +backpack_taker_percent_fee: +backpack_buy_percent_fee_deducted_from_returns: +backpack_maker_fixed_fees: +backpack_taker_fixed_fees: +htx_percent_fee_token: +htx_maker_percent_fee: +htx_taker_percent_fee: +htx_buy_percent_fee_deducted_from_returns: +htx_maker_fixed_fees: +htx_taker_fixed_fees: +lighter_percent_fee_token: +lighter_maker_percent_fee: +lighter_taker_percent_fee: +lighter_buy_percent_fee_deducted_from_returns: +lighter_maker_fixed_fees: +lighter_taker_fixed_fees: +lighter_testnet_percent_fee_token: +lighter_testnet_maker_percent_fee: +lighter_testnet_taker_percent_fee: +lighter_testnet_buy_percent_fee_deducted_from_returns: +lighter_testnet_maker_fixed_fees: +lighter_testnet_taker_fixed_fees: +hyperliquid_percent_fee_token: +hyperliquid_maker_percent_fee: +hyperliquid_taker_percent_fee: +hyperliquid_buy_percent_fee_deducted_from_returns: +hyperliquid_maker_fixed_fees: +hyperliquid_taker_fixed_fees: +hyperliquid_testnet_percent_fee_token: +hyperliquid_testnet_maker_percent_fee: +hyperliquid_testnet_taker_percent_fee: +hyperliquid_testnet_buy_percent_fee_deducted_from_returns: +hyperliquid_testnet_maker_fixed_fees: +hyperliquid_testnet_taker_fixed_fees: +coinbase_advanced_trade_percent_fee_token: +coinbase_advanced_trade_maker_percent_fee: +coinbase_advanced_trade_taker_percent_fee: +coinbase_advanced_trade_buy_percent_fee_deducted_from_returns: +coinbase_advanced_trade_maker_fixed_fees: +coinbase_advanced_trade_taker_fixed_fees: +xrpl_percent_fee_token: +xrpl_maker_percent_fee: +xrpl_taker_percent_fee: +xrpl_buy_percent_fee_deducted_from_returns: +xrpl_maker_fixed_fees: +xrpl_taker_fixed_fees: +bitget_percent_fee_token: +bitget_maker_percent_fee: +bitget_taker_percent_fee: +bitget_buy_percent_fee_deducted_from_returns: +bitget_maker_fixed_fees: +bitget_taker_fixed_fees: diff --git a/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/.dockerignore b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/.dockerignore new file mode 100644 index 00000000..ad30bfec --- /dev/null +++ b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/.dockerignore @@ -0,0 +1 @@ +*.yml \ No newline at end of file diff --git a/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/.gitignore b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/gate_io_perpetual.yml b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/gate_io_perpetual.yml new file mode 100644 index 00000000..503e58d7 --- /dev/null +++ b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/connectors/gate_io_perpetual.yml @@ -0,0 +1,11 @@ +#################################### +### gate_io_perpetual config ### +#################################### + +connector: gate_io_perpetual + +gate_io_perpetual_api_key: 7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20223536666463386230626433613261353136633462393737313437353638653139227d2c202263697068657274657874223a202235376637663363336662626464646562386632666465316637643039306231393234656265393133643664353836653362636537316537643561363138613561222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20223731316464306439376336316430306331353461306362633663613833313364227d2c20226d6163223a202236303137366133306439313765376663623436623432316639376430356637326335343266363238633034383164646136653735643638653732623663613238227d2c202276657273696f6e223a20332c2022616c696173223a2022227d + +gate_io_perpetual_secret_key: 7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20223734303930346566636339636331613064313163393065346133646263616264227d2c202263697068657274657874223a20223665653731376164313439326139363463366539643036633662386132666661373532626636653831393965653963346333356239336663616562633738346335656235626231626166666365646662336361646633336164386630623935306466306163333763303934376165356236613761646238326466613134626333222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20226332396631646230333065323564363734643663306365643166383161313462227d2c20226d6163223a202265376239346564316564303537646434366639356537646166653464326533386465326330353365636261356534613662636335623232313566393164393963227d2c202276657273696f6e223a20332c2022616c696173223a2022227d + +gate_io_perpetual_user_id: 7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20226165613865323531633839323037663132373166323739653233633835356261227d2c202263697068657274657874223a20226338663061646538666230353335222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20226438303663636334663833366538636662613366323761396361643364623765227d2c20226d6163223a202230383430646330336463646636313263383433326262373330623466613133363333613136626562373637306533363031656265343838653461646636333634227d2c202276657273696f6e223a20332c2022616c696173223a2022227d diff --git a/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/controllers/sndk_gate_hft_mm.yml b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/controllers/sndk_gate_hft_mm.yml new file mode 100644 index 00000000..8ab1678e --- /dev/null +++ b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/controllers/sndk_gate_hft_mm.yml @@ -0,0 +1,33 @@ +buy_amounts_pct: 4,2,1 +buy_cooldown_time: 8 +buy_position_effectivization_time: 45 +buy_spreads: 0.0002,0.0008,0.0020 +connector_name: gate_io_perpetual +controller_name: pmm_mister +controller_type: generic +executor_refresh_time: 5 +global_stop_loss: 0.025 +global_take_profit: 0.02 +id: sndk_gate_hft_mm +leverage: 5 +manual_kill_switch: false +max_active_executors_by_level: 3 +max_base_pct: 0.75 +min_base_pct: 0.3 +min_skew: 1.8 +open_order_type: 3 +portfolio_allocation: 0.3 +position_mode: HEDGE +position_profit_protection: true +price_distance_tolerance: 0.0002 +refresh_tolerance: 0.0002 +sell_amounts_pct: 2,2,1 +sell_cooldown_time: 12 +sell_position_effectivization_time: 45 +sell_spreads: 0.0004,0.0016,0.0040 +take_profit: 0.0003 +take_profit_order_type: 3 +target_base_pct: 0.55 +tolerance_scaling: 1.3 +total_amount_quote: 46 +trading_pair: SNDK-USDT diff --git a/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/hummingbot_logs.yml b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/hummingbot_logs.yml new file mode 100755 index 00000000..8e65271c --- /dev/null +++ b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/hummingbot_logs.yml @@ -0,0 +1,83 @@ +--- +version: 1 +template_version: 12 + +formatters: + simple: + format: "%(asctime)s - %(process)d - %(name)s - %(levelname)s - %(message)s" + +handlers: + console: + class: hummingbot.logger.cli_handler.CLIHandler + level: DEBUG + formatter: simple + stream: ext://sys.stdout + console_warning: + class: hummingbot.logger.cli_handler.CLIHandler + level: WARNING + formatter: simple + stream: ext://sys.stdout + console_info: + class: hummingbot.logger.cli_handler.CLIHandler + level: INFO + formatter: simple + stream: ext://sys.stdout + file_handler: + class: logging.handlers.TimedRotatingFileHandler + level: DEBUG + formatter: simple + filename: $PROJECT_DIR/logs/logs_$STRATEGY_FILE_PATH.log + encoding: utf8 + when: "D" + interval: 1 + backupCount: 7 + "null": + class: logging.NullHandler + level: DEBUG + +loggers: + hummingbot.core.utils.eth_gas_station_lookup: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.logger.log_server_client: + level: WARNING + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.logger.reporting_proxy_handler: + level: WARNING + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.strategy: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.connector: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.client: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.core.event.event_reporter: + level: EVENT_LOG + propagate: false + handlers: [file_handler] + mqtt: false + conf: + level: NETWORK + handlers: ["null"] + propagate: false + mqtt: false + +root: + level: INFO + handlers: [console, file_handler] + mqtt: true diff --git a/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/scripts/sndk-gate-hft-mm-20260818-030708.yml b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/scripts/sndk-gate-hft-mm-20260818-030708.yml new file mode 100644 index 00000000..f882315d --- /dev/null +++ b/bots/archived/sndk-gate-hft-mm-20260818-030708/conf/scripts/sndk-gate-hft-mm-20260818-030708.yml @@ -0,0 +1,4 @@ +controllers_config: +- sndk_gate_hft_mm.yml +max_controller_drawdown_quote: 10.0 +script_file_name: v2_with_controllers.py diff --git a/bots/archived/sndk-gate-hft-mm-20260818-030708/data/sndk-gate-hft-mm-20260818-030708.sqlite b/bots/archived/sndk-gate-hft-mm-20260818-030708/data/sndk-gate-hft-mm-20260818-030708.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..6fce8dfc4da34432c6d752601e028eee0b8760e2 GIT binary patch literal 204800 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 literal 0 HcmV?d00001 diff --git a/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/.password_verification b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/.password_verification new file mode 100644 index 00000000..d1a274b6 --- /dev/null +++ b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/.password_verification @@ -0,0 +1 @@ +7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20226463613937613132666365383432623664323437613830393966313638373662227d2c202263697068657274657874223a20223537393331373032636666343037396439623430222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20226536376664366338373033386336613439653066373938316366656138656539227d2c20226d6163223a202233366134323163646334303464393433343264656166613037623235373866363734383930653961393765303037393231333132326532333464303666323034227d2c202276657273696f6e223a20332c2022616c696173223a2022227d \ No newline at end of file diff --git a/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/conf_client.yml b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/conf_client.yml new file mode 100644 index 00000000..895d6ac2 --- /dev/null +++ b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/conf_client.yml @@ -0,0 +1,179 @@ +#################################### +### client_config_map config ### +#################################### + +instance_id: sndk_gate_hft_mm-20260818-032452 + +# Fetch trading pairs from all exchanges if True, otherwise fetch only from connected exchanges. +fetch_pairs_from_all_exchanges: false + +log_level: INFO + +debug_console: false + +strategy_report_interval: 900.0 + +logger_override_whitelist: +- hummingbot.strategy.arbitrage +- hummingbot.strategy.cross_exchange_market_making +- conf + +log_file_path: /home/hummingbot/logs + +kill_switch_mode: {} + +# What to auto-fill in the prompt after each import command (start/config) +autofill_import: disabled + +# MQTT Bridge configuration. +mqtt_bridge: + mqtt_host: localhost + mqtt_port: 1883 + mqtt_username: '' + mqtt_password: '' + mqtt_namespace: hbot + mqtt_ssl: false + mqtt_logger: true + mqtt_notifier: true + mqtt_commands: true + mqtt_events: true + mqtt_external_events: true + mqtt_autostart: true + +# Error log sharing +send_error_logs: true + +# Advanced database options, currently supports SQLAlchemy's included dialects +# Reference: https://docs.sqlalchemy.org/en/13/dialects/ +# To use an instance of SQLite DB the required configuration is +# db_engine: sqlite +# To use a DBMS the required configuration is +# db_host: 127.0.0.1 +# db_port: 3306 +# db_username: username +# db_password: password +# db_name: dbname +db_mode: + db_engine: sqlite + +# Balance Limit Configurations +# e.g. Setting USDT and BTC limits on Binance. +# balance_asset_limit: +# binance: +# BTC: 0.1 +# USDT: 1000 +balance_asset_limit: + ascend_ex: {} + ascend_ex_paper_trade: {} + binance: {} + binance_paper_trade: {} + binance_us: {} + bitfinex: {} + bitmart: {} + bitmex: {} + bitmex_testnet: {} + btc_markets: {} + bybit: {} + bybit_testnet: {} + coinbase_pro: {} + foxbit: {} + gate_io: {} + gate_io_paper_trade: {} + hitbtc: {} + huobi: {} + injective_v2: {} + kraken: {} + kucoin: {} + kucoin_paper_trade: {} + mexc: {} + mock_paper_exchange: {} + ndax: {} + ndax_testnet: {} + okx: {} + polkadex: {} + vertex: {} + vertex_testnet: {} + woo_x: {} + woo_x_testnet: {} + +# Fixed gas price (in Gwei) for Ethereum transactions +manual_gas_price: 50.0 + +# Gateway API Configurations +# default host to only use localhost +# Port need to match the final installation port for Gateway +gateway: + gateway_api_host: localhost + gateway_api_port: '15888' + gateway_use_ssl: true + +# Whether to enable aggregated order and trade data collection +anonymized_metrics_mode: + anonymized_metrics_interval_min: 15.0 + +# A source for rate oracle, currently binance, coin_gecko, coin_cap, dexalot, evedex_perpetual, decibel_perpetual, kucoin, gate_io, backpack, coinbase_advanced_trade, hyperliquid, hyperliquid_perpetual, architect_perpetual, derive, mexc +rate_oracle_source: + name: gate_io + +# A universal token which to display tokens values in, e.g. USD,EUR,BTC +global_token: + global_token_name: USDT + global_token_symbol: $ + usd_equivalent_tokens: + - USD + +# Percentage of API rate limits (on any exchange and any end point) allocated to this bot instance. +# Enter 50 to indicate 50%. E.g. if the API rate limit is 100 calls per second, and you allocate +# 50% to this setting, the bot will have a maximum (limit) of 50 calls per second +rate_limits_share_pct: 100.0 + +commands_timeout: + create_command_timeout: 10.0 + other_commands_timeout: 30.0 + +# Tabulate table format style (https://github.com/astanin/python-tabulate#table-format) +tables_format: psql + +paper_trade: + paper_trade_exchanges: + - binance + - kucoin + - ascend_ex + - gate_io + paper_trade_account_balance: + BTC: 1.0 + DAI: 1000.0 + ETH: 10.0 + ONE: 1000.0 + TUSD: 1000.0 + USDC: 1000.0 + USDQ: 1000.0 + USDT: 1000.0 + WETH: 10.0 + +color: + top_pane: '#000000' + bottom_pane: '#000000' + output_pane: '#262626' + input_pane: '#1C1C1C' + logs_pane: '#121212' + terminal_primary: '#5FFFD7' + primary_label: '#5FFFD7' + secondary_label: '#FFFFFF' + success_label: '#5FFFD7' + warning_label: '#FFFF00' + info_label: '#5FD7FF' + error_label: '#FF0000' + gold_label: '#FFD700' + silver_label: '#C0C0C0' + bronze_label: '#CD7F32' + +# The tick size is the frequency with which the clock notifies the time iterators by calling the +# c_tick() method, that means for example that if the tick size is 1, the logic of the strategy +# will run every second. +tick_size: 1.0 + +market_data_collection: + market_data_collection_enabled: false + market_data_collection_interval: 60 + market_data_collection_depth: 20 diff --git a/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/conf_fee_overrides.yml b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/conf_fee_overrides.yml new file mode 100644 index 00000000..8dc9d57e --- /dev/null +++ b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/conf_fee_overrides.yml @@ -0,0 +1,526 @@ +######################################## +### Fee overrides configurations ### +######################################## + +# For more detailed information: https://docs.hummingbot.io +template_version: 14 + +# Example of the fields that can be specified to override the `TradeFeeFactory` default settings. +# If the field is missing or the value is left blank, the default value will be used. +# The percentage values are specified as 0.1 for 0.1%. +# +# [exchange name]_percent_fee_token: +# [exchange name]_maker_percent_fee: +# [exchange name]_taker_percent_fee: +# [exchange name]_buy_percent_fee_deducted_from_returns: # if False, the buy fee is added to the order costs +# [exchange name]_maker_fixed_fees: # a list of lists of token-fee pairs (e.g. [["ETH", 1]]) +# [exchange name]_taker_fixed_fees: # a list of lists of token-fee pairs (e.g. [["ETH", 1]]) + +binance_percent_fee_token: # BNB +binance_maker_percent_fee: # 0.75 +binance_taker_percent_fee: # 0.75 +binance_buy_percent_fee_deducted_from_returns: # True + +# List of supported Exchanges for which the user's conf/conf_fee_override.yml +# will work. This file currently needs to be in sync with hummingbot list of +# supported exchanges +ascend_ex_buy_percent_fee_deducted_from_returns: +ascend_ex_maker_fixed_fees: +ascend_ex_maker_percent_fee: +ascend_ex_percent_fee_token: +ascend_ex_taker_fixed_fees: +ascend_ex_taker_percent_fee: +binance_maker_fixed_fees: +binance_perpetual_buy_percent_fee_deducted_from_returns: +binance_perpetual_maker_fixed_fees: +binance_perpetual_maker_percent_fee: +binance_perpetual_percent_fee_token: +binance_perpetual_taker_fixed_fees: +binance_perpetual_taker_percent_fee: +binance_perpetual_testnet_buy_percent_fee_deducted_from_returns: +binance_perpetual_testnet_maker_fixed_fees: +binance_perpetual_testnet_maker_percent_fee: +binance_perpetual_testnet_percent_fee_token: +binance_perpetual_testnet_taker_fixed_fees: +binance_perpetual_testnet_taker_percent_fee: +binance_taker_fixed_fees: +binance_us_buy_percent_fee_deducted_from_returns: +binance_us_maker_fixed_fees: +binance_us_maker_percent_fee: +binance_us_percent_fee_token: +binance_us_taker_fixed_fees: +binance_us_taker_percent_fee: +bitfinex_buy_percent_fee_deducted_from_returns: +bitfinex_maker_fixed_fees: +bitfinex_maker_percent_fee: +bitfinex_percent_fee_token: +bitfinex_taker_fixed_fees: +bitfinex_taker_percent_fee: +bitmart_buy_percent_fee_deducted_from_returns: +bitmart_maker_fixed_fees: +bitmart_maker_percent_fee: +bitmart_percent_fee_token: +bitmart_taker_fixed_fees: +bitmart_taker_percent_fee: +btc_markets_percent_fee_token: +btc_markets_maker_percent_fee: +btc_markets_taker_percent_fee: +btc_markets_buy_percent_fee_deducted_from_returns: +bybit_perpetual_buy_percent_fee_deducted_from_returns: +bybit_perpetual_maker_fixed_fees: +bybit_perpetual_maker_percent_fee: +bybit_perpetual_percent_fee_token: +bybit_perpetual_taker_fixed_fees: +bybit_perpetual_taker_percent_fee: +bybit_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bybit_perpetual_testnet_maker_fixed_fees: +bybit_perpetual_testnet_maker_percent_fee: +bybit_perpetual_testnet_percent_fee_token: +bybit_perpetual_testnet_taker_fixed_fees: +bybit_perpetual_testnet_taker_percent_fee: +coinbase_pro_buy_percent_fee_deducted_from_returns: +coinbase_pro_maker_fixed_fees: +coinbase_pro_maker_percent_fee: +coinbase_pro_percent_fee_token: +coinbase_pro_taker_fixed_fees: +coinbase_pro_taker_percent_fee: +dydx_perpetual_buy_percent_fee_deducted_from_returns: +dydx_perpetual_maker_fixed_fees: +dydx_perpetual_maker_percent_fee: +dydx_perpetual_percent_fee_token: +dydx_perpetual_taker_fixed_fees: +dydx_perpetual_taker_percent_fee: +gate_io_buy_percent_fee_deducted_from_returns: +gate_io_maker_fixed_fees: +gate_io_maker_percent_fee: +gate_io_percent_fee_token: +gate_io_taker_fixed_fees: +gate_io_taker_percent_fee: +hitbtc_buy_percent_fee_deducted_from_returns: +hitbtc_maker_fixed_fees: +hitbtc_maker_percent_fee: +hitbtc_percent_fee_token: +hitbtc_taker_fixed_fees: +hitbtc_taker_percent_fee: +huobi_buy_percent_fee_deducted_from_returns: +huobi_maker_fixed_fees: +huobi_maker_percent_fee: +huobi_percent_fee_token: +huobi_taker_fixed_fees: +huobi_taker_percent_fee: +kraken_buy_percent_fee_deducted_from_returns: +kraken_maker_fixed_fees: +kraken_maker_percent_fee: +kraken_percent_fee_token: +kraken_taker_fixed_fees: +kraken_taker_percent_fee: +kucoin_buy_percent_fee_deducted_from_returns: +kucoin_maker_fixed_fees: +kucoin_maker_percent_fee: +kucoin_percent_fee_token: +kucoin_taker_fixed_fees: +kucoin_taker_percent_fee: +mexc_buy_percent_fee_deducted_from_returns: +mexc_maker_fixed_fees: +mexc_maker_percent_fee: +mexc_percent_fee_token: +mexc_taker_fixed_fees: +mexc_taker_percent_fee: +ndax_buy_percent_fee_deducted_from_returns: +ndax_maker_fixed_fees: +ndax_maker_percent_fee: +ndax_percent_fee_token: +ndax_taker_fixed_fees: +ndax_taker_percent_fee: +ndax_testnet_buy_percent_fee_deducted_from_returns: +ndax_testnet_maker_fixed_fees: +ndax_testnet_maker_percent_fee: +ndax_testnet_percent_fee_token: +ndax_testnet_taker_fixed_fees: +ndax_testnet_taker_percent_fee: +okx_buy_percent_fee_deducted_from_returns: +okx_maker_fixed_fees: +okx_maker_percent_fee: +okx_percent_fee_token: +okx_taker_fixed_fees: +okx_taker_percent_fee: +phemex_perpetual_percent_fee_token: +phemex_perpetual_maker_percent_fee: +phemex_perpetual_taker_percent_fee: +phemex_perpetual_buy_percent_fee_deducted_from_returns: +phemex_perpetual_maker_fixed_fees: +phemex_perpetual_taker_fixed_fees: +phemex_perpetual_testnet_percent_fee_token: +phemex_perpetual_testnet_maker_percent_fee: +phemex_perpetual_testnet_taker_percent_fee: +phemex_perpetual_testnet_buy_percent_fee_deducted_from_returns: +phemex_perpetual_testnet_maker_fixed_fees: +phemex_perpetual_testnet_taker_fixed_fees: +injective_v2_perpetual_percent_fee_token: +injective_v2_perpetual_maker_percent_fee: +injective_v2_perpetual_taker_percent_fee: +injective_v2_perpetual_buy_percent_fee_deducted_from_returns: +injective_v2_perpetual_maker_fixed_fees: +injective_v2_perpetual_taker_fixed_fees: +bitget_perpetual_percent_fee_token: +bitget_perpetual_maker_percent_fee: +bitget_perpetual_taker_percent_fee: +bitget_perpetual_buy_percent_fee_deducted_from_returns: +bitget_perpetual_maker_fixed_fees: +bitget_perpetual_taker_fixed_fees: +bit_com_perpetual_percent_fee_token: +bit_com_perpetual_maker_percent_fee: +bit_com_perpetual_taker_percent_fee: +bit_com_perpetual_buy_percent_fee_deducted_from_returns: +bit_com_perpetual_maker_fixed_fees: +bit_com_perpetual_taker_fixed_fees: +bit_com_perpetual_testnet_percent_fee_token: +bit_com_perpetual_testnet_maker_percent_fee: +bit_com_perpetual_testnet_taker_percent_fee: +bit_com_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bit_com_perpetual_testnet_maker_fixed_fees: +bit_com_perpetual_testnet_taker_fixed_fees: +gate_io_perpetual_percent_fee_token: +gate_io_perpetual_maker_percent_fee: +gate_io_perpetual_taker_percent_fee: +gate_io_perpetual_buy_percent_fee_deducted_from_returns: +gate_io_perpetual_maker_fixed_fees: +gate_io_perpetual_taker_fixed_fees: +bitmex_perpetual_percent_fee_token: +bitmex_perpetual_maker_percent_fee: +bitmex_perpetual_taker_percent_fee: +bitmex_perpetual_buy_percent_fee_deducted_from_returns: +bitmex_perpetual_maker_fixed_fees: +bitmex_perpetual_taker_fixed_fees: +bitmex_perpetual_testnet_percent_fee_token: +bitmex_perpetual_testnet_maker_percent_fee: +bitmex_perpetual_testnet_taker_percent_fee: +bitmex_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bitmex_perpetual_testnet_maker_fixed_fees: +bitmex_perpetual_testnet_taker_fixed_fees: +kucoin_perpetual_percent_fee_token: +kucoin_perpetual_maker_percent_fee: +kucoin_perpetual_taker_percent_fee: +kucoin_perpetual_buy_percent_fee_deducted_from_returns: +kucoin_perpetual_maker_fixed_fees: +kucoin_perpetual_taker_fixed_fees: +bitmex_percent_fee_token: +bitmex_maker_percent_fee: +bitmex_taker_percent_fee: +bitmex_buy_percent_fee_deducted_from_returns: +bitmex_maker_fixed_fees: +bitmex_taker_fixed_fees: +bitmex_testnet_percent_fee_token: +bitmex_testnet_maker_percent_fee: +bitmex_testnet_taker_percent_fee: +bitmex_testnet_buy_percent_fee_deducted_from_returns: +bitmex_testnet_maker_fixed_fees: +bitmex_testnet_taker_fixed_fees: +btc_markets_maker_fixed_fees: +btc_markets_taker_fixed_fees: +woo_x_percent_fee_token: +woo_x_maker_percent_fee: +woo_x_taker_percent_fee: +woo_x_buy_percent_fee_deducted_from_returns: +woo_x_maker_fixed_fees: +woo_x_taker_fixed_fees: +woo_x_testnet_percent_fee_token: +woo_x_testnet_maker_percent_fee: +woo_x_testnet_taker_percent_fee: +woo_x_testnet_buy_percent_fee_deducted_from_returns: +woo_x_testnet_maker_fixed_fees: +woo_x_testnet_taker_fixed_fees: +polkadex_percent_fee_token: +polkadex_maker_percent_fee: +polkadex_taker_percent_fee: +polkadex_buy_percent_fee_deducted_from_returns: +polkadex_maker_fixed_fees: +polkadex_taker_fixed_fees: +bybit_percent_fee_token: +bybit_maker_percent_fee: +bybit_taker_percent_fee: +bybit_buy_percent_fee_deducted_from_returns: +bybit_maker_fixed_fees: +bybit_taker_fixed_fees: +bybit_testnet_percent_fee_token: +bybit_testnet_maker_percent_fee: +bybit_testnet_taker_percent_fee: +bybit_testnet_buy_percent_fee_deducted_from_returns: +bybit_testnet_maker_fixed_fees: +bybit_testnet_taker_fixed_fees: +injective_v2_percent_fee_token: +injective_v2_maker_percent_fee: +injective_v2_taker_percent_fee: +injective_v2_buy_percent_fee_deducted_from_returns: +injective_v2_maker_fixed_fees: +injective_v2_taker_fixed_fees: +vertex_percent_fee_token: +vertex_maker_percent_fee: +vertex_taker_percent_fee: +vertex_buy_percent_fee_deducted_from_returns: +vertex_maker_fixed_fees: +vertex_taker_fixed_fees: +vertex_testnet_percent_fee_token: +vertex_testnet_maker_percent_fee: +vertex_testnet_taker_percent_fee: +vertex_testnet_buy_percent_fee_deducted_from_returns: +vertex_testnet_maker_fixed_fees: +vertex_testnet_taker_fixed_fees: +mock_paper_exchange_percent_fee_token: +mock_paper_exchange_maker_percent_fee: +mock_paper_exchange_taker_percent_fee: +mock_paper_exchange_buy_percent_fee_deducted_from_returns: +mock_paper_exchange_maker_fixed_fees: +mock_paper_exchange_taker_fixed_fees: +kucoin_perpetual_testnet_percent_fee_token: +kucoin_perpetual_testnet_maker_percent_fee: +kucoin_perpetual_testnet_taker_percent_fee: +kucoin_perpetual_testnet_buy_percent_fee_deducted_from_returns: +kucoin_perpetual_testnet_maker_fixed_fees: +kucoin_perpetual_testnet_taker_fixed_fees: +foxbit_percent_fee_token: +foxbit_maker_percent_fee: +foxbit_taker_percent_fee: +foxbit_buy_percent_fee_deducted_from_returns: +foxbit_maker_fixed_fees: +foxbit_taker_fixed_fees: +hyperliquid_perpetual_percent_fee_token: +hyperliquid_perpetual_maker_percent_fee: +hyperliquid_perpetual_taker_percent_fee: +hyperliquid_perpetual_buy_percent_fee_deducted_from_returns: +hyperliquid_perpetual_maker_fixed_fees: +hyperliquid_perpetual_taker_fixed_fees: +hyperliquid_perpetual_testnet_percent_fee_token: +hyperliquid_perpetual_testnet_maker_percent_fee: +hyperliquid_perpetual_testnet_taker_percent_fee: +hyperliquid_perpetual_testnet_buy_percent_fee_deducted_from_returns: +hyperliquid_perpetual_testnet_maker_fixed_fees: +hyperliquid_perpetual_testnet_taker_fixed_fees: +bitmart_perpetual_percent_fee_token: +bitmart_perpetual_maker_percent_fee: +bitmart_perpetual_taker_percent_fee: +bitmart_perpetual_buy_percent_fee_deducted_from_returns: +bitmart_perpetual_maker_fixed_fees: +bitmart_perpetual_taker_fixed_fees: +architect_perpetual_percent_fee_token: +architect_perpetual_maker_percent_fee: +architect_perpetual_taker_percent_fee: +architect_perpetual_buy_percent_fee_deducted_from_returns: +architect_perpetual_maker_fixed_fees: +architect_perpetual_taker_fixed_fees: +architect_perpetual_sandbox_percent_fee_token: +architect_perpetual_sandbox_maker_percent_fee: +architect_perpetual_sandbox_taker_percent_fee: +architect_perpetual_sandbox_buy_percent_fee_deducted_from_returns: +architect_perpetual_sandbox_maker_fixed_fees: +architect_perpetual_sandbox_taker_fixed_fees: +pacifica_perpetual_percent_fee_token: +pacifica_perpetual_maker_percent_fee: +pacifica_perpetual_taker_percent_fee: +pacifica_perpetual_buy_percent_fee_deducted_from_returns: +pacifica_perpetual_maker_fixed_fees: +pacifica_perpetual_taker_fixed_fees: +pacifica_perpetual_testnet_percent_fee_token: +pacifica_perpetual_testnet_maker_percent_fee: +pacifica_perpetual_testnet_taker_percent_fee: +pacifica_perpetual_testnet_buy_percent_fee_deducted_from_returns: +pacifica_perpetual_testnet_maker_fixed_fees: +pacifica_perpetual_testnet_taker_fixed_fees: +lighter_perpetual_percent_fee_token: +lighter_perpetual_maker_percent_fee: +lighter_perpetual_taker_percent_fee: +lighter_perpetual_buy_percent_fee_deducted_from_returns: +lighter_perpetual_maker_fixed_fees: +lighter_perpetual_taker_fixed_fees: +lighter_perpetual_testnet_percent_fee_token: +lighter_perpetual_testnet_maker_percent_fee: +lighter_perpetual_testnet_taker_percent_fee: +lighter_perpetual_testnet_buy_percent_fee_deducted_from_returns: +lighter_perpetual_testnet_maker_fixed_fees: +lighter_perpetual_testnet_taker_fixed_fees: +derive_perpetual_percent_fee_token: +derive_perpetual_maker_percent_fee: +derive_perpetual_taker_percent_fee: +derive_perpetual_buy_percent_fee_deducted_from_returns: +derive_perpetual_maker_fixed_fees: +derive_perpetual_taker_fixed_fees: +derive_perpetual_testnet_percent_fee_token: +derive_perpetual_testnet_maker_percent_fee: +derive_perpetual_testnet_taker_percent_fee: +derive_perpetual_testnet_buy_percent_fee_deducted_from_returns: +derive_perpetual_testnet_maker_fixed_fees: +derive_perpetual_testnet_taker_fixed_fees: +aevo_perpetual_percent_fee_token: +aevo_perpetual_maker_percent_fee: +aevo_perpetual_taker_percent_fee: +aevo_perpetual_buy_percent_fee_deducted_from_returns: +aevo_perpetual_maker_fixed_fees: +aevo_perpetual_taker_fixed_fees: +aevo_perpetual_testnet_percent_fee_token: +aevo_perpetual_testnet_maker_percent_fee: +aevo_perpetual_testnet_taker_percent_fee: +aevo_perpetual_testnet_buy_percent_fee_deducted_from_returns: +aevo_perpetual_testnet_maker_fixed_fees: +aevo_perpetual_testnet_taker_fixed_fees: +dydx_v4_perpetual_percent_fee_token: +dydx_v4_perpetual_maker_percent_fee: +dydx_v4_perpetual_taker_percent_fee: +dydx_v4_perpetual_buy_percent_fee_deducted_from_returns: +dydx_v4_perpetual_maker_fixed_fees: +dydx_v4_perpetual_taker_fixed_fees: +okx_perpetual_percent_fee_token: +okx_perpetual_maker_percent_fee: +okx_perpetual_taker_percent_fee: +okx_perpetual_buy_percent_fee_deducted_from_returns: +okx_perpetual_maker_fixed_fees: +okx_perpetual_taker_fixed_fees: +grvt_perpetual_percent_fee_token: +grvt_perpetual_maker_percent_fee: +grvt_perpetual_taker_percent_fee: +grvt_perpetual_buy_percent_fee_deducted_from_returns: +grvt_perpetual_maker_fixed_fees: +grvt_perpetual_taker_fixed_fees: +grvt_perpetual_testnet_percent_fee_token: +grvt_perpetual_testnet_maker_percent_fee: +grvt_perpetual_testnet_taker_percent_fee: +grvt_perpetual_testnet_buy_percent_fee_deducted_from_returns: +grvt_perpetual_testnet_maker_fixed_fees: +grvt_perpetual_testnet_taker_fixed_fees: +evedex_perpetual_percent_fee_token: +evedex_perpetual_maker_percent_fee: +evedex_perpetual_taker_percent_fee: +evedex_perpetual_buy_percent_fee_deducted_from_returns: +evedex_perpetual_maker_fixed_fees: +evedex_perpetual_taker_fixed_fees: +backpack_perpetual_percent_fee_token: +backpack_perpetual_maker_percent_fee: +backpack_perpetual_taker_percent_fee: +backpack_perpetual_buy_percent_fee_deducted_from_returns: +backpack_perpetual_maker_fixed_fees: +backpack_perpetual_taker_fixed_fees: +decibel_perpetual_percent_fee_token: +decibel_perpetual_maker_percent_fee: +decibel_perpetual_taker_percent_fee: +decibel_perpetual_buy_percent_fee_deducted_from_returns: +decibel_perpetual_maker_fixed_fees: +decibel_perpetual_taker_fixed_fees: +decibel_perpetual_testnet_percent_fee_token: +decibel_perpetual_testnet_maker_percent_fee: +decibel_perpetual_testnet_taker_percent_fee: +decibel_perpetual_testnet_buy_percent_fee_deducted_from_returns: +decibel_perpetual_testnet_maker_fixed_fees: +decibel_perpetual_testnet_taker_fixed_fees: +derive_percent_fee_token: +derive_maker_percent_fee: +derive_taker_percent_fee: +derive_buy_percent_fee_deducted_from_returns: +derive_maker_fixed_fees: +derive_taker_fixed_fees: +derive_testnet_percent_fee_token: +derive_testnet_maker_percent_fee: +derive_testnet_taker_percent_fee: +derive_testnet_buy_percent_fee_deducted_from_returns: +derive_testnet_maker_fixed_fees: +derive_testnet_taker_fixed_fees: +bitrue_percent_fee_token: +bitrue_maker_percent_fee: +bitrue_taker_percent_fee: +bitrue_buy_percent_fee_deducted_from_returns: +bitrue_maker_fixed_fees: +bitrue_taker_fixed_fees: +lambdaplex_percent_fee_token: +lambdaplex_maker_percent_fee: +lambdaplex_taker_percent_fee: +lambdaplex_buy_percent_fee_deducted_from_returns: +lambdaplex_maker_fixed_fees: +lambdaplex_taker_fixed_fees: +dexalot_percent_fee_token: +dexalot_maker_percent_fee: +dexalot_taker_percent_fee: +dexalot_buy_percent_fee_deducted_from_returns: +dexalot_maker_fixed_fees: +dexalot_taker_fixed_fees: +dexalot_testnet_percent_fee_token: +dexalot_testnet_maker_percent_fee: +dexalot_testnet_taker_percent_fee: +dexalot_testnet_buy_percent_fee_deducted_from_returns: +dexalot_testnet_maker_fixed_fees: +dexalot_testnet_taker_fixed_fees: +bing_x_percent_fee_token: +bing_x_maker_percent_fee: +bing_x_taker_percent_fee: +bing_x_buy_percent_fee_deducted_from_returns: +bing_x_maker_fixed_fees: +bing_x_taker_fixed_fees: +kucoin_hft_percent_fee_token: +kucoin_hft_maker_percent_fee: +kucoin_hft_taker_percent_fee: +kucoin_hft_buy_percent_fee_deducted_from_returns: +kucoin_hft_maker_fixed_fees: +kucoin_hft_taker_fixed_fees: +bitstamp_percent_fee_token: +bitstamp_maker_percent_fee: +bitstamp_taker_percent_fee: +bitstamp_buy_percent_fee_deducted_from_returns: +bitstamp_maker_fixed_fees: +bitstamp_taker_fixed_fees: +gemini_percent_fee_token: +gemini_maker_percent_fee: +gemini_taker_percent_fee: +gemini_buy_percent_fee_deducted_from_returns: +gemini_maker_fixed_fees: +gemini_taker_fixed_fees: +backpack_percent_fee_token: +backpack_maker_percent_fee: +backpack_taker_percent_fee: +backpack_buy_percent_fee_deducted_from_returns: +backpack_maker_fixed_fees: +backpack_taker_fixed_fees: +htx_percent_fee_token: +htx_maker_percent_fee: +htx_taker_percent_fee: +htx_buy_percent_fee_deducted_from_returns: +htx_maker_fixed_fees: +htx_taker_fixed_fees: +lighter_percent_fee_token: +lighter_maker_percent_fee: +lighter_taker_percent_fee: +lighter_buy_percent_fee_deducted_from_returns: +lighter_maker_fixed_fees: +lighter_taker_fixed_fees: +lighter_testnet_percent_fee_token: +lighter_testnet_maker_percent_fee: +lighter_testnet_taker_percent_fee: +lighter_testnet_buy_percent_fee_deducted_from_returns: +lighter_testnet_maker_fixed_fees: +lighter_testnet_taker_fixed_fees: +hyperliquid_percent_fee_token: +hyperliquid_maker_percent_fee: +hyperliquid_taker_percent_fee: +hyperliquid_buy_percent_fee_deducted_from_returns: +hyperliquid_maker_fixed_fees: +hyperliquid_taker_fixed_fees: +hyperliquid_testnet_percent_fee_token: +hyperliquid_testnet_maker_percent_fee: +hyperliquid_testnet_taker_percent_fee: +hyperliquid_testnet_buy_percent_fee_deducted_from_returns: +hyperliquid_testnet_maker_fixed_fees: +hyperliquid_testnet_taker_fixed_fees: +coinbase_advanced_trade_percent_fee_token: +coinbase_advanced_trade_maker_percent_fee: +coinbase_advanced_trade_taker_percent_fee: +coinbase_advanced_trade_buy_percent_fee_deducted_from_returns: +coinbase_advanced_trade_maker_fixed_fees: +coinbase_advanced_trade_taker_fixed_fees: +xrpl_percent_fee_token: +xrpl_maker_percent_fee: +xrpl_taker_percent_fee: +xrpl_buy_percent_fee_deducted_from_returns: +xrpl_maker_fixed_fees: +xrpl_taker_fixed_fees: +bitget_percent_fee_token: +bitget_maker_percent_fee: +bitget_taker_percent_fee: +bitget_buy_percent_fee_deducted_from_returns: +bitget_maker_fixed_fees: +bitget_taker_fixed_fees: diff --git a/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/.dockerignore b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/.dockerignore new file mode 100644 index 00000000..ad30bfec --- /dev/null +++ b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/.dockerignore @@ -0,0 +1 @@ +*.yml \ No newline at end of file diff --git a/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/.gitignore b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/gate_io_perpetual.yml b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/gate_io_perpetual.yml new file mode 100644 index 00000000..503e58d7 --- /dev/null +++ b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/connectors/gate_io_perpetual.yml @@ -0,0 +1,11 @@ +#################################### +### gate_io_perpetual config ### +#################################### + +connector: gate_io_perpetual + +gate_io_perpetual_api_key: 7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20223536666463386230626433613261353136633462393737313437353638653139227d2c202263697068657274657874223a202235376637663363336662626464646562386632666465316637643039306231393234656265393133643664353836653362636537316537643561363138613561222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20223731316464306439376336316430306331353461306362633663613833313364227d2c20226d6163223a202236303137366133306439313765376663623436623432316639376430356637326335343266363238633034383164646136653735643638653732623663613238227d2c202276657273696f6e223a20332c2022616c696173223a2022227d + +gate_io_perpetual_secret_key: 7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20223734303930346566636339636331613064313163393065346133646263616264227d2c202263697068657274657874223a20223665653731376164313439326139363463366539643036633662386132666661373532626636653831393965653963346333356239336663616562633738346335656235626231626166666365646662336361646633336164386630623935306466306163333763303934376165356236613761646238326466613134626333222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20226332396631646230333065323564363734643663306365643166383161313462227d2c20226d6163223a202265376239346564316564303537646434366639356537646166653464326533386465326330353365636261356534613662636335623232313566393164393963227d2c202276657273696f6e223a20332c2022616c696173223a2022227d + +gate_io_perpetual_user_id: 7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20226165613865323531633839323037663132373166323739653233633835356261227d2c202263697068657274657874223a20226338663061646538666230353335222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20226438303663636334663833366538636662613366323761396361643364623765227d2c20226d6163223a202230383430646330336463646636313263383433326262373330623466613133363333613136626562373637306533363031656265343838653461646636333634227d2c202276657273696f6e223a20332c2022616c696173223a2022227d diff --git a/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/controllers/sndk_gate_hft_mm.yml b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/controllers/sndk_gate_hft_mm.yml new file mode 100644 index 00000000..fda6b929 --- /dev/null +++ b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/controllers/sndk_gate_hft_mm.yml @@ -0,0 +1,33 @@ +buy_amounts_pct: 4,2,1 +buy_cooldown_time: 8 +buy_position_effectivization_time: 45 +buy_spreads: 0.0002,0.0008,0.0020 +connector_name: gate_io_perpetual +controller_name: pmm_mister +controller_type: generic +executor_refresh_time: 5 +global_stop_loss: 0.025 +global_take_profit: 0.02 +id: sndk_gate_hft_mm +leverage: 5 +manual_kill_switch: false +max_active_executors_by_level: 3 +max_base_pct: 0.75 +min_base_pct: 0.3 +min_skew: 1.8 +open_order_type: 3 +portfolio_allocation: 0.3 +position_mode: HEDGE +position_profit_protection: true +price_distance_tolerance: 0.0002 +refresh_tolerance: 0.0002 +sell_amounts_pct: 2,2,1 +sell_cooldown_time: 12 +sell_position_effectivization_time: 45 +sell_spreads: 0.0004,0.0016,0.0040 +take_profit: 0.0003 +take_profit_order_type: 3 +target_base_pct: 0.55 +tolerance_scaling: 1.3 +total_amount_quote: 100 +trading_pair: SNDK-USDT diff --git a/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/hummingbot_logs.yml b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/hummingbot_logs.yml new file mode 100755 index 00000000..8e65271c --- /dev/null +++ b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/hummingbot_logs.yml @@ -0,0 +1,83 @@ +--- +version: 1 +template_version: 12 + +formatters: + simple: + format: "%(asctime)s - %(process)d - %(name)s - %(levelname)s - %(message)s" + +handlers: + console: + class: hummingbot.logger.cli_handler.CLIHandler + level: DEBUG + formatter: simple + stream: ext://sys.stdout + console_warning: + class: hummingbot.logger.cli_handler.CLIHandler + level: WARNING + formatter: simple + stream: ext://sys.stdout + console_info: + class: hummingbot.logger.cli_handler.CLIHandler + level: INFO + formatter: simple + stream: ext://sys.stdout + file_handler: + class: logging.handlers.TimedRotatingFileHandler + level: DEBUG + formatter: simple + filename: $PROJECT_DIR/logs/logs_$STRATEGY_FILE_PATH.log + encoding: utf8 + when: "D" + interval: 1 + backupCount: 7 + "null": + class: logging.NullHandler + level: DEBUG + +loggers: + hummingbot.core.utils.eth_gas_station_lookup: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.logger.log_server_client: + level: WARNING + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.logger.reporting_proxy_handler: + level: WARNING + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.strategy: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.connector: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.client: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.core.event.event_reporter: + level: EVENT_LOG + propagate: false + handlers: [file_handler] + mqtt: false + conf: + level: NETWORK + handlers: ["null"] + propagate: false + mqtt: false + +root: + level: INFO + handlers: [console, file_handler] + mqtt: true diff --git a/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/scripts/sndk_gate_hft_mm-20260818-032452.yml b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/scripts/sndk_gate_hft_mm-20260818-032452.yml new file mode 100644 index 00000000..1d14777b --- /dev/null +++ b/bots/archived/sndk_gate_hft_mm-20260818-032452/conf/scripts/sndk_gate_hft_mm-20260818-032452.yml @@ -0,0 +1,5 @@ +controllers_config: +- sndk_gate_hft_mm.yml +max_controller_drawdown_quote: 10.0 +max_global_drawdown_quote: 15.0 +script_file_name: v2_with_controllers.py diff --git a/bots/archived/sndk_gate_hft_mm-20260818-032452/data/sndk_gate_hft_mm-20260818-032452.sqlite b/bots/archived/sndk_gate_hft_mm-20260818-032452/data/sndk_gate_hft_mm-20260818-032452.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..d5308aa240cd8c9e8e50b9c9af6fc195134a9267 GIT binary patch literal 262144 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 literal 0 HcmV?d00001 diff --git a/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/.password_verification b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/.password_verification new file mode 100644 index 00000000..d1a274b6 --- /dev/null +++ b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/.password_verification @@ -0,0 +1 @@ +7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20226463613937613132666365383432623664323437613830393966313638373662227d2c202263697068657274657874223a20223537393331373032636666343037396439623430222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20226536376664366338373033386336613439653066373938316366656138656539227d2c20226d6163223a202233366134323163646334303464393433343264656166613037623235373866363734383930653961393765303037393231333132326532333464303666323034227d2c202276657273696f6e223a20332c2022616c696173223a2022227d \ No newline at end of file diff --git a/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/conf_client.yml b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/conf_client.yml new file mode 100644 index 00000000..b41480dd --- /dev/null +++ b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/conf_client.yml @@ -0,0 +1,179 @@ +#################################### +### client_config_map config ### +#################################### + +instance_id: xrpl-pmm-usdc-xrp-20260807-171512 + +# Fetch trading pairs from all exchanges if True, otherwise fetch only from connected exchanges. +fetch_pairs_from_all_exchanges: false + +log_level: INFO + +debug_console: false + +strategy_report_interval: 900.0 + +logger_override_whitelist: +- hummingbot.strategy.arbitrage +- hummingbot.strategy.cross_exchange_market_making +- conf + +log_file_path: /home/hummingbot/logs + +kill_switch_mode: {} + +# What to auto-fill in the prompt after each import command (start/config) +autofill_import: disabled + +# MQTT Bridge configuration. +mqtt_bridge: + mqtt_host: localhost + mqtt_port: 1883 + mqtt_username: '' + mqtt_password: '' + mqtt_namespace: hbot + mqtt_ssl: false + mqtt_logger: true + mqtt_notifier: true + mqtt_commands: true + mqtt_events: true + mqtt_external_events: true + mqtt_autostart: true + +# Error log sharing +send_error_logs: true + +# Advanced database options, currently supports SQLAlchemy's included dialects +# Reference: https://docs.sqlalchemy.org/en/13/dialects/ +# To use an instance of SQLite DB the required configuration is +# db_engine: sqlite +# To use a DBMS the required configuration is +# db_host: 127.0.0.1 +# db_port: 3306 +# db_username: username +# db_password: password +# db_name: dbname +db_mode: + db_engine: sqlite + +# Balance Limit Configurations +# e.g. Setting USDT and BTC limits on Binance. +# balance_asset_limit: +# binance: +# BTC: 0.1 +# USDT: 1000 +balance_asset_limit: + ascend_ex: {} + ascend_ex_paper_trade: {} + binance: {} + binance_paper_trade: {} + binance_us: {} + bitfinex: {} + bitmart: {} + bitmex: {} + bitmex_testnet: {} + btc_markets: {} + bybit: {} + bybit_testnet: {} + coinbase_pro: {} + foxbit: {} + gate_io: {} + gate_io_paper_trade: {} + hitbtc: {} + huobi: {} + injective_v2: {} + kraken: {} + kucoin: {} + kucoin_paper_trade: {} + mexc: {} + mock_paper_exchange: {} + ndax: {} + ndax_testnet: {} + okx: {} + polkadex: {} + vertex: {} + vertex_testnet: {} + woo_x: {} + woo_x_testnet: {} + +# Fixed gas price (in Gwei) for Ethereum transactions +manual_gas_price: 50.0 + +# Gateway API Configurations +# default host to only use localhost +# Port need to match the final installation port for Gateway +gateway: + gateway_api_host: localhost + gateway_api_port: '15888' + gateway_use_ssl: true + +# Whether to enable aggregated order and trade data collection +anonymized_metrics_mode: + anonymized_metrics_interval_min: 15.0 + +# A source for rate oracle, currently binance, coin_gecko, coin_cap, dexalot, evedex_perpetual, decibel_perpetual, kucoin, gate_io, backpack, coinbase_advanced_trade, hyperliquid, hyperliquid_perpetual, architect_perpetual, derive, mexc +rate_oracle_source: + name: gate_io + +# A universal token which to display tokens values in, e.g. USD,EUR,BTC +global_token: + global_token_name: USDT + global_token_symbol: $ + usd_equivalent_tokens: + - USD + +# Percentage of API rate limits (on any exchange and any end point) allocated to this bot instance. +# Enter 50 to indicate 50%. E.g. if the API rate limit is 100 calls per second, and you allocate +# 50% to this setting, the bot will have a maximum (limit) of 50 calls per second +rate_limits_share_pct: 100.0 + +commands_timeout: + create_command_timeout: 10.0 + other_commands_timeout: 30.0 + +# Tabulate table format style (https://github.com/astanin/python-tabulate#table-format) +tables_format: psql + +paper_trade: + paper_trade_exchanges: + - binance + - kucoin + - ascend_ex + - gate_io + paper_trade_account_balance: + BTC: 1.0 + DAI: 1000.0 + ETH: 10.0 + ONE: 1000.0 + TUSD: 1000.0 + USDC: 1000.0 + USDQ: 1000.0 + USDT: 1000.0 + WETH: 10.0 + +color: + top_pane: '#000000' + bottom_pane: '#000000' + output_pane: '#262626' + input_pane: '#1C1C1C' + logs_pane: '#121212' + terminal_primary: '#5FFFD7' + primary_label: '#5FFFD7' + secondary_label: '#FFFFFF' + success_label: '#5FFFD7' + warning_label: '#FFFF00' + info_label: '#5FD7FF' + error_label: '#FF0000' + gold_label: '#FFD700' + silver_label: '#C0C0C0' + bronze_label: '#CD7F32' + +# The tick size is the frequency with which the clock notifies the time iterators by calling the +# c_tick() method, that means for example that if the tick size is 1, the logic of the strategy +# will run every second. +tick_size: 1.0 + +market_data_collection: + market_data_collection_enabled: false + market_data_collection_interval: 60 + market_data_collection_depth: 20 diff --git a/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/conf_fee_overrides.yml b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/conf_fee_overrides.yml new file mode 100644 index 00000000..8dc9d57e --- /dev/null +++ b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/conf_fee_overrides.yml @@ -0,0 +1,526 @@ +######################################## +### Fee overrides configurations ### +######################################## + +# For more detailed information: https://docs.hummingbot.io +template_version: 14 + +# Example of the fields that can be specified to override the `TradeFeeFactory` default settings. +# If the field is missing or the value is left blank, the default value will be used. +# The percentage values are specified as 0.1 for 0.1%. +# +# [exchange name]_percent_fee_token: +# [exchange name]_maker_percent_fee: +# [exchange name]_taker_percent_fee: +# [exchange name]_buy_percent_fee_deducted_from_returns: # if False, the buy fee is added to the order costs +# [exchange name]_maker_fixed_fees: # a list of lists of token-fee pairs (e.g. [["ETH", 1]]) +# [exchange name]_taker_fixed_fees: # a list of lists of token-fee pairs (e.g. [["ETH", 1]]) + +binance_percent_fee_token: # BNB +binance_maker_percent_fee: # 0.75 +binance_taker_percent_fee: # 0.75 +binance_buy_percent_fee_deducted_from_returns: # True + +# List of supported Exchanges for which the user's conf/conf_fee_override.yml +# will work. This file currently needs to be in sync with hummingbot list of +# supported exchanges +ascend_ex_buy_percent_fee_deducted_from_returns: +ascend_ex_maker_fixed_fees: +ascend_ex_maker_percent_fee: +ascend_ex_percent_fee_token: +ascend_ex_taker_fixed_fees: +ascend_ex_taker_percent_fee: +binance_maker_fixed_fees: +binance_perpetual_buy_percent_fee_deducted_from_returns: +binance_perpetual_maker_fixed_fees: +binance_perpetual_maker_percent_fee: +binance_perpetual_percent_fee_token: +binance_perpetual_taker_fixed_fees: +binance_perpetual_taker_percent_fee: +binance_perpetual_testnet_buy_percent_fee_deducted_from_returns: +binance_perpetual_testnet_maker_fixed_fees: +binance_perpetual_testnet_maker_percent_fee: +binance_perpetual_testnet_percent_fee_token: +binance_perpetual_testnet_taker_fixed_fees: +binance_perpetual_testnet_taker_percent_fee: +binance_taker_fixed_fees: +binance_us_buy_percent_fee_deducted_from_returns: +binance_us_maker_fixed_fees: +binance_us_maker_percent_fee: +binance_us_percent_fee_token: +binance_us_taker_fixed_fees: +binance_us_taker_percent_fee: +bitfinex_buy_percent_fee_deducted_from_returns: +bitfinex_maker_fixed_fees: +bitfinex_maker_percent_fee: +bitfinex_percent_fee_token: +bitfinex_taker_fixed_fees: +bitfinex_taker_percent_fee: +bitmart_buy_percent_fee_deducted_from_returns: +bitmart_maker_fixed_fees: +bitmart_maker_percent_fee: +bitmart_percent_fee_token: +bitmart_taker_fixed_fees: +bitmart_taker_percent_fee: +btc_markets_percent_fee_token: +btc_markets_maker_percent_fee: +btc_markets_taker_percent_fee: +btc_markets_buy_percent_fee_deducted_from_returns: +bybit_perpetual_buy_percent_fee_deducted_from_returns: +bybit_perpetual_maker_fixed_fees: +bybit_perpetual_maker_percent_fee: +bybit_perpetual_percent_fee_token: +bybit_perpetual_taker_fixed_fees: +bybit_perpetual_taker_percent_fee: +bybit_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bybit_perpetual_testnet_maker_fixed_fees: +bybit_perpetual_testnet_maker_percent_fee: +bybit_perpetual_testnet_percent_fee_token: +bybit_perpetual_testnet_taker_fixed_fees: +bybit_perpetual_testnet_taker_percent_fee: +coinbase_pro_buy_percent_fee_deducted_from_returns: +coinbase_pro_maker_fixed_fees: +coinbase_pro_maker_percent_fee: +coinbase_pro_percent_fee_token: +coinbase_pro_taker_fixed_fees: +coinbase_pro_taker_percent_fee: +dydx_perpetual_buy_percent_fee_deducted_from_returns: +dydx_perpetual_maker_fixed_fees: +dydx_perpetual_maker_percent_fee: +dydx_perpetual_percent_fee_token: +dydx_perpetual_taker_fixed_fees: +dydx_perpetual_taker_percent_fee: +gate_io_buy_percent_fee_deducted_from_returns: +gate_io_maker_fixed_fees: +gate_io_maker_percent_fee: +gate_io_percent_fee_token: +gate_io_taker_fixed_fees: +gate_io_taker_percent_fee: +hitbtc_buy_percent_fee_deducted_from_returns: +hitbtc_maker_fixed_fees: +hitbtc_maker_percent_fee: +hitbtc_percent_fee_token: +hitbtc_taker_fixed_fees: +hitbtc_taker_percent_fee: +huobi_buy_percent_fee_deducted_from_returns: +huobi_maker_fixed_fees: +huobi_maker_percent_fee: +huobi_percent_fee_token: +huobi_taker_fixed_fees: +huobi_taker_percent_fee: +kraken_buy_percent_fee_deducted_from_returns: +kraken_maker_fixed_fees: +kraken_maker_percent_fee: +kraken_percent_fee_token: +kraken_taker_fixed_fees: +kraken_taker_percent_fee: +kucoin_buy_percent_fee_deducted_from_returns: +kucoin_maker_fixed_fees: +kucoin_maker_percent_fee: +kucoin_percent_fee_token: +kucoin_taker_fixed_fees: +kucoin_taker_percent_fee: +mexc_buy_percent_fee_deducted_from_returns: +mexc_maker_fixed_fees: +mexc_maker_percent_fee: +mexc_percent_fee_token: +mexc_taker_fixed_fees: +mexc_taker_percent_fee: +ndax_buy_percent_fee_deducted_from_returns: +ndax_maker_fixed_fees: +ndax_maker_percent_fee: +ndax_percent_fee_token: +ndax_taker_fixed_fees: +ndax_taker_percent_fee: +ndax_testnet_buy_percent_fee_deducted_from_returns: +ndax_testnet_maker_fixed_fees: +ndax_testnet_maker_percent_fee: +ndax_testnet_percent_fee_token: +ndax_testnet_taker_fixed_fees: +ndax_testnet_taker_percent_fee: +okx_buy_percent_fee_deducted_from_returns: +okx_maker_fixed_fees: +okx_maker_percent_fee: +okx_percent_fee_token: +okx_taker_fixed_fees: +okx_taker_percent_fee: +phemex_perpetual_percent_fee_token: +phemex_perpetual_maker_percent_fee: +phemex_perpetual_taker_percent_fee: +phemex_perpetual_buy_percent_fee_deducted_from_returns: +phemex_perpetual_maker_fixed_fees: +phemex_perpetual_taker_fixed_fees: +phemex_perpetual_testnet_percent_fee_token: +phemex_perpetual_testnet_maker_percent_fee: +phemex_perpetual_testnet_taker_percent_fee: +phemex_perpetual_testnet_buy_percent_fee_deducted_from_returns: +phemex_perpetual_testnet_maker_fixed_fees: +phemex_perpetual_testnet_taker_fixed_fees: +injective_v2_perpetual_percent_fee_token: +injective_v2_perpetual_maker_percent_fee: +injective_v2_perpetual_taker_percent_fee: +injective_v2_perpetual_buy_percent_fee_deducted_from_returns: +injective_v2_perpetual_maker_fixed_fees: +injective_v2_perpetual_taker_fixed_fees: +bitget_perpetual_percent_fee_token: +bitget_perpetual_maker_percent_fee: +bitget_perpetual_taker_percent_fee: +bitget_perpetual_buy_percent_fee_deducted_from_returns: +bitget_perpetual_maker_fixed_fees: +bitget_perpetual_taker_fixed_fees: +bit_com_perpetual_percent_fee_token: +bit_com_perpetual_maker_percent_fee: +bit_com_perpetual_taker_percent_fee: +bit_com_perpetual_buy_percent_fee_deducted_from_returns: +bit_com_perpetual_maker_fixed_fees: +bit_com_perpetual_taker_fixed_fees: +bit_com_perpetual_testnet_percent_fee_token: +bit_com_perpetual_testnet_maker_percent_fee: +bit_com_perpetual_testnet_taker_percent_fee: +bit_com_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bit_com_perpetual_testnet_maker_fixed_fees: +bit_com_perpetual_testnet_taker_fixed_fees: +gate_io_perpetual_percent_fee_token: +gate_io_perpetual_maker_percent_fee: +gate_io_perpetual_taker_percent_fee: +gate_io_perpetual_buy_percent_fee_deducted_from_returns: +gate_io_perpetual_maker_fixed_fees: +gate_io_perpetual_taker_fixed_fees: +bitmex_perpetual_percent_fee_token: +bitmex_perpetual_maker_percent_fee: +bitmex_perpetual_taker_percent_fee: +bitmex_perpetual_buy_percent_fee_deducted_from_returns: +bitmex_perpetual_maker_fixed_fees: +bitmex_perpetual_taker_fixed_fees: +bitmex_perpetual_testnet_percent_fee_token: +bitmex_perpetual_testnet_maker_percent_fee: +bitmex_perpetual_testnet_taker_percent_fee: +bitmex_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bitmex_perpetual_testnet_maker_fixed_fees: +bitmex_perpetual_testnet_taker_fixed_fees: +kucoin_perpetual_percent_fee_token: +kucoin_perpetual_maker_percent_fee: +kucoin_perpetual_taker_percent_fee: +kucoin_perpetual_buy_percent_fee_deducted_from_returns: +kucoin_perpetual_maker_fixed_fees: +kucoin_perpetual_taker_fixed_fees: +bitmex_percent_fee_token: +bitmex_maker_percent_fee: +bitmex_taker_percent_fee: +bitmex_buy_percent_fee_deducted_from_returns: +bitmex_maker_fixed_fees: +bitmex_taker_fixed_fees: +bitmex_testnet_percent_fee_token: +bitmex_testnet_maker_percent_fee: +bitmex_testnet_taker_percent_fee: +bitmex_testnet_buy_percent_fee_deducted_from_returns: +bitmex_testnet_maker_fixed_fees: +bitmex_testnet_taker_fixed_fees: +btc_markets_maker_fixed_fees: +btc_markets_taker_fixed_fees: +woo_x_percent_fee_token: +woo_x_maker_percent_fee: +woo_x_taker_percent_fee: +woo_x_buy_percent_fee_deducted_from_returns: +woo_x_maker_fixed_fees: +woo_x_taker_fixed_fees: +woo_x_testnet_percent_fee_token: +woo_x_testnet_maker_percent_fee: +woo_x_testnet_taker_percent_fee: +woo_x_testnet_buy_percent_fee_deducted_from_returns: +woo_x_testnet_maker_fixed_fees: +woo_x_testnet_taker_fixed_fees: +polkadex_percent_fee_token: +polkadex_maker_percent_fee: +polkadex_taker_percent_fee: +polkadex_buy_percent_fee_deducted_from_returns: +polkadex_maker_fixed_fees: +polkadex_taker_fixed_fees: +bybit_percent_fee_token: +bybit_maker_percent_fee: +bybit_taker_percent_fee: +bybit_buy_percent_fee_deducted_from_returns: +bybit_maker_fixed_fees: +bybit_taker_fixed_fees: +bybit_testnet_percent_fee_token: +bybit_testnet_maker_percent_fee: +bybit_testnet_taker_percent_fee: +bybit_testnet_buy_percent_fee_deducted_from_returns: +bybit_testnet_maker_fixed_fees: +bybit_testnet_taker_fixed_fees: +injective_v2_percent_fee_token: +injective_v2_maker_percent_fee: +injective_v2_taker_percent_fee: +injective_v2_buy_percent_fee_deducted_from_returns: +injective_v2_maker_fixed_fees: +injective_v2_taker_fixed_fees: +vertex_percent_fee_token: +vertex_maker_percent_fee: +vertex_taker_percent_fee: +vertex_buy_percent_fee_deducted_from_returns: +vertex_maker_fixed_fees: +vertex_taker_fixed_fees: +vertex_testnet_percent_fee_token: +vertex_testnet_maker_percent_fee: +vertex_testnet_taker_percent_fee: +vertex_testnet_buy_percent_fee_deducted_from_returns: +vertex_testnet_maker_fixed_fees: +vertex_testnet_taker_fixed_fees: +mock_paper_exchange_percent_fee_token: +mock_paper_exchange_maker_percent_fee: +mock_paper_exchange_taker_percent_fee: +mock_paper_exchange_buy_percent_fee_deducted_from_returns: +mock_paper_exchange_maker_fixed_fees: +mock_paper_exchange_taker_fixed_fees: +kucoin_perpetual_testnet_percent_fee_token: +kucoin_perpetual_testnet_maker_percent_fee: +kucoin_perpetual_testnet_taker_percent_fee: +kucoin_perpetual_testnet_buy_percent_fee_deducted_from_returns: +kucoin_perpetual_testnet_maker_fixed_fees: +kucoin_perpetual_testnet_taker_fixed_fees: +foxbit_percent_fee_token: +foxbit_maker_percent_fee: +foxbit_taker_percent_fee: +foxbit_buy_percent_fee_deducted_from_returns: +foxbit_maker_fixed_fees: +foxbit_taker_fixed_fees: +hyperliquid_perpetual_percent_fee_token: +hyperliquid_perpetual_maker_percent_fee: +hyperliquid_perpetual_taker_percent_fee: +hyperliquid_perpetual_buy_percent_fee_deducted_from_returns: +hyperliquid_perpetual_maker_fixed_fees: +hyperliquid_perpetual_taker_fixed_fees: +hyperliquid_perpetual_testnet_percent_fee_token: +hyperliquid_perpetual_testnet_maker_percent_fee: +hyperliquid_perpetual_testnet_taker_percent_fee: +hyperliquid_perpetual_testnet_buy_percent_fee_deducted_from_returns: +hyperliquid_perpetual_testnet_maker_fixed_fees: +hyperliquid_perpetual_testnet_taker_fixed_fees: +bitmart_perpetual_percent_fee_token: +bitmart_perpetual_maker_percent_fee: +bitmart_perpetual_taker_percent_fee: +bitmart_perpetual_buy_percent_fee_deducted_from_returns: +bitmart_perpetual_maker_fixed_fees: +bitmart_perpetual_taker_fixed_fees: +architect_perpetual_percent_fee_token: +architect_perpetual_maker_percent_fee: +architect_perpetual_taker_percent_fee: +architect_perpetual_buy_percent_fee_deducted_from_returns: +architect_perpetual_maker_fixed_fees: +architect_perpetual_taker_fixed_fees: +architect_perpetual_sandbox_percent_fee_token: +architect_perpetual_sandbox_maker_percent_fee: +architect_perpetual_sandbox_taker_percent_fee: +architect_perpetual_sandbox_buy_percent_fee_deducted_from_returns: +architect_perpetual_sandbox_maker_fixed_fees: +architect_perpetual_sandbox_taker_fixed_fees: +pacifica_perpetual_percent_fee_token: +pacifica_perpetual_maker_percent_fee: +pacifica_perpetual_taker_percent_fee: +pacifica_perpetual_buy_percent_fee_deducted_from_returns: +pacifica_perpetual_maker_fixed_fees: +pacifica_perpetual_taker_fixed_fees: +pacifica_perpetual_testnet_percent_fee_token: +pacifica_perpetual_testnet_maker_percent_fee: +pacifica_perpetual_testnet_taker_percent_fee: +pacifica_perpetual_testnet_buy_percent_fee_deducted_from_returns: +pacifica_perpetual_testnet_maker_fixed_fees: +pacifica_perpetual_testnet_taker_fixed_fees: +lighter_perpetual_percent_fee_token: +lighter_perpetual_maker_percent_fee: +lighter_perpetual_taker_percent_fee: +lighter_perpetual_buy_percent_fee_deducted_from_returns: +lighter_perpetual_maker_fixed_fees: +lighter_perpetual_taker_fixed_fees: +lighter_perpetual_testnet_percent_fee_token: +lighter_perpetual_testnet_maker_percent_fee: +lighter_perpetual_testnet_taker_percent_fee: +lighter_perpetual_testnet_buy_percent_fee_deducted_from_returns: +lighter_perpetual_testnet_maker_fixed_fees: +lighter_perpetual_testnet_taker_fixed_fees: +derive_perpetual_percent_fee_token: +derive_perpetual_maker_percent_fee: +derive_perpetual_taker_percent_fee: +derive_perpetual_buy_percent_fee_deducted_from_returns: +derive_perpetual_maker_fixed_fees: +derive_perpetual_taker_fixed_fees: +derive_perpetual_testnet_percent_fee_token: +derive_perpetual_testnet_maker_percent_fee: +derive_perpetual_testnet_taker_percent_fee: +derive_perpetual_testnet_buy_percent_fee_deducted_from_returns: +derive_perpetual_testnet_maker_fixed_fees: +derive_perpetual_testnet_taker_fixed_fees: +aevo_perpetual_percent_fee_token: +aevo_perpetual_maker_percent_fee: +aevo_perpetual_taker_percent_fee: +aevo_perpetual_buy_percent_fee_deducted_from_returns: +aevo_perpetual_maker_fixed_fees: +aevo_perpetual_taker_fixed_fees: +aevo_perpetual_testnet_percent_fee_token: +aevo_perpetual_testnet_maker_percent_fee: +aevo_perpetual_testnet_taker_percent_fee: +aevo_perpetual_testnet_buy_percent_fee_deducted_from_returns: +aevo_perpetual_testnet_maker_fixed_fees: +aevo_perpetual_testnet_taker_fixed_fees: +dydx_v4_perpetual_percent_fee_token: +dydx_v4_perpetual_maker_percent_fee: +dydx_v4_perpetual_taker_percent_fee: +dydx_v4_perpetual_buy_percent_fee_deducted_from_returns: +dydx_v4_perpetual_maker_fixed_fees: +dydx_v4_perpetual_taker_fixed_fees: +okx_perpetual_percent_fee_token: +okx_perpetual_maker_percent_fee: +okx_perpetual_taker_percent_fee: +okx_perpetual_buy_percent_fee_deducted_from_returns: +okx_perpetual_maker_fixed_fees: +okx_perpetual_taker_fixed_fees: +grvt_perpetual_percent_fee_token: +grvt_perpetual_maker_percent_fee: +grvt_perpetual_taker_percent_fee: +grvt_perpetual_buy_percent_fee_deducted_from_returns: +grvt_perpetual_maker_fixed_fees: +grvt_perpetual_taker_fixed_fees: +grvt_perpetual_testnet_percent_fee_token: +grvt_perpetual_testnet_maker_percent_fee: +grvt_perpetual_testnet_taker_percent_fee: +grvt_perpetual_testnet_buy_percent_fee_deducted_from_returns: +grvt_perpetual_testnet_maker_fixed_fees: +grvt_perpetual_testnet_taker_fixed_fees: +evedex_perpetual_percent_fee_token: +evedex_perpetual_maker_percent_fee: +evedex_perpetual_taker_percent_fee: +evedex_perpetual_buy_percent_fee_deducted_from_returns: +evedex_perpetual_maker_fixed_fees: +evedex_perpetual_taker_fixed_fees: +backpack_perpetual_percent_fee_token: +backpack_perpetual_maker_percent_fee: +backpack_perpetual_taker_percent_fee: +backpack_perpetual_buy_percent_fee_deducted_from_returns: +backpack_perpetual_maker_fixed_fees: +backpack_perpetual_taker_fixed_fees: +decibel_perpetual_percent_fee_token: +decibel_perpetual_maker_percent_fee: +decibel_perpetual_taker_percent_fee: +decibel_perpetual_buy_percent_fee_deducted_from_returns: +decibel_perpetual_maker_fixed_fees: +decibel_perpetual_taker_fixed_fees: +decibel_perpetual_testnet_percent_fee_token: +decibel_perpetual_testnet_maker_percent_fee: +decibel_perpetual_testnet_taker_percent_fee: +decibel_perpetual_testnet_buy_percent_fee_deducted_from_returns: +decibel_perpetual_testnet_maker_fixed_fees: +decibel_perpetual_testnet_taker_fixed_fees: +derive_percent_fee_token: +derive_maker_percent_fee: +derive_taker_percent_fee: +derive_buy_percent_fee_deducted_from_returns: +derive_maker_fixed_fees: +derive_taker_fixed_fees: +derive_testnet_percent_fee_token: +derive_testnet_maker_percent_fee: +derive_testnet_taker_percent_fee: +derive_testnet_buy_percent_fee_deducted_from_returns: +derive_testnet_maker_fixed_fees: +derive_testnet_taker_fixed_fees: +bitrue_percent_fee_token: +bitrue_maker_percent_fee: +bitrue_taker_percent_fee: +bitrue_buy_percent_fee_deducted_from_returns: +bitrue_maker_fixed_fees: +bitrue_taker_fixed_fees: +lambdaplex_percent_fee_token: +lambdaplex_maker_percent_fee: +lambdaplex_taker_percent_fee: +lambdaplex_buy_percent_fee_deducted_from_returns: +lambdaplex_maker_fixed_fees: +lambdaplex_taker_fixed_fees: +dexalot_percent_fee_token: +dexalot_maker_percent_fee: +dexalot_taker_percent_fee: +dexalot_buy_percent_fee_deducted_from_returns: +dexalot_maker_fixed_fees: +dexalot_taker_fixed_fees: +dexalot_testnet_percent_fee_token: +dexalot_testnet_maker_percent_fee: +dexalot_testnet_taker_percent_fee: +dexalot_testnet_buy_percent_fee_deducted_from_returns: +dexalot_testnet_maker_fixed_fees: +dexalot_testnet_taker_fixed_fees: +bing_x_percent_fee_token: +bing_x_maker_percent_fee: +bing_x_taker_percent_fee: +bing_x_buy_percent_fee_deducted_from_returns: +bing_x_maker_fixed_fees: +bing_x_taker_fixed_fees: +kucoin_hft_percent_fee_token: +kucoin_hft_maker_percent_fee: +kucoin_hft_taker_percent_fee: +kucoin_hft_buy_percent_fee_deducted_from_returns: +kucoin_hft_maker_fixed_fees: +kucoin_hft_taker_fixed_fees: +bitstamp_percent_fee_token: +bitstamp_maker_percent_fee: +bitstamp_taker_percent_fee: +bitstamp_buy_percent_fee_deducted_from_returns: +bitstamp_maker_fixed_fees: +bitstamp_taker_fixed_fees: +gemini_percent_fee_token: +gemini_maker_percent_fee: +gemini_taker_percent_fee: +gemini_buy_percent_fee_deducted_from_returns: +gemini_maker_fixed_fees: +gemini_taker_fixed_fees: +backpack_percent_fee_token: +backpack_maker_percent_fee: +backpack_taker_percent_fee: +backpack_buy_percent_fee_deducted_from_returns: +backpack_maker_fixed_fees: +backpack_taker_fixed_fees: +htx_percent_fee_token: +htx_maker_percent_fee: +htx_taker_percent_fee: +htx_buy_percent_fee_deducted_from_returns: +htx_maker_fixed_fees: +htx_taker_fixed_fees: +lighter_percent_fee_token: +lighter_maker_percent_fee: +lighter_taker_percent_fee: +lighter_buy_percent_fee_deducted_from_returns: +lighter_maker_fixed_fees: +lighter_taker_fixed_fees: +lighter_testnet_percent_fee_token: +lighter_testnet_maker_percent_fee: +lighter_testnet_taker_percent_fee: +lighter_testnet_buy_percent_fee_deducted_from_returns: +lighter_testnet_maker_fixed_fees: +lighter_testnet_taker_fixed_fees: +hyperliquid_percent_fee_token: +hyperliquid_maker_percent_fee: +hyperliquid_taker_percent_fee: +hyperliquid_buy_percent_fee_deducted_from_returns: +hyperliquid_maker_fixed_fees: +hyperliquid_taker_fixed_fees: +hyperliquid_testnet_percent_fee_token: +hyperliquid_testnet_maker_percent_fee: +hyperliquid_testnet_taker_percent_fee: +hyperliquid_testnet_buy_percent_fee_deducted_from_returns: +hyperliquid_testnet_maker_fixed_fees: +hyperliquid_testnet_taker_fixed_fees: +coinbase_advanced_trade_percent_fee_token: +coinbase_advanced_trade_maker_percent_fee: +coinbase_advanced_trade_taker_percent_fee: +coinbase_advanced_trade_buy_percent_fee_deducted_from_returns: +coinbase_advanced_trade_maker_fixed_fees: +coinbase_advanced_trade_taker_fixed_fees: +xrpl_percent_fee_token: +xrpl_maker_percent_fee: +xrpl_taker_percent_fee: +xrpl_buy_percent_fee_deducted_from_returns: +xrpl_maker_fixed_fees: +xrpl_taker_fixed_fees: +bitget_percent_fee_token: +bitget_maker_percent_fee: +bitget_taker_percent_fee: +bitget_buy_percent_fee_deducted_from_returns: +bitget_maker_fixed_fees: +bitget_taker_fixed_fees: diff --git a/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/.dockerignore b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/.dockerignore new file mode 100644 index 00000000..ad30bfec --- /dev/null +++ b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/.dockerignore @@ -0,0 +1 @@ +*.yml \ No newline at end of file diff --git a/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/.gitignore b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/xrpl.yml b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/xrpl.yml new file mode 100644 index 00000000..c4f7a2a4 --- /dev/null +++ b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/connectors/xrpl.yml @@ -0,0 +1,20 @@ +####################### +### xrpl config ### +####################### + +connector: xrpl + +xrpl_secret_key: 7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20223231663134393764323839646334653336626339396166636430303635303534227d2c202263697068657274657874223a20226533343635353939656434393262653036336238363338303164343262373930303435393734353536386633656361386131646135363164313934643737222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20223061613133383663313339383462666261653937316239366361303239313639227d2c20226d6163223a202234613536643862613033646536623163316635326463303165373463653337373066636263396362303739303865333864343163346533356430633035363038227d2c202276657273696f6e223a20332c2022616c696173223a2022227d + +wss_node_urls: +- wss://alien-magical-sky.xrp-mainnet.quiknode.pro/cdeda141d5c67711f061b73b1c4bf70eb163c6a8/ + +custom_markets: + SOLO-XRP: + base: SOLO + quote: XRP + base_issuer: rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz + quote_issuer: '' + trading_pair_symbol: null + +max_request_per_minute: 60 diff --git a/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/controllers/001_pmm_xrpl_USDC-XRP.yml b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/controllers/001_pmm_xrpl_USDC-XRP.yml new file mode 100644 index 00000000..8ca91e37 --- /dev/null +++ b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/controllers/001_pmm_xrpl_USDC-XRP.yml @@ -0,0 +1,37 @@ +buy_amounts_pct: 1,1 +buy_cooldown_time: 60 +buy_position_effectivization_time: 300 +buy_spreads: 0.003,0.007 +connector_name: xrpl +controller_name: pmm_mister +controller_type: generic +executor_refresh_time: 60 +global_pnl_reference: position +global_sl_activation_from: target_base +global_sl_enabled: true +global_stop_loss: 0.05 +global_take_profit: 0.03 +global_tp_activation_from: min_base +global_tp_enabled: true +id: 001_pmm_xrpl_USDC-XRP +leverage: 1 +max_active_executors_by_level: 2 +max_base_pct: 0.8 +min_base_pct: 0.5 +min_skew: 2 +open_order_type: 3 +portfolio_allocation: 0.1 +position_profit_protection: true +price_distance_tolerance: 0.001 +refresh_tolerance: 0.001 +sell_amounts_pct: 1,1 +sell_cooldown_time: 300 +sell_position_effectivization_time: 300 +sell_spreads: 0.007,0.014 +take_profit: 0.0015 +take_profit_order_type: 3 +target_base_pct: 0.65 +tick_mode: false +tolerance_scaling: 1.3 +total_amount_quote: 50 +trading_pair: USDC-XRP diff --git a/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/hummingbot_logs.yml b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/hummingbot_logs.yml new file mode 100755 index 00000000..8e65271c --- /dev/null +++ b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/hummingbot_logs.yml @@ -0,0 +1,83 @@ +--- +version: 1 +template_version: 12 + +formatters: + simple: + format: "%(asctime)s - %(process)d - %(name)s - %(levelname)s - %(message)s" + +handlers: + console: + class: hummingbot.logger.cli_handler.CLIHandler + level: DEBUG + formatter: simple + stream: ext://sys.stdout + console_warning: + class: hummingbot.logger.cli_handler.CLIHandler + level: WARNING + formatter: simple + stream: ext://sys.stdout + console_info: + class: hummingbot.logger.cli_handler.CLIHandler + level: INFO + formatter: simple + stream: ext://sys.stdout + file_handler: + class: logging.handlers.TimedRotatingFileHandler + level: DEBUG + formatter: simple + filename: $PROJECT_DIR/logs/logs_$STRATEGY_FILE_PATH.log + encoding: utf8 + when: "D" + interval: 1 + backupCount: 7 + "null": + class: logging.NullHandler + level: DEBUG + +loggers: + hummingbot.core.utils.eth_gas_station_lookup: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.logger.log_server_client: + level: WARNING + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.logger.reporting_proxy_handler: + level: WARNING + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.strategy: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.connector: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.client: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.core.event.event_reporter: + level: EVENT_LOG + propagate: false + handlers: [file_handler] + mqtt: false + conf: + level: NETWORK + handlers: ["null"] + propagate: false + mqtt: false + +root: + level: INFO + handlers: [console, file_handler] + mqtt: true diff --git a/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/scripts/xrpl-pmm-usdc-xrp-20260807-171512.yml b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/scripts/xrpl-pmm-usdc-xrp-20260807-171512.yml new file mode 100644 index 00000000..67c21f58 --- /dev/null +++ b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/conf/scripts/xrpl-pmm-usdc-xrp-20260807-171512.yml @@ -0,0 +1,3 @@ +controllers_config: +- 001_pmm_xrpl_USDC-XRP.yml +script_file_name: v2_with_controllers.py diff --git a/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/data/xrpl-pmm-usdc-xrp-20260807-171512.sqlite b/bots/archived/xrpl-pmm-usdc-xrp-20260807-171512/data/xrpl-pmm-usdc-xrp-20260807-171512.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..ec005b97dee57274f605ed39ac9e76d581d3d9e3 GIT binary patch literal 212992 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 literal 0 HcmV?d00001 diff --git a/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/.password_verification b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/.password_verification new file mode 100644 index 00000000..d1a274b6 --- /dev/null +++ b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/.password_verification @@ -0,0 +1 @@ +7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20226463613937613132666365383432623664323437613830393966313638373662227d2c202263697068657274657874223a20223537393331373032636666343037396439623430222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20226536376664366338373033386336613439653066373938316366656138656539227d2c20226d6163223a202233366134323163646334303464393433343264656166613037623235373866363734383930653961393765303037393231333132326532333464303666323034227d2c202276657273696f6e223a20332c2022616c696173223a2022227d \ No newline at end of file diff --git a/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/conf_client.yml b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/conf_client.yml new file mode 100644 index 00000000..ab8a282c --- /dev/null +++ b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/conf_client.yml @@ -0,0 +1,179 @@ +#################################### +### client_config_map config ### +#################################### + +instance_id: xrpl-xrp-usdc-mm-20260807-185151 + +# Fetch trading pairs from all exchanges if True, otherwise fetch only from connected exchanges. +fetch_pairs_from_all_exchanges: false + +log_level: INFO + +debug_console: false + +strategy_report_interval: 900.0 + +logger_override_whitelist: +- hummingbot.strategy.arbitrage +- hummingbot.strategy.cross_exchange_market_making +- conf + +log_file_path: /home/hummingbot/logs + +kill_switch_mode: {} + +# What to auto-fill in the prompt after each import command (start/config) +autofill_import: disabled + +# MQTT Bridge configuration. +mqtt_bridge: + mqtt_host: localhost + mqtt_port: 1883 + mqtt_username: '' + mqtt_password: '' + mqtt_namespace: hbot + mqtt_ssl: false + mqtt_logger: true + mqtt_notifier: true + mqtt_commands: true + mqtt_events: true + mqtt_external_events: true + mqtt_autostart: true + +# Error log sharing +send_error_logs: true + +# Advanced database options, currently supports SQLAlchemy's included dialects +# Reference: https://docs.sqlalchemy.org/en/13/dialects/ +# To use an instance of SQLite DB the required configuration is +# db_engine: sqlite +# To use a DBMS the required configuration is +# db_host: 127.0.0.1 +# db_port: 3306 +# db_username: username +# db_password: password +# db_name: dbname +db_mode: + db_engine: sqlite + +# Balance Limit Configurations +# e.g. Setting USDT and BTC limits on Binance. +# balance_asset_limit: +# binance: +# BTC: 0.1 +# USDT: 1000 +balance_asset_limit: + ascend_ex: {} + ascend_ex_paper_trade: {} + binance: {} + binance_paper_trade: {} + binance_us: {} + bitfinex: {} + bitmart: {} + bitmex: {} + bitmex_testnet: {} + btc_markets: {} + bybit: {} + bybit_testnet: {} + coinbase_pro: {} + foxbit: {} + gate_io: {} + gate_io_paper_trade: {} + hitbtc: {} + huobi: {} + injective_v2: {} + kraken: {} + kucoin: {} + kucoin_paper_trade: {} + mexc: {} + mock_paper_exchange: {} + ndax: {} + ndax_testnet: {} + okx: {} + polkadex: {} + vertex: {} + vertex_testnet: {} + woo_x: {} + woo_x_testnet: {} + +# Fixed gas price (in Gwei) for Ethereum transactions +manual_gas_price: 50.0 + +# Gateway API Configurations +# default host to only use localhost +# Port need to match the final installation port for Gateway +gateway: + gateway_api_host: localhost + gateway_api_port: '15888' + gateway_use_ssl: true + +# Whether to enable aggregated order and trade data collection +anonymized_metrics_mode: + anonymized_metrics_interval_min: 15.0 + +# A source for rate oracle, currently binance, coin_gecko, coin_cap, dexalot, evedex_perpetual, decibel_perpetual, kucoin, gate_io, backpack, coinbase_advanced_trade, hyperliquid, hyperliquid_perpetual, architect_perpetual, derive, mexc +rate_oracle_source: + name: gate_io + +# A universal token which to display tokens values in, e.g. USD,EUR,BTC +global_token: + global_token_name: USDT + global_token_symbol: $ + usd_equivalent_tokens: + - USD + +# Percentage of API rate limits (on any exchange and any end point) allocated to this bot instance. +# Enter 50 to indicate 50%. E.g. if the API rate limit is 100 calls per second, and you allocate +# 50% to this setting, the bot will have a maximum (limit) of 50 calls per second +rate_limits_share_pct: 100.0 + +commands_timeout: + create_command_timeout: 10.0 + other_commands_timeout: 30.0 + +# Tabulate table format style (https://github.com/astanin/python-tabulate#table-format) +tables_format: psql + +paper_trade: + paper_trade_exchanges: + - binance + - kucoin + - ascend_ex + - gate_io + paper_trade_account_balance: + BTC: 1.0 + DAI: 1000.0 + ETH: 10.0 + ONE: 1000.0 + TUSD: 1000.0 + USDC: 1000.0 + USDQ: 1000.0 + USDT: 1000.0 + WETH: 10.0 + +color: + top_pane: '#000000' + bottom_pane: '#000000' + output_pane: '#262626' + input_pane: '#1C1C1C' + logs_pane: '#121212' + terminal_primary: '#5FFFD7' + primary_label: '#5FFFD7' + secondary_label: '#FFFFFF' + success_label: '#5FFFD7' + warning_label: '#FFFF00' + info_label: '#5FD7FF' + error_label: '#FF0000' + gold_label: '#FFD700' + silver_label: '#C0C0C0' + bronze_label: '#CD7F32' + +# The tick size is the frequency with which the clock notifies the time iterators by calling the +# c_tick() method, that means for example that if the tick size is 1, the logic of the strategy +# will run every second. +tick_size: 1.0 + +market_data_collection: + market_data_collection_enabled: false + market_data_collection_interval: 60 + market_data_collection_depth: 20 diff --git a/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/conf_fee_overrides.yml b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/conf_fee_overrides.yml new file mode 100644 index 00000000..8dc9d57e --- /dev/null +++ b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/conf_fee_overrides.yml @@ -0,0 +1,526 @@ +######################################## +### Fee overrides configurations ### +######################################## + +# For more detailed information: https://docs.hummingbot.io +template_version: 14 + +# Example of the fields that can be specified to override the `TradeFeeFactory` default settings. +# If the field is missing or the value is left blank, the default value will be used. +# The percentage values are specified as 0.1 for 0.1%. +# +# [exchange name]_percent_fee_token: +# [exchange name]_maker_percent_fee: +# [exchange name]_taker_percent_fee: +# [exchange name]_buy_percent_fee_deducted_from_returns: # if False, the buy fee is added to the order costs +# [exchange name]_maker_fixed_fees: # a list of lists of token-fee pairs (e.g. [["ETH", 1]]) +# [exchange name]_taker_fixed_fees: # a list of lists of token-fee pairs (e.g. [["ETH", 1]]) + +binance_percent_fee_token: # BNB +binance_maker_percent_fee: # 0.75 +binance_taker_percent_fee: # 0.75 +binance_buy_percent_fee_deducted_from_returns: # True + +# List of supported Exchanges for which the user's conf/conf_fee_override.yml +# will work. This file currently needs to be in sync with hummingbot list of +# supported exchanges +ascend_ex_buy_percent_fee_deducted_from_returns: +ascend_ex_maker_fixed_fees: +ascend_ex_maker_percent_fee: +ascend_ex_percent_fee_token: +ascend_ex_taker_fixed_fees: +ascend_ex_taker_percent_fee: +binance_maker_fixed_fees: +binance_perpetual_buy_percent_fee_deducted_from_returns: +binance_perpetual_maker_fixed_fees: +binance_perpetual_maker_percent_fee: +binance_perpetual_percent_fee_token: +binance_perpetual_taker_fixed_fees: +binance_perpetual_taker_percent_fee: +binance_perpetual_testnet_buy_percent_fee_deducted_from_returns: +binance_perpetual_testnet_maker_fixed_fees: +binance_perpetual_testnet_maker_percent_fee: +binance_perpetual_testnet_percent_fee_token: +binance_perpetual_testnet_taker_fixed_fees: +binance_perpetual_testnet_taker_percent_fee: +binance_taker_fixed_fees: +binance_us_buy_percent_fee_deducted_from_returns: +binance_us_maker_fixed_fees: +binance_us_maker_percent_fee: +binance_us_percent_fee_token: +binance_us_taker_fixed_fees: +binance_us_taker_percent_fee: +bitfinex_buy_percent_fee_deducted_from_returns: +bitfinex_maker_fixed_fees: +bitfinex_maker_percent_fee: +bitfinex_percent_fee_token: +bitfinex_taker_fixed_fees: +bitfinex_taker_percent_fee: +bitmart_buy_percent_fee_deducted_from_returns: +bitmart_maker_fixed_fees: +bitmart_maker_percent_fee: +bitmart_percent_fee_token: +bitmart_taker_fixed_fees: +bitmart_taker_percent_fee: +btc_markets_percent_fee_token: +btc_markets_maker_percent_fee: +btc_markets_taker_percent_fee: +btc_markets_buy_percent_fee_deducted_from_returns: +bybit_perpetual_buy_percent_fee_deducted_from_returns: +bybit_perpetual_maker_fixed_fees: +bybit_perpetual_maker_percent_fee: +bybit_perpetual_percent_fee_token: +bybit_perpetual_taker_fixed_fees: +bybit_perpetual_taker_percent_fee: +bybit_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bybit_perpetual_testnet_maker_fixed_fees: +bybit_perpetual_testnet_maker_percent_fee: +bybit_perpetual_testnet_percent_fee_token: +bybit_perpetual_testnet_taker_fixed_fees: +bybit_perpetual_testnet_taker_percent_fee: +coinbase_pro_buy_percent_fee_deducted_from_returns: +coinbase_pro_maker_fixed_fees: +coinbase_pro_maker_percent_fee: +coinbase_pro_percent_fee_token: +coinbase_pro_taker_fixed_fees: +coinbase_pro_taker_percent_fee: +dydx_perpetual_buy_percent_fee_deducted_from_returns: +dydx_perpetual_maker_fixed_fees: +dydx_perpetual_maker_percent_fee: +dydx_perpetual_percent_fee_token: +dydx_perpetual_taker_fixed_fees: +dydx_perpetual_taker_percent_fee: +gate_io_buy_percent_fee_deducted_from_returns: +gate_io_maker_fixed_fees: +gate_io_maker_percent_fee: +gate_io_percent_fee_token: +gate_io_taker_fixed_fees: +gate_io_taker_percent_fee: +hitbtc_buy_percent_fee_deducted_from_returns: +hitbtc_maker_fixed_fees: +hitbtc_maker_percent_fee: +hitbtc_percent_fee_token: +hitbtc_taker_fixed_fees: +hitbtc_taker_percent_fee: +huobi_buy_percent_fee_deducted_from_returns: +huobi_maker_fixed_fees: +huobi_maker_percent_fee: +huobi_percent_fee_token: +huobi_taker_fixed_fees: +huobi_taker_percent_fee: +kraken_buy_percent_fee_deducted_from_returns: +kraken_maker_fixed_fees: +kraken_maker_percent_fee: +kraken_percent_fee_token: +kraken_taker_fixed_fees: +kraken_taker_percent_fee: +kucoin_buy_percent_fee_deducted_from_returns: +kucoin_maker_fixed_fees: +kucoin_maker_percent_fee: +kucoin_percent_fee_token: +kucoin_taker_fixed_fees: +kucoin_taker_percent_fee: +mexc_buy_percent_fee_deducted_from_returns: +mexc_maker_fixed_fees: +mexc_maker_percent_fee: +mexc_percent_fee_token: +mexc_taker_fixed_fees: +mexc_taker_percent_fee: +ndax_buy_percent_fee_deducted_from_returns: +ndax_maker_fixed_fees: +ndax_maker_percent_fee: +ndax_percent_fee_token: +ndax_taker_fixed_fees: +ndax_taker_percent_fee: +ndax_testnet_buy_percent_fee_deducted_from_returns: +ndax_testnet_maker_fixed_fees: +ndax_testnet_maker_percent_fee: +ndax_testnet_percent_fee_token: +ndax_testnet_taker_fixed_fees: +ndax_testnet_taker_percent_fee: +okx_buy_percent_fee_deducted_from_returns: +okx_maker_fixed_fees: +okx_maker_percent_fee: +okx_percent_fee_token: +okx_taker_fixed_fees: +okx_taker_percent_fee: +phemex_perpetual_percent_fee_token: +phemex_perpetual_maker_percent_fee: +phemex_perpetual_taker_percent_fee: +phemex_perpetual_buy_percent_fee_deducted_from_returns: +phemex_perpetual_maker_fixed_fees: +phemex_perpetual_taker_fixed_fees: +phemex_perpetual_testnet_percent_fee_token: +phemex_perpetual_testnet_maker_percent_fee: +phemex_perpetual_testnet_taker_percent_fee: +phemex_perpetual_testnet_buy_percent_fee_deducted_from_returns: +phemex_perpetual_testnet_maker_fixed_fees: +phemex_perpetual_testnet_taker_fixed_fees: +injective_v2_perpetual_percent_fee_token: +injective_v2_perpetual_maker_percent_fee: +injective_v2_perpetual_taker_percent_fee: +injective_v2_perpetual_buy_percent_fee_deducted_from_returns: +injective_v2_perpetual_maker_fixed_fees: +injective_v2_perpetual_taker_fixed_fees: +bitget_perpetual_percent_fee_token: +bitget_perpetual_maker_percent_fee: +bitget_perpetual_taker_percent_fee: +bitget_perpetual_buy_percent_fee_deducted_from_returns: +bitget_perpetual_maker_fixed_fees: +bitget_perpetual_taker_fixed_fees: +bit_com_perpetual_percent_fee_token: +bit_com_perpetual_maker_percent_fee: +bit_com_perpetual_taker_percent_fee: +bit_com_perpetual_buy_percent_fee_deducted_from_returns: +bit_com_perpetual_maker_fixed_fees: +bit_com_perpetual_taker_fixed_fees: +bit_com_perpetual_testnet_percent_fee_token: +bit_com_perpetual_testnet_maker_percent_fee: +bit_com_perpetual_testnet_taker_percent_fee: +bit_com_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bit_com_perpetual_testnet_maker_fixed_fees: +bit_com_perpetual_testnet_taker_fixed_fees: +gate_io_perpetual_percent_fee_token: +gate_io_perpetual_maker_percent_fee: +gate_io_perpetual_taker_percent_fee: +gate_io_perpetual_buy_percent_fee_deducted_from_returns: +gate_io_perpetual_maker_fixed_fees: +gate_io_perpetual_taker_fixed_fees: +bitmex_perpetual_percent_fee_token: +bitmex_perpetual_maker_percent_fee: +bitmex_perpetual_taker_percent_fee: +bitmex_perpetual_buy_percent_fee_deducted_from_returns: +bitmex_perpetual_maker_fixed_fees: +bitmex_perpetual_taker_fixed_fees: +bitmex_perpetual_testnet_percent_fee_token: +bitmex_perpetual_testnet_maker_percent_fee: +bitmex_perpetual_testnet_taker_percent_fee: +bitmex_perpetual_testnet_buy_percent_fee_deducted_from_returns: +bitmex_perpetual_testnet_maker_fixed_fees: +bitmex_perpetual_testnet_taker_fixed_fees: +kucoin_perpetual_percent_fee_token: +kucoin_perpetual_maker_percent_fee: +kucoin_perpetual_taker_percent_fee: +kucoin_perpetual_buy_percent_fee_deducted_from_returns: +kucoin_perpetual_maker_fixed_fees: +kucoin_perpetual_taker_fixed_fees: +bitmex_percent_fee_token: +bitmex_maker_percent_fee: +bitmex_taker_percent_fee: +bitmex_buy_percent_fee_deducted_from_returns: +bitmex_maker_fixed_fees: +bitmex_taker_fixed_fees: +bitmex_testnet_percent_fee_token: +bitmex_testnet_maker_percent_fee: +bitmex_testnet_taker_percent_fee: +bitmex_testnet_buy_percent_fee_deducted_from_returns: +bitmex_testnet_maker_fixed_fees: +bitmex_testnet_taker_fixed_fees: +btc_markets_maker_fixed_fees: +btc_markets_taker_fixed_fees: +woo_x_percent_fee_token: +woo_x_maker_percent_fee: +woo_x_taker_percent_fee: +woo_x_buy_percent_fee_deducted_from_returns: +woo_x_maker_fixed_fees: +woo_x_taker_fixed_fees: +woo_x_testnet_percent_fee_token: +woo_x_testnet_maker_percent_fee: +woo_x_testnet_taker_percent_fee: +woo_x_testnet_buy_percent_fee_deducted_from_returns: +woo_x_testnet_maker_fixed_fees: +woo_x_testnet_taker_fixed_fees: +polkadex_percent_fee_token: +polkadex_maker_percent_fee: +polkadex_taker_percent_fee: +polkadex_buy_percent_fee_deducted_from_returns: +polkadex_maker_fixed_fees: +polkadex_taker_fixed_fees: +bybit_percent_fee_token: +bybit_maker_percent_fee: +bybit_taker_percent_fee: +bybit_buy_percent_fee_deducted_from_returns: +bybit_maker_fixed_fees: +bybit_taker_fixed_fees: +bybit_testnet_percent_fee_token: +bybit_testnet_maker_percent_fee: +bybit_testnet_taker_percent_fee: +bybit_testnet_buy_percent_fee_deducted_from_returns: +bybit_testnet_maker_fixed_fees: +bybit_testnet_taker_fixed_fees: +injective_v2_percent_fee_token: +injective_v2_maker_percent_fee: +injective_v2_taker_percent_fee: +injective_v2_buy_percent_fee_deducted_from_returns: +injective_v2_maker_fixed_fees: +injective_v2_taker_fixed_fees: +vertex_percent_fee_token: +vertex_maker_percent_fee: +vertex_taker_percent_fee: +vertex_buy_percent_fee_deducted_from_returns: +vertex_maker_fixed_fees: +vertex_taker_fixed_fees: +vertex_testnet_percent_fee_token: +vertex_testnet_maker_percent_fee: +vertex_testnet_taker_percent_fee: +vertex_testnet_buy_percent_fee_deducted_from_returns: +vertex_testnet_maker_fixed_fees: +vertex_testnet_taker_fixed_fees: +mock_paper_exchange_percent_fee_token: +mock_paper_exchange_maker_percent_fee: +mock_paper_exchange_taker_percent_fee: +mock_paper_exchange_buy_percent_fee_deducted_from_returns: +mock_paper_exchange_maker_fixed_fees: +mock_paper_exchange_taker_fixed_fees: +kucoin_perpetual_testnet_percent_fee_token: +kucoin_perpetual_testnet_maker_percent_fee: +kucoin_perpetual_testnet_taker_percent_fee: +kucoin_perpetual_testnet_buy_percent_fee_deducted_from_returns: +kucoin_perpetual_testnet_maker_fixed_fees: +kucoin_perpetual_testnet_taker_fixed_fees: +foxbit_percent_fee_token: +foxbit_maker_percent_fee: +foxbit_taker_percent_fee: +foxbit_buy_percent_fee_deducted_from_returns: +foxbit_maker_fixed_fees: +foxbit_taker_fixed_fees: +hyperliquid_perpetual_percent_fee_token: +hyperliquid_perpetual_maker_percent_fee: +hyperliquid_perpetual_taker_percent_fee: +hyperliquid_perpetual_buy_percent_fee_deducted_from_returns: +hyperliquid_perpetual_maker_fixed_fees: +hyperliquid_perpetual_taker_fixed_fees: +hyperliquid_perpetual_testnet_percent_fee_token: +hyperliquid_perpetual_testnet_maker_percent_fee: +hyperliquid_perpetual_testnet_taker_percent_fee: +hyperliquid_perpetual_testnet_buy_percent_fee_deducted_from_returns: +hyperliquid_perpetual_testnet_maker_fixed_fees: +hyperliquid_perpetual_testnet_taker_fixed_fees: +bitmart_perpetual_percent_fee_token: +bitmart_perpetual_maker_percent_fee: +bitmart_perpetual_taker_percent_fee: +bitmart_perpetual_buy_percent_fee_deducted_from_returns: +bitmart_perpetual_maker_fixed_fees: +bitmart_perpetual_taker_fixed_fees: +architect_perpetual_percent_fee_token: +architect_perpetual_maker_percent_fee: +architect_perpetual_taker_percent_fee: +architect_perpetual_buy_percent_fee_deducted_from_returns: +architect_perpetual_maker_fixed_fees: +architect_perpetual_taker_fixed_fees: +architect_perpetual_sandbox_percent_fee_token: +architect_perpetual_sandbox_maker_percent_fee: +architect_perpetual_sandbox_taker_percent_fee: +architect_perpetual_sandbox_buy_percent_fee_deducted_from_returns: +architect_perpetual_sandbox_maker_fixed_fees: +architect_perpetual_sandbox_taker_fixed_fees: +pacifica_perpetual_percent_fee_token: +pacifica_perpetual_maker_percent_fee: +pacifica_perpetual_taker_percent_fee: +pacifica_perpetual_buy_percent_fee_deducted_from_returns: +pacifica_perpetual_maker_fixed_fees: +pacifica_perpetual_taker_fixed_fees: +pacifica_perpetual_testnet_percent_fee_token: +pacifica_perpetual_testnet_maker_percent_fee: +pacifica_perpetual_testnet_taker_percent_fee: +pacifica_perpetual_testnet_buy_percent_fee_deducted_from_returns: +pacifica_perpetual_testnet_maker_fixed_fees: +pacifica_perpetual_testnet_taker_fixed_fees: +lighter_perpetual_percent_fee_token: +lighter_perpetual_maker_percent_fee: +lighter_perpetual_taker_percent_fee: +lighter_perpetual_buy_percent_fee_deducted_from_returns: +lighter_perpetual_maker_fixed_fees: +lighter_perpetual_taker_fixed_fees: +lighter_perpetual_testnet_percent_fee_token: +lighter_perpetual_testnet_maker_percent_fee: +lighter_perpetual_testnet_taker_percent_fee: +lighter_perpetual_testnet_buy_percent_fee_deducted_from_returns: +lighter_perpetual_testnet_maker_fixed_fees: +lighter_perpetual_testnet_taker_fixed_fees: +derive_perpetual_percent_fee_token: +derive_perpetual_maker_percent_fee: +derive_perpetual_taker_percent_fee: +derive_perpetual_buy_percent_fee_deducted_from_returns: +derive_perpetual_maker_fixed_fees: +derive_perpetual_taker_fixed_fees: +derive_perpetual_testnet_percent_fee_token: +derive_perpetual_testnet_maker_percent_fee: +derive_perpetual_testnet_taker_percent_fee: +derive_perpetual_testnet_buy_percent_fee_deducted_from_returns: +derive_perpetual_testnet_maker_fixed_fees: +derive_perpetual_testnet_taker_fixed_fees: +aevo_perpetual_percent_fee_token: +aevo_perpetual_maker_percent_fee: +aevo_perpetual_taker_percent_fee: +aevo_perpetual_buy_percent_fee_deducted_from_returns: +aevo_perpetual_maker_fixed_fees: +aevo_perpetual_taker_fixed_fees: +aevo_perpetual_testnet_percent_fee_token: +aevo_perpetual_testnet_maker_percent_fee: +aevo_perpetual_testnet_taker_percent_fee: +aevo_perpetual_testnet_buy_percent_fee_deducted_from_returns: +aevo_perpetual_testnet_maker_fixed_fees: +aevo_perpetual_testnet_taker_fixed_fees: +dydx_v4_perpetual_percent_fee_token: +dydx_v4_perpetual_maker_percent_fee: +dydx_v4_perpetual_taker_percent_fee: +dydx_v4_perpetual_buy_percent_fee_deducted_from_returns: +dydx_v4_perpetual_maker_fixed_fees: +dydx_v4_perpetual_taker_fixed_fees: +okx_perpetual_percent_fee_token: +okx_perpetual_maker_percent_fee: +okx_perpetual_taker_percent_fee: +okx_perpetual_buy_percent_fee_deducted_from_returns: +okx_perpetual_maker_fixed_fees: +okx_perpetual_taker_fixed_fees: +grvt_perpetual_percent_fee_token: +grvt_perpetual_maker_percent_fee: +grvt_perpetual_taker_percent_fee: +grvt_perpetual_buy_percent_fee_deducted_from_returns: +grvt_perpetual_maker_fixed_fees: +grvt_perpetual_taker_fixed_fees: +grvt_perpetual_testnet_percent_fee_token: +grvt_perpetual_testnet_maker_percent_fee: +grvt_perpetual_testnet_taker_percent_fee: +grvt_perpetual_testnet_buy_percent_fee_deducted_from_returns: +grvt_perpetual_testnet_maker_fixed_fees: +grvt_perpetual_testnet_taker_fixed_fees: +evedex_perpetual_percent_fee_token: +evedex_perpetual_maker_percent_fee: +evedex_perpetual_taker_percent_fee: +evedex_perpetual_buy_percent_fee_deducted_from_returns: +evedex_perpetual_maker_fixed_fees: +evedex_perpetual_taker_fixed_fees: +backpack_perpetual_percent_fee_token: +backpack_perpetual_maker_percent_fee: +backpack_perpetual_taker_percent_fee: +backpack_perpetual_buy_percent_fee_deducted_from_returns: +backpack_perpetual_maker_fixed_fees: +backpack_perpetual_taker_fixed_fees: +decibel_perpetual_percent_fee_token: +decibel_perpetual_maker_percent_fee: +decibel_perpetual_taker_percent_fee: +decibel_perpetual_buy_percent_fee_deducted_from_returns: +decibel_perpetual_maker_fixed_fees: +decibel_perpetual_taker_fixed_fees: +decibel_perpetual_testnet_percent_fee_token: +decibel_perpetual_testnet_maker_percent_fee: +decibel_perpetual_testnet_taker_percent_fee: +decibel_perpetual_testnet_buy_percent_fee_deducted_from_returns: +decibel_perpetual_testnet_maker_fixed_fees: +decibel_perpetual_testnet_taker_fixed_fees: +derive_percent_fee_token: +derive_maker_percent_fee: +derive_taker_percent_fee: +derive_buy_percent_fee_deducted_from_returns: +derive_maker_fixed_fees: +derive_taker_fixed_fees: +derive_testnet_percent_fee_token: +derive_testnet_maker_percent_fee: +derive_testnet_taker_percent_fee: +derive_testnet_buy_percent_fee_deducted_from_returns: +derive_testnet_maker_fixed_fees: +derive_testnet_taker_fixed_fees: +bitrue_percent_fee_token: +bitrue_maker_percent_fee: +bitrue_taker_percent_fee: +bitrue_buy_percent_fee_deducted_from_returns: +bitrue_maker_fixed_fees: +bitrue_taker_fixed_fees: +lambdaplex_percent_fee_token: +lambdaplex_maker_percent_fee: +lambdaplex_taker_percent_fee: +lambdaplex_buy_percent_fee_deducted_from_returns: +lambdaplex_maker_fixed_fees: +lambdaplex_taker_fixed_fees: +dexalot_percent_fee_token: +dexalot_maker_percent_fee: +dexalot_taker_percent_fee: +dexalot_buy_percent_fee_deducted_from_returns: +dexalot_maker_fixed_fees: +dexalot_taker_fixed_fees: +dexalot_testnet_percent_fee_token: +dexalot_testnet_maker_percent_fee: +dexalot_testnet_taker_percent_fee: +dexalot_testnet_buy_percent_fee_deducted_from_returns: +dexalot_testnet_maker_fixed_fees: +dexalot_testnet_taker_fixed_fees: +bing_x_percent_fee_token: +bing_x_maker_percent_fee: +bing_x_taker_percent_fee: +bing_x_buy_percent_fee_deducted_from_returns: +bing_x_maker_fixed_fees: +bing_x_taker_fixed_fees: +kucoin_hft_percent_fee_token: +kucoin_hft_maker_percent_fee: +kucoin_hft_taker_percent_fee: +kucoin_hft_buy_percent_fee_deducted_from_returns: +kucoin_hft_maker_fixed_fees: +kucoin_hft_taker_fixed_fees: +bitstamp_percent_fee_token: +bitstamp_maker_percent_fee: +bitstamp_taker_percent_fee: +bitstamp_buy_percent_fee_deducted_from_returns: +bitstamp_maker_fixed_fees: +bitstamp_taker_fixed_fees: +gemini_percent_fee_token: +gemini_maker_percent_fee: +gemini_taker_percent_fee: +gemini_buy_percent_fee_deducted_from_returns: +gemini_maker_fixed_fees: +gemini_taker_fixed_fees: +backpack_percent_fee_token: +backpack_maker_percent_fee: +backpack_taker_percent_fee: +backpack_buy_percent_fee_deducted_from_returns: +backpack_maker_fixed_fees: +backpack_taker_fixed_fees: +htx_percent_fee_token: +htx_maker_percent_fee: +htx_taker_percent_fee: +htx_buy_percent_fee_deducted_from_returns: +htx_maker_fixed_fees: +htx_taker_fixed_fees: +lighter_percent_fee_token: +lighter_maker_percent_fee: +lighter_taker_percent_fee: +lighter_buy_percent_fee_deducted_from_returns: +lighter_maker_fixed_fees: +lighter_taker_fixed_fees: +lighter_testnet_percent_fee_token: +lighter_testnet_maker_percent_fee: +lighter_testnet_taker_percent_fee: +lighter_testnet_buy_percent_fee_deducted_from_returns: +lighter_testnet_maker_fixed_fees: +lighter_testnet_taker_fixed_fees: +hyperliquid_percent_fee_token: +hyperliquid_maker_percent_fee: +hyperliquid_taker_percent_fee: +hyperliquid_buy_percent_fee_deducted_from_returns: +hyperliquid_maker_fixed_fees: +hyperliquid_taker_fixed_fees: +hyperliquid_testnet_percent_fee_token: +hyperliquid_testnet_maker_percent_fee: +hyperliquid_testnet_taker_percent_fee: +hyperliquid_testnet_buy_percent_fee_deducted_from_returns: +hyperliquid_testnet_maker_fixed_fees: +hyperliquid_testnet_taker_fixed_fees: +coinbase_advanced_trade_percent_fee_token: +coinbase_advanced_trade_maker_percent_fee: +coinbase_advanced_trade_taker_percent_fee: +coinbase_advanced_trade_buy_percent_fee_deducted_from_returns: +coinbase_advanced_trade_maker_fixed_fees: +coinbase_advanced_trade_taker_fixed_fees: +xrpl_percent_fee_token: +xrpl_maker_percent_fee: +xrpl_taker_percent_fee: +xrpl_buy_percent_fee_deducted_from_returns: +xrpl_maker_fixed_fees: +xrpl_taker_fixed_fees: +bitget_percent_fee_token: +bitget_maker_percent_fee: +bitget_taker_percent_fee: +bitget_buy_percent_fee_deducted_from_returns: +bitget_maker_fixed_fees: +bitget_taker_fixed_fees: diff --git a/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/.dockerignore b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/.dockerignore new file mode 100644 index 00000000..ad30bfec --- /dev/null +++ b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/.dockerignore @@ -0,0 +1 @@ +*.yml \ No newline at end of file diff --git a/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/.gitignore b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/xrpl.yml b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/xrpl.yml new file mode 100644 index 00000000..c4f7a2a4 --- /dev/null +++ b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/connectors/xrpl.yml @@ -0,0 +1,20 @@ +####################### +### xrpl config ### +####################### + +connector: xrpl + +xrpl_secret_key: 7b2263727970746f223a207b22636970686572223a20226165732d3132382d637472222c2022636970686572706172616d73223a207b226976223a20223231663134393764323839646334653336626339396166636430303635303534227d2c202263697068657274657874223a20226533343635353939656434393262653036336238363338303164343262373930303435393734353536386633656361386131646135363164313934643737222c20226b6466223a202270626b646632222c20226b6466706172616d73223a207b2263223a20313030303030302c2022646b6c656e223a2033322c2022707266223a2022686d61632d736861323536222c202273616c74223a20223061613133383663313339383462666261653937316239366361303239313639227d2c20226d6163223a202234613536643862613033646536623163316635326463303165373463653337373066636263396362303739303865333864343163346533356430633035363038227d2c202276657273696f6e223a20332c2022616c696173223a2022227d + +wss_node_urls: +- wss://alien-magical-sky.xrp-mainnet.quiknode.pro/cdeda141d5c67711f061b73b1c4bf70eb163c6a8/ + +custom_markets: + SOLO-XRP: + base: SOLO + quote: XRP + base_issuer: rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz + quote_issuer: '' + trading_pair_symbol: null + +max_request_per_minute: 60 diff --git a/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/controllers/xrpl_xrp_usdc_mm.yml b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/controllers/xrpl_xrp_usdc_mm.yml new file mode 100644 index 00000000..9347f724 --- /dev/null +++ b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/controllers/xrpl_xrp_usdc_mm.yml @@ -0,0 +1,39 @@ +buy_amounts_pct: 1,1 +buy_cooldown_time: 30 +buy_position_effectivization_time: 600 +buy_spreads: 0.0010,0.0020 +connector_name: xrpl +controller_name: pmm_mister +controller_type: generic +executor_refresh_time: 30 +global_pnl_reference: position +global_sl_activation_from: target_base +global_sl_enabled: true +global_stop_loss: 0.05 +global_take_profit: 0.03 +global_tp_activation_from: min_base +global_tp_enabled: true +id: xrpl_xrp_usdc_mm +leverage: 1 +manual_kill_switch: false +max_active_executors_by_level: 3 +max_base_pct: 0.7 +min_base_pct: 0.3 +min_skew: 1 +open_order_type: 3 +portfolio_allocation: 0.05 +position_mode: ONEWAY +position_profit_protection: true +price_distance_tolerance: 0.0005 +refresh_tolerance: 0.0005 +sell_amounts_pct: 1,1 +sell_cooldown_time: 30 +sell_position_effectivization_time: 600 +sell_spreads: 0.0015,0.0030 +take_profit: 0.0012 +take_profit_order_type: 3 +target_base_pct: 0.6 +tick_mode: false +tolerance_scaling: 1.2 +total_amount_quote: 100 +trading_pair: XRP-USDC diff --git a/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/hummingbot_logs.yml b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/hummingbot_logs.yml new file mode 100755 index 00000000..8e65271c --- /dev/null +++ b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/hummingbot_logs.yml @@ -0,0 +1,83 @@ +--- +version: 1 +template_version: 12 + +formatters: + simple: + format: "%(asctime)s - %(process)d - %(name)s - %(levelname)s - %(message)s" + +handlers: + console: + class: hummingbot.logger.cli_handler.CLIHandler + level: DEBUG + formatter: simple + stream: ext://sys.stdout + console_warning: + class: hummingbot.logger.cli_handler.CLIHandler + level: WARNING + formatter: simple + stream: ext://sys.stdout + console_info: + class: hummingbot.logger.cli_handler.CLIHandler + level: INFO + formatter: simple + stream: ext://sys.stdout + file_handler: + class: logging.handlers.TimedRotatingFileHandler + level: DEBUG + formatter: simple + filename: $PROJECT_DIR/logs/logs_$STRATEGY_FILE_PATH.log + encoding: utf8 + when: "D" + interval: 1 + backupCount: 7 + "null": + class: logging.NullHandler + level: DEBUG + +loggers: + hummingbot.core.utils.eth_gas_station_lookup: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.logger.log_server_client: + level: WARNING + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.logger.reporting_proxy_handler: + level: WARNING + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.strategy: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.connector: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.client: + level: NETWORK + propagate: false + handlers: [console, file_handler] + mqtt: true + hummingbot.core.event.event_reporter: + level: EVENT_LOG + propagate: false + handlers: [file_handler] + mqtt: false + conf: + level: NETWORK + handlers: ["null"] + propagate: false + mqtt: false + +root: + level: INFO + handlers: [console, file_handler] + mqtt: true diff --git a/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/scripts/xrpl-xrp-usdc-mm-20260807-185151.yml b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/scripts/xrpl-xrp-usdc-mm-20260807-185151.yml new file mode 100644 index 00000000..4ec11c4d --- /dev/null +++ b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/conf/scripts/xrpl-xrp-usdc-mm-20260807-185151.yml @@ -0,0 +1,4 @@ +controllers_config: +- xrpl_xrp_usdc_mm.yml +max_global_drawdown_quote: 7.0 +script_file_name: v2_with_controllers.py diff --git a/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/data/xrpl-xrp-usdc-mm-20260807-185151.sqlite b/bots/archived/xrpl-xrp-usdc-mm-20260807-185151/data/xrpl-xrp-usdc-mm-20260807-185151.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..52b9cf0564fcd903c6de2f16dfe93c7940e5a167 GIT binary patch literal 413696 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%0Dns9L|h-J7j||Gs})nPuKf;XIA5}+VGlO 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 literal 0 HcmV?d00001 diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index 8793f4ce..c8cd6a6b 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -591,10 +591,11 @@ def determine_executor_actions(self) -> List[ExecutorAction]: self._current_executor_id = None # Determine side for new position - if executor_failed and failed_executor_side is not None: - # Retry with same side on failure + if (executor_failed or involuntary_hold) and failed_executor_side is not None: + # Retry with same side after any abnormal terminal (FAILED, or an + # involuntary hold that left no on-chain position to recover) side = failed_executor_side - self.logger().info(f"Retrying with same side={side} after executor failure") + self.logger().info(f"Retrying with same side={side} after abnormal executor end") elif not self._initial_position_created: # Initial position: use configured side side = self.config.side @@ -822,24 +823,25 @@ def _is_price_within_limits(self, price: Decimal, side: TradeType) -> bool: """ Check if price is within configured limits for the position type. """ + # `is not None`: a limit set to exactly 0 is a real bound, not "unset" if side == TradeType.SELL: - if self.config.sell_price_min and price < self.config.sell_price_min: + if self.config.sell_price_min is not None and price < self.config.sell_price_min: return False - if self.config.sell_price_max and price > self.config.sell_price_max: + if self.config.sell_price_max is not None and price > self.config.sell_price_max: return False elif side == TradeType.BUY: - if self.config.buy_price_min and price < self.config.buy_price_min: + if self.config.buy_price_min is not None and price < self.config.buy_price_min: return False - if self.config.buy_price_max and price > self.config.buy_price_max: + if self.config.buy_price_max is not None and price > self.config.buy_price_max: return False else: # RANGE - if self.config.buy_price_min and price < self.config.buy_price_min: + if self.config.buy_price_min is not None and price < self.config.buy_price_min: return False - if self.config.buy_price_max and price > self.config.buy_price_max: + if self.config.buy_price_max is not None and price > self.config.buy_price_max: return False - if self.config.sell_price_min and price < self.config.sell_price_min: + if self.config.sell_price_min is not None and price < self.config.sell_price_min: return False - if self.config.sell_price_max and price > self.config.sell_price_max: + if self.config.sell_price_max is not None and price > self.config.sell_price_max: return False return True diff --git a/database/models.py b/database/models.py index 6b85af06..84698835 100644 --- a/database/models.py +++ b/database/models.py @@ -325,7 +325,8 @@ class GatewayCLMMEvent(Base): # Event type event_type = Column(String, nullable=False, - index=True) # OPEN, ADD_LIQUIDITY, REMOVE_LIQUIDITY, COLLECT_FEES, CLOSE + index=True) # OPEN, ADD_LIQUIDITY, REMOVE_LIQUIDITY, COLLECT_FEES, CLOSE, + # DISCOVERED (written by the poller with a synthetic tx hash) # Event amounts base_token_amount = Column(Numeric(precision=30, scale=18), nullable=True) diff --git a/database/repositories/gateway_clmm_repository.py b/database/repositories/gateway_clmm_repository.py index f292c69f..c5281767 100644 --- a/database/repositories/gateway_clmm_repository.py +++ b/database/repositories/gateway_clmm_repository.py @@ -1,8 +1,8 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from decimal import Decimal from typing import Dict, List, Optional, Set -from sqlalchemy import distinct, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from database.models import GatewayCLMMEvent, GatewayCLMMPosition @@ -93,7 +93,7 @@ async def close_position(self, position_address: str) -> Optional[GatewayCLMMPos position = result.scalar_one_or_none() if position: position.status = "CLOSED" - position.closed_at = datetime.utcnow() + position.closed_at = datetime.now(timezone.utc) await self.session.flush() return position @@ -114,6 +114,44 @@ async def reopen_position(self, position_address: str) -> Optional[GatewayCLMMPo await self.session.flush() return position + async def add_to_initial_amounts( + self, + position_address: str, + base_delta: Decimal, + quote_delta: Decimal + ) -> Optional[GatewayCLMMPosition]: + """Raise the PnL baseline when liquidity is ADDED to an existing position. + + Without this, pnl_summary compares post-add value against the original + deposit only and overstates PnL by exactly the added capital. + """ + result = await self.session.execute( + select(GatewayCLMMPosition).where(GatewayCLMMPosition.position_address == position_address) + ) + position = result.scalar_one_or_none() + if position: + position.initial_base_token_amount = float(position.initial_base_token_amount or 0) + float(base_delta) + position.initial_quote_token_amount = float(position.initial_quote_token_amount or 0) + float(quote_delta) + await self.session.flush() + return position + + async def get_recently_closed_addresses(self, within_seconds: int) -> Set[str]: + """Addresses of positions closed within the last `within_seconds`. + + Used by the discovery sweep's reopen logic: a lagging positions-owned RPC + read can still show a position the user just closed, and reopening it would + flap the record CLOSED -> OPEN -> CLOSED. + """ + cutoff = datetime.now(timezone.utc) - timedelta(seconds=within_seconds) + result = await self.session.execute( + select(GatewayCLMMPosition.position_address).where( + GatewayCLMMPosition.status == "CLOSED", + GatewayCLMMPosition.closed_at.isnot(None), + GatewayCLMMPosition.closed_at >= cutoff, + ) + ) + return set(result.scalars().all()) + async def get_positions( self, network: Optional[str] = None, @@ -162,32 +200,6 @@ async def get_open_positions( limit=1000 ) - async def get_unique_wallet_configs(self) -> List[Dict]: - """ - Get unique combinations of connector/network/wallet from all positions. - - Returns: - List of dicts with keys: connector, network, wallet_address - This is useful for discovering which wallets to poll for positions. - """ - query = select( - distinct(GatewayCLMMPosition.connector), - GatewayCLMMPosition.network, - GatewayCLMMPosition.wallet_address - ).distinct() - - result = await self.session.execute(query) - rows = result.all() - - return [ - { - "connector": row[0], - "network": row[1], - "wallet_address": row[2] - } - for row in rows - ] - async def get_position_addresses_set(self, status: Optional[str] = None) -> Set[str]: """ Get a set of position addresses in the database. @@ -216,19 +228,6 @@ async def create_event(self, event_data: Dict) -> GatewayCLMMEvent: await self.session.flush() return event - async def get_event_by_tx_hash( - self, - transaction_hash: str, - event_type: Optional[str] = None - ) -> Optional[GatewayCLMMEvent]: - """Get an event by transaction hash.""" - query = select(GatewayCLMMEvent).where(GatewayCLMMEvent.transaction_hash == transaction_hash) - if event_type: - query = query.where(GatewayCLMMEvent.event_type == event_type) - - result = await self.session.execute(query) - return result.scalar_one_or_none() - async def update_event_status( self, transaction_hash: str, @@ -294,14 +293,14 @@ def position_to_dict(self, position: GatewayCLMMPosition) -> Dict: pnl_summary = None # Get prices for PnL calculation - entry_price = float(position.entry_price) if position.entry_price else None - current_price = float(position.current_price) if position.current_price else None + entry_price = float(position.entry_price) if position.entry_price is not None else None + current_price = float(position.current_price) if position.current_price is not None else None # Calculate PnL if we have initial amounts and prices - if (position.initial_base_token_amount is not None and - position.initial_quote_token_amount is not None and - entry_price and entry_price > 0 and - current_price and current_price > 0): + if (position.initial_base_token_amount is not None + and position.initial_quote_token_amount is not None + and entry_price and entry_price > 0 + and current_price and current_price > 0): # Initial amounts initial_base = float(position.initial_base_token_amount) @@ -411,8 +410,10 @@ def position_to_dict(self, position: GatewayCLMMPosition) -> Dict: "entry_price": entry_price, "current_price": current_price, "percentage": float(position.percentage) if position.percentage is not None else None, - "initial_base_token_amount": float(position.initial_base_token_amount) if position.initial_base_token_amount is not None else None, - "initial_quote_token_amount": float(position.initial_quote_token_amount) if position.initial_quote_token_amount is not None else None, + "initial_base_token_amount": (float(position.initial_base_token_amount) + if position.initial_base_token_amount is not None else None), + "initial_quote_token_amount": (float(position.initial_quote_token_amount) + if position.initial_quote_token_amount is not None else None), "position_rent": float(position.position_rent) if position.position_rent is not None else None, "base_token_amount": float(position.base_token_amount), "quote_token_amount": float(position.quote_token_amount), @@ -431,11 +432,11 @@ def event_to_dict(self, event: GatewayCLMMEvent) -> Dict: "transaction_hash": event.transaction_hash, "timestamp": event.timestamp.isoformat(), "event_type": event.event_type, - "base_token_amount": float(event.base_token_amount) if event.base_token_amount else None, - "quote_token_amount": float(event.quote_token_amount) if event.quote_token_amount else None, - "base_fee_collected": float(event.base_fee_collected) if event.base_fee_collected else None, - "quote_fee_collected": float(event.quote_fee_collected) if event.quote_fee_collected else None, - "gas_fee": float(event.gas_fee) if event.gas_fee else None, + "base_token_amount": float(event.base_token_amount) if event.base_token_amount is not None else None, + "quote_token_amount": float(event.quote_token_amount) if event.quote_token_amount is not None else None, + "base_fee_collected": float(event.base_fee_collected) if event.base_fee_collected is not None else None, + "quote_fee_collected": float(event.quote_fee_collected) if event.quote_fee_collected is not None else None, + "gas_fee": float(event.gas_fee) if event.gas_fee is not None else None, "gas_token": event.gas_token, "status": event.status, "error_message": event.error_message, diff --git a/database/repositories/gateway_swap_repository.py b/database/repositories/gateway_swap_repository.py index 51c75ed6..4547cc31 100644 --- a/database/repositories/gateway_swap_repository.py +++ b/database/repositories/gateway_swap_repository.py @@ -1,4 +1,5 @@ -from datetime import datetime +import logging +from datetime import datetime, timezone from decimal import Decimal from typing import Dict, List, Optional @@ -7,6 +8,8 @@ from database.models import GatewaySwap +logger = logging.getLogger(__name__) + class GatewaySwapRepository: def __init__(self, session: AsyncSession): @@ -75,12 +78,15 @@ async def get_swaps( if trading_pair: query = query.where(GatewaySwap.trading_pair == trading_pair) if status: - query = query.where(GatewaySwap.status == status) + # DB statuses are uppercase (SUBMITTED/CONFIRMED/FAILED); accept either + # casing so a status copied from a write response matches. + query = query.where(GatewaySwap.status == status.upper()) if start_time: - start_dt = datetime.fromtimestamp(start_time) + # tz-aware: the timestamp column is TIMESTAMP(timezone=True) + start_dt = datetime.fromtimestamp(start_time, tz=timezone.utc) query = query.where(GatewaySwap.timestamp >= start_dt) if end_time: - end_dt = datetime.fromtimestamp(end_time) + end_dt = datetime.fromtimestamp(end_time, tz=timezone.utc) query = query.where(GatewaySwap.timestamp <= end_dt) # Apply ordering and pagination @@ -112,19 +118,29 @@ async def get_swaps_summary( wallet_address=wallet_address, start_time=start_time, end_time=end_time, - limit=10000 # Get all for summary + limit=10000 ) + if len(swaps) == 10000: + logger.warning("Swap summary hit the 10,000-row cap; totals cover only the most recent rows") total_swaps = len(swaps) confirmed_swaps = sum(1 for s in swaps if s.status == "CONFIRMED") failed_swaps = sum(1 for s in swaps if s.status == "FAILED") pending_swaps = sum(1 for s in swaps if s.status == "SUBMITTED") - # Calculate total volume (in quote token) - total_volume = sum( - float(s.output_amount if s.side == "BUY" else s.input_amount) - for s in swaps if s.status == "CONFIRMED" - ) + # Quote-denominated volume per quote token. Amount columns are token-flow + # (BUY: input=quote, output=base; SELL: input=base, output=quote), so the + # quote leg is input for BUY and output for SELL — and volumes are only + # comparable within one quote token, never summed across denominations. + # side.upper() tolerates legacy lowercase rows written before normalization. + volume_by_quote_token: Dict[str, float] = {} + for s in swaps: + if s.status != "CONFIRMED": + continue + quote_amount = s.input_amount if (s.side or "").upper() == "BUY" else s.output_amount + volume_by_quote_token[s.quote_token] = ( + volume_by_quote_token.get(s.quote_token, 0.0) + float(quote_amount) + ) # Calculate total gas fees total_gas_fees = sum( @@ -138,7 +154,7 @@ async def get_swaps_summary( "failed_swaps": failed_swaps, "pending_swaps": pending_swaps, "success_rate": confirmed_swaps / total_swaps if total_swaps > 0 else 0, - "total_volume": total_volume, + "volume_by_quote_token": volume_by_quote_token, "total_gas_fees": total_gas_fees, } diff --git a/models/__init__.py b/models/__init__.py index 5991d18b..b0742cf6 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -111,11 +111,9 @@ CLMMCollectFeesRequest, CLMMCollectFeesResponse, CLMMCreatePoolRequest, - CLMMGetPositionInfoRequest, CLMMOpenPositionRequest, CLMMOpenPositionResponse, CLMMPoolBin, - CLMMPoolInfoRequest, CLMMPoolInfoResponse, CLMMPoolListItem, CLMMPoolListResponse, @@ -124,13 +122,10 @@ CLMMQuotePositionRequest, CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, - GetPoolInfoRequest, - PoolInfo, SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, SwapQuoteResponse, - TimeBasedMetrics, ) # Market data models @@ -352,13 +347,8 @@ "CLMMQuotePositionRequest", "CLMMQuotePositionResponse", "CLMMPositionInfo", - "CLMMGetPositionInfoRequest", - "CLMMPoolInfoRequest", "CLMMPoolBin", "CLMMPoolInfoResponse", - "GetPoolInfoRequest", - "PoolInfo", - "TimeBasedMetrics", "CLMMPoolListItem", "CLMMPoolListResponse", # Portfolio models diff --git a/models/gateway_trading.py b/models/gateway_trading.py index ab944766..c1ddc1a2 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -155,7 +155,9 @@ class CLMMRemoveLiquidityRequest(BaseModel): connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") position_address: str = Field(description="Position address to remove liquidity from") - percentage: Decimal = Field(description="Percentage of liquidity to remove (0-100)") + # Same name as the AMM remove model and Gateway's percentageToRemove — and distinct + # from the position row's `percentage`, which means price-range width. + percentage_to_remove: Decimal = Field(description="Percentage of liquidity to remove (0-100)") slippage_pct: Optional[Decimal] = Field( default=None, description="Maximum slippage percentage. Only honored by the Orca connector; " @@ -170,8 +172,8 @@ class CLMMClosePositionRequest(BaseModel): position_address: str = Field(description="Position address to close") pool_address: Optional[str] = Field( default=None, - description="Pool the position belongs to. Only needed for positions this API never recorded " - "(e.g. opened by an lp_executor straight against Gateway); otherwise read from the database" + description="Pool the position belongs to. Informational only — neither Gateway's call " + "nor the fee snapshot needs it, and unrecorded positions work without it" ) wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") @@ -183,8 +185,8 @@ class CLMMCollectFeesRequest(BaseModel): position_address: str = Field(description="Position address to collect fees from") pool_address: Optional[str] = Field( default=None, - description="Pool the position belongs to. Only needed for positions this API never recorded " - "(e.g. opened by an lp_executor straight against Gateway); otherwise read from the database" + description="Pool the position belongs to. Informational only — neither Gateway's call " + "nor the fee snapshot needs it, and unrecorded positions work without it" ) wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") @@ -274,12 +276,17 @@ class CLMMPositionsOwnedRequest(BaseModel): class CLMMPositionInfo(BaseModel): - """Information about a CLMM liquidity position""" + """Information about a CLMM liquidity position. + + Note: in_range here is a bool (live Gateway read); the DB-backed + /clmm/positions/search endpoint reports in_range as the string enum + IN_RANGE / OUT_OF_RANGE / UNKNOWN (three states, so not collapsible to bool). + """ position_address: str = Field(description="Position address") pool_address: str = Field(description="Pool address") - trading_pair: str = Field(description="Trading pair") - base_token: str = Field(description="Base token symbol") - quote_token: str = Field(description="Quote token symbol") + trading_pair: str = Field(description="Trading pair (address-derived identifiers, not symbols)") + base_token: str = Field(description="Base token identifier (derived from the token address; not a symbol)") + quote_token: str = Field(description="Quote token identifier (derived from the token address; not a symbol)") base_token_amount: Decimal = Field(description="Base token amount in position") quote_token_amount: Decimal = Field(description="Quote token amount in position") current_price: Decimal = Field(description="Current pool price") @@ -292,25 +299,6 @@ class CLMMPositionInfo(BaseModel): in_range: bool = Field(description="Whether position is currently in range") -class CLMMGetPositionInfoRequest(BaseModel): - """Request to get detailed info about a specific CLMM position""" - connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") - network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - position_address: str = Field(description="Position address to query") - - -class CLMMPoolInfoRequest(BaseModel): - """Request to get CLMM pool information by pool address""" - connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium')") - network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - pool_address: str = Field(description="Pool contract address") - bin_count: int = Field( - default=0, - description="If > 0, include the per-tick liquidity distribution (bins) around the active " - "price — Gateway's binCount. Meteora always returns its bins and ignores this; orca, " - "raydium, uniswap and pancakeswap compute them on request.") - - class CLMMPoolBin(BaseModel): """Individual bin in a CLMM pool (e.g., Meteora)""" bin_id: int = Field(alias="binId", description="Bin identifier") @@ -549,47 +537,10 @@ class AMMPositionsOwnedRequest(BaseModel): wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") -# ============================================ -# Pool Information Models -# ============================================ - -class GetPoolInfoRequest(BaseModel): - """Request to get pool information""" - connector: str = Field(description="DEX connector (e.g., 'meteora', 'raydium', 'jupiter')") - network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - trading_pair: str = Field(description="Trading pair (e.g., 'SOL-USDC')") - - -class PoolInfo(BaseModel): - """Information about a liquidity pool""" - type: str = Field(description="Pool type: 'clmm' or 'router'") - address: str = Field(description="Pool address") - trading_pair: str = Field(description="Trading pair") - base_token: str = Field(description="Base token symbol") - quote_token: str = Field(description="Quote token symbol") - current_price: Decimal = Field(description="Current pool price") - base_token_amount: Decimal = Field(description="Base token liquidity in pool") - quote_token_amount: Decimal = Field(description="Quote token liquidity in pool") - fee_pct: Decimal = Field(description="Pool fee percentage") - - # CLMM-specific - bin_step: Optional[int] = Field(default=None, description="Bin step (CLMM)") - active_bin_id: Optional[int] = Field(default=None, description="Active bin ID (CLMM)") - - # ============================================ # CLMM Pool Listing Models # ============================================ -class TimeBasedMetrics(BaseModel): - """Time-based metrics (volume, fees, fee-to-TVL ratio) for different time periods""" - min_30: Optional[Decimal] = Field(default=None, description="30 minute metric") - hour_1: Optional[Decimal] = Field(default=None, description="1 hour metric") - hour_2: Optional[Decimal] = Field(default=None, description="2 hour metric") - hour_4: Optional[Decimal] = Field(default=None, description="4 hour metric") - hour_12: Optional[Decimal] = Field(default=None, description="12 hour metric") - hour_24: Optional[Decimal] = Field(default=None, description="24 hour metric") - class CLMMPoolListItem(BaseModel): """Individual pool item in CLMM pool listing - matches Gateway fetch-pools response""" diff --git a/routers/gateway.py b/routers/gateway.py index ec5f4419..e864efc8 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -369,9 +369,11 @@ async def list_pools_legacy( if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - # Determine chain from connector (legacy behavior) - # This is a simple mapping - in production, you'd want to look this up - chain = "solana" if connector_name in ["raydium", "meteora", "orca", "pancakeswap-sol"] else "ethereum" + # Determine chain from connector (legacy behavior). Solana routers + # (jupiter/dflow/okx/titan) must map to solana too, or this returns the + # (empty) ethereum pool list for them. + solana_connectors = {"raydium", "meteora", "orca", "pancakeswap-sol", "jupiter", "dflow", "okx", "titan"} + chain = "solana" if connector_name in solana_connectors else "ethereum" pools = check_gateway_error( await accounts_service.gateway_client.get_pools(chain, network, connector=connector_name) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 0e11a2b4..cb5a56df 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -31,9 +31,9 @@ CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, ) -from routers.gateway_extras import ExtraParamsSpec, validate_extra_params +from routers.gateway_extras import ExtraParamsSpec, get_transaction_status_from_response, validate_extra_params from services.accounts_service import AccountsService -from services.gateway_client import GatewayError, check_gateway_error +from services.gateway_client import GatewayError, check_gateway_error, get_native_gas_token logger = logging.getLogger(__name__) @@ -55,47 +55,6 @@ } -def get_transaction_status_from_response(gateway_response: dict) -> str: - """ - Determine transaction status from Gateway response: - status 1 -> CONFIRMED, negative (-1 failed, -2 dropped) -> FAILED, - 0 or missing -> SUBMITTED. - """ - status = gateway_response.get("status") - - if status == 1: - return "CONFIRMED" - # Gateway's TransactionStatus uses negative values for terminal failures - # (e.g. an EVM revert surfaced through the extended-poll path). - if isinstance(status, (int, float)) and status < 0: - return "FAILED" - return "SUBMITTED" - - -def get_native_gas_token(chain: str) -> str: - """ - Get the native gas token symbol for a blockchain. - - Args: - chain: Blockchain name (e.g., 'solana', 'ethereum', 'polygon') - - Returns: - Gas token symbol (e.g., 'SOL', 'ETH', 'MATIC') - """ - gas_token_map = { - "solana": "SOL", - "ethereum": "ETH", - "polygon": "MATIC", - "avalanche": "AVAX", - "optimism": "ETH", - "arbitrum": "ETH", - "base": "ETH", - "bsc": "BNB", - "cronos": "CRO", - } - return gas_token_map.get(chain.lower(), "UNKNOWN") - - async def _refresh_position_data(position, accounts_service: AccountsService, clmm_repo: GatewayCLMMRepository): """ Refresh position data from Gateway and update database. @@ -128,10 +87,13 @@ async def _refresh_position_data(position, accounts_service: AccountsService, cl result = pos break - # If position not found, it was closed externally + # Absent from a single positions-owned read: could be closed externally, + # could be a lagging RPC node. Closing is owned by the poller's + # consecutive-miss gate (and the zero-liquidity check below) so one + # refresh can never close a live position. if result is None: - logger.info(f"Position {position.position_address} not found on Gateway, marking as CLOSED") - await clmm_repo.close_position(position.position_address) + logger.info(f"Position {position.position_address} absent from positions-owned; " + "skipping update (poller's miss-gate owns close detection)") return except Exception as e: @@ -171,16 +133,16 @@ async def _refresh_position_data(position, accounts_service: AccountsService, cl current_price=current_price ) - # Update pending fees if available + # Always write pending fees — 0 is a real value (e.g. right after an + # external collect); the old non-zero guard left stale pendings forever. base_fee_pending = Decimal(str(result.get("baseFeeAmount", 0))) quote_fee_pending = Decimal(str(result.get("quoteFeeAmount", 0))) - if base_fee_pending or quote_fee_pending: - await clmm_repo.update_position_fees( - position_address=position.position_address, - base_fee_pending=base_fee_pending, - quote_fee_pending=quote_fee_pending - ) + await clmm_repo.update_position_fees( + position_address=position.position_address, + base_fee_pending=base_fee_pending, + quote_fee_pending=quote_fee_pending + ) logger.debug(f"Refreshed position {position.position_address}: price={current_price}, in_range={in_range}, " f"base={base_token_amount}, quote={quote_token_amount}") @@ -271,7 +233,7 @@ async def get_clmm_pools( include_unknown: Include pools with unverified tokens Example: - GET /gateway/clmm/pools?connector=meteora&query=SOL&limit=20 + GET /gateway/clmm/pools?connector=meteora&search_term=SOL&limit=20 Returns: List of available pools with trading pairs, addresses, liquidity, volume, APR, etc. @@ -544,8 +506,8 @@ async def open_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "OPEN", - "base_token_amount": float(base_amount_added) if base_amount_added else None, - "quote_token_amount": float(quote_amount_added) if quote_amount_added else None, + "base_token_amount": float(base_amount_added) if base_amount_added is not None else None, + "quote_token_amount": float(quote_amount_added) if quote_amount_added is not None else None, "gas_fee": float(gas_fee) if gas_fee else None, "gas_token": gas_token, "status": tx_status @@ -565,8 +527,8 @@ async def open_clmm_position( pool_address=request.pool_address, lower_price=request.lower_price, upper_price=request.upper_price, - base_token_amount_added=Decimal(str(base_amount_added)) if base_amount_added else None, - quote_token_amount_added=Decimal(str(quote_amount_added)) if quote_amount_added else None, + base_token_amount_added=Decimal(str(base_amount_added)) if base_amount_added is not None else None, + quote_token_amount_added=Decimal(str(quote_amount_added)) if quote_amount_added is not None else None, position_rent=Decimal(str(position_rent)) if position_rent else None, status="confirmed" ) @@ -642,7 +604,7 @@ async def add_liquidity_to_clmm_position( # Extract gas fee from Gateway response data = result.get("data", {}) gas_fee = data.get("fee") - gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None + gas_token = get_native_gas_token(chain) # Prefer the CONFIRMED on-chain amounts (data is only present when Gateway # confirmed the tx); the requested amounts are the submitted-not-confirmed @@ -666,15 +628,32 @@ async def add_liquidity_to_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "ADD_LIQUIDITY", - "base_token_amount": float(base_amount_added) if base_amount_added else None, - "quote_token_amount": float(quote_amount_added) if quote_amount_added else None, - "gas_fee": float(gas_fee) if gas_fee else None, + # `is not None`: 0 is a real amount on single-sided adds + "base_token_amount": float(base_amount_added) if base_amount_added is not None else None, + "quote_token_amount": float(quote_amount_added) if quote_amount_added is not None else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, "gas_token": gas_token, "status": tx_status } await clmm_repo.create_event(event_data) logger.info(f"Recorded CLMM ADD_LIQUIDITY event: {transaction_hash} " f"(status: {tx_status}, gas: {gas_fee} {gas_token})") + + # Added capital raises the PnL baseline. Book here only when the + # tx confirmed inline (the event is created CONFIRMED and the + # poller never re-processes it); SUBMITTED events are booked by + # the poller's confirm path. + if tx_status == "CONFIRMED": + await clmm_repo.add_to_initial_amounts( + position_address=request.position_address, + base_delta=Decimal(str(base_amount_added or 0)), + quote_delta=Decimal(str(quote_amount_added or 0)), + ) + else: + logger.warning(f"ADD_LIQUIDITY {transaction_hash} executed for position " + f"{request.position_address} with no database record — " + "no event recorded (position may be a pending open " + "not yet discovered)") except Exception as db_error: logger.error(f"Error recording ADD_LIQUIDITY event: {db_error}", exc_info=True) @@ -684,7 +663,7 @@ async def add_liquidity_to_clmm_position( "base_token_amount_added": base_amount_added, "quote_token_amount_added": quote_amount_added, "gas_fee": gas_fee, - "status": "submitted" + "status": tx_status.lower() } except HTTPException: @@ -711,7 +690,7 @@ async def remove_liquidity_from_clmm_position( connector: 'meteora' network: 'solana-mainnet-beta' position_address: '...' - percentage: 50 + percentage_to_remove: 50 slippage_pct: 1 (optional; Orca only — other connectors ignore it) wallet_address: (optional) @@ -737,7 +716,7 @@ async def remove_liquidity_from_clmm_position( chain_network=request.network, wallet_address=wallet_address, position_address=request.position_address, - percentage=float(request.percentage), + percentage_to_remove=float(request.percentage_to_remove), slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None )) @@ -751,7 +730,7 @@ async def remove_liquidity_from_clmm_position( # Extract gas fee from Gateway response data = result.get("data", {}) gas_fee = data.get("fee") - gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None + gas_token = get_native_gas_token(chain) # The CONFIRMED on-chain amounts (data is only present when Gateway confirmed # the tx). A percentage alone says nothing about what actually left the pool. @@ -775,24 +754,29 @@ async def remove_liquidity_from_clmm_position( "event_type": "REMOVE_LIQUIDITY", "base_token_amount": float(base_amount_removed) if base_amount_removed is not None else None, "quote_token_amount": float(quote_amount_removed) if quote_amount_removed is not None else None, - "gas_fee": float(gas_fee) if gas_fee else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, "gas_token": gas_token, "status": tx_status } await clmm_repo.create_event(event_data) logger.info(f"Recorded CLMM REMOVE_LIQUIDITY event: {transaction_hash} " f"(status: {tx_status}, gas: {gas_fee} {gas_token})") + else: + logger.warning(f"REMOVE_LIQUIDITY {transaction_hash} executed for position " + f"{request.position_address} with no database record — " + "no event recorded (position may be a pending open " + "not yet discovered)") except Exception as db_error: logger.error(f"Error recording REMOVE_LIQUIDITY event: {db_error}", exc_info=True) return { "transaction_hash": transaction_hash, "position_address": request.position_address, - "percentage": float(request.percentage), + "percentage_to_remove": float(request.percentage_to_remove), "base_token_amount_removed": base_amount_removed, "quote_token_amount_removed": quote_amount_removed, "gas_fee": gas_fee, - "status": "submitted" + "status": tx_status.lower() } except HTTPException: @@ -831,38 +815,23 @@ async def close_clmm_position( # Parse network_id chain, _ = accounts_service.gateway_client.parse_network_id(request.network) - # Get pool_address and wallet_address from database - pool_address = None - wallet_address = None - + # Wallet resolution: an explicit request value wins (same precedence as + # open/add/remove), then the DB row's wallet, then the default wallet. + db_wallet = None async with db_manager.get_session_context() as session: clmm_repo = GatewayCLMMRepository(session) db_position = await clmm_repo.get_position_by_address(request.position_address) if db_position: - pool_address = db_position.pool_address - wallet_address = db_position.wallet_address - - # If not in database, use default wallet - if not wallet_address: - wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( - chain=chain, - wallet_address=request.wallet_address - ) - - # Positions this API never recorded (an lp_executor opens straight against Gateway) have no - # row to read the pool from, so accept it on the request. Gateway's close needs only - # position_address - pool_address is for the pre-close fee snapshot below. - pool_address = pool_address or request.pool_address + db_wallet = db_position.wallet_address - if not pool_address: - raise HTTPException( - status_code=400, - detail=( - f"Position {request.position_address} is not in the database, so its pool is unknown. " - "Pass pool_address explicitly - LP-executor positions are never recorded here, and " - "/executors/positions/orphaned reports the pool for each orphan." - ) - ) + wallet_address = request.wallet_address or db_wallet + wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( + chain=chain, + wallet_address=wallet_address + ) + # Note: neither Gateway's close nor the pre-close snapshot (positions_owned) + # needs the pool — unrecorded positions (e.g. lp_executor opens) close fine + # without one, so pool_address is informational only. # Fetch pending fees and current price BEFORE closing (Gateway doesn't always return these in response) base_fee_to_collect = Decimal("0") @@ -956,70 +925,86 @@ async def close_clmm_position( logger.info(f"Recorded CLMM CLOSE event: {transaction_hash} " f"(status: {tx_status}, gas: {gas_fee} {gas_token})") - # Update position: add to collected, reset pending to 0, mark as CLOSED - new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected - new_quote_collected = Decimal(str(position.quote_fee_collected)) + quote_fee_collected - - await clmm_repo.update_position_fees( - position_address=request.position_address, - base_fee_collected=new_base_collected, - quote_fee_collected=new_quote_collected, - base_fee_pending=Decimal("0"), - quote_fee_pending=Decimal("0") - ) - - # Update current_price with close price - if close_price: - await clmm_repo.update_position_liquidity( + # Position bookkeeping happens exactly once, when the tx is known + # good: CONFIRMED here (the event is created CONFIRMED, so the + # poller never touches it), or in the poller's confirm path for + # SUBMITTED events. A FAILED tx mutates nothing — the old + # unconditional booking permanently inflated *_fee_collected on + # failed closes. + if tx_status == "CONFIRMED": + new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected + new_quote_collected = Decimal(str(position.quote_fee_collected)) + quote_fee_collected + + await clmm_repo.update_position_fees( position_address=request.position_address, - base_token_amount=Decimal(str(position.base_token_amount)), - quote_token_amount=Decimal(str(position.quote_token_amount)), - current_price=Decimal(str(close_price)) - ) - - # Verify position is actually closed by checking if it still exists on Gateway - # Gateway returns 500 (or 404) when position doesn't exist - try: - await asyncio.sleep(2) # Wait for transaction to propagate - - verify_result = await accounts_service.gateway_client.clmm_position_info( - connector=request.connector, - chain_network=request.network, - position_address=request.position_address + base_fee_collected=new_base_collected, + quote_fee_collected=new_quote_collected, + base_fee_pending=Decimal("0"), + quote_fee_pending=Decimal("0") ) - # If we get an error response (404 or 500), position is closed - if verify_result and isinstance(verify_result, dict) and "error" in verify_result: - status_code = verify_result.get("status") - if status_code in (404, 500): - await clmm_repo.close_position(request.position_address) - logger.info(f"Position {request.position_address} verified as closed " - f"(Gateway returned {status_code})") + # Update current_price with close price + if close_price: + await clmm_repo.update_position_liquidity( + position_address=request.position_address, + base_token_amount=Decimal(str(position.base_token_amount)), + quote_token_amount=Decimal(str(position.quote_token_amount)), + current_price=Decimal(str(close_price)) + ) + + # Verify position is actually gone on Gateway before marking + # CLOSED (some connectors 500 instead of 404 for a + # nonexistent position — right after our own close, either + # means gone). + try: + await asyncio.sleep(2) # Wait for transaction to propagate + + verify_result = await accounts_service.gateway_client.clmm_position_info( + connector=request.connector, + chain_network=request.network, + position_address=request.position_address + ) + + if verify_result and isinstance(verify_result, dict) and "error" in verify_result: + status_code = verify_result.get("status") + if status_code in (404, 500): + await clmm_repo.close_position(request.position_address) + logger.info(f"Position {request.position_address} verified as closed " + f"(Gateway returned {status_code})") + else: + logger.warning(f"Unexpected error verifying position close: {verify_result}") + elif verify_result and "address" in verify_result: + # Position still exists - might be a failed close or delayed propagation + logger.warning(f"Position {request.position_address} still exists after close " + "transaction. Will be handled by poller.") else: - logger.warning(f"Unexpected error verifying position close: {verify_result}") - elif verify_result and "address" in verify_result: - # Position still exists - might be a failed close or delayed propagation - logger.warning(f"Position {request.position_address} still exists after close " - "transaction. Will be handled by poller.") - else: - logger.debug("Could not verify position close status, will be handled by poller") - - except Exception as verify_error: - logger.warning(f"Error verifying position close: {verify_error}. Will be handled by poller.") - - logger.info(f"Updated position {request.position_address}: collected fees updated, pending fees reset to 0.") + logger.debug("Could not verify position close status, will be handled by poller") + + except Exception as verify_error: + logger.warning(f"Error verifying position close: {verify_error}. Will be handled by poller.") + + logger.info(f"Updated position {request.position_address}: " + "collected fees updated, pending fees reset to 0.") + else: + # H8 window: a close on a position hapi has no row for (e.g. a + # pending open awaiting the discovery sweep) leaves no event — + # say so loudly instead of silently skipping. + logger.warning(f"CLOSE {transaction_hash} executed for position " + f"{request.position_address} with no database record — " + "no CLOSE event recorded (position may be a pending open " + "not yet discovered)") except Exception as db_error: logger.error(f"Error recording CLOSE event: {db_error}", exc_info=True) return CLMMClosePositionResponse( transaction_hash=transaction_hash, position_address=request.position_address, - base_fee_collected=Decimal(str(base_fee_collected)) if base_fee_collected else None, - quote_fee_collected=Decimal(str(quote_fee_collected)) if quote_fee_collected else None, + base_fee_collected=Decimal(str(base_fee_collected)) if base_fee_collected is not None else None, + quote_fee_collected=Decimal(str(quote_fee_collected)) if quote_fee_collected is not None else None, base_token_amount_removed=Decimal(str(base_amount_removed)) if base_amount_removed is not None else None, quote_token_amount_removed=Decimal(str(quote_amount_removed)) if quote_amount_removed is not None else None, position_rent_refunded=Decimal(str(position_rent_refunded)) if position_rent_refunded is not None else None, - status="submitted" + status=tx_status.lower() ) except HTTPException: @@ -1058,37 +1043,23 @@ async def collect_fees_from_clmm_position( # Parse network_id chain, _ = accounts_service.gateway_client.parse_network_id(request.network) - # Get pool_address and wallet_address from database - pool_address = None - wallet_address = None - + # Wallet resolution: an explicit request value wins (same precedence as + # open/add/remove), then the DB row's wallet, then the default wallet. + db_wallet = None async with db_manager.get_session_context() as session: clmm_repo = GatewayCLMMRepository(session) db_position = await clmm_repo.get_position_by_address(request.position_address) if db_position: - pool_address = db_position.pool_address - wallet_address = db_position.wallet_address - - # If not in database, use default wallet - if not wallet_address: - wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( - chain=chain, - wallet_address=request.wallet_address - ) + db_wallet = db_position.wallet_address - # Positions this API never recorded (an lp_executor opens straight against Gateway) have no - # row to read the pool from, so accept it on the request. - pool_address = pool_address or request.pool_address - - if not pool_address: - raise HTTPException( - status_code=400, - detail=( - f"Position {request.position_address} is not in the database, so its pool is unknown. " - "Pass pool_address explicitly - LP-executor positions are never recorded here, and " - "/executors/positions/orphaned reports the pool for each orphan." - ) - ) + wallet_address = request.wallet_address or db_wallet + wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( + chain=chain, + wallet_address=wallet_address + ) + # Note: neither Gateway's collect nor the fee snapshot (positions_owned) needs + # the pool — unrecorded positions work without one; pool_address is + # informational only. # Fetch pending fees BEFORE collecting (Gateway doesn't always return collected amounts in response) base_fee_to_collect = Decimal("0") @@ -1168,27 +1139,37 @@ async def collect_fees_from_clmm_position( logger.info(f"Recorded CLMM COLLECT_FEES event: {transaction_hash} " f"(status: {tx_status}, gas: {gas_fee} {gas_token})") - # Update position: add to collected, reset pending to 0 - new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected - new_quote_collected = Decimal(str(position.quote_fee_collected)) + quote_fee_collected - - await clmm_repo.update_position_fees( - position_address=request.position_address, - base_fee_collected=new_base_collected, - quote_fee_collected=new_quote_collected, - base_fee_pending=Decimal("0"), - quote_fee_pending=Decimal("0") - ) - logger.info(f"Updated position {request.position_address}: collected fees updated, pending fees reset to 0") + # Book fees exactly once: CONFIRMED here (event created CONFIRMED, + # never re-processed), SUBMITTED in the poller's confirm path. + # The old unconditional booking double-counted every pending + # collect (endpoint + poller) and kept phantom fees on failures. + if tx_status == "CONFIRMED": + new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected + new_quote_collected = Decimal(str(position.quote_fee_collected)) + quote_fee_collected + + await clmm_repo.update_position_fees( + position_address=request.position_address, + base_fee_collected=new_base_collected, + quote_fee_collected=new_quote_collected, + base_fee_pending=Decimal("0"), + quote_fee_pending=Decimal("0") + ) + logger.info(f"Updated position {request.position_address}: " + "collected fees updated, pending fees reset to 0") + else: + logger.warning(f"COLLECT_FEES {transaction_hash} executed for position " + f"{request.position_address} with no database record — " + "no event recorded (position may be a pending open " + "not yet discovered)") except Exception as db_error: logger.error(f"Error recording COLLECT_FEES event: {db_error}", exc_info=True) return CLMMCollectFeesResponse( transaction_hash=transaction_hash, position_address=request.position_address, - base_fee_collected=Decimal(str(base_fee_collected)) if base_fee_collected else None, - quote_fee_collected=Decimal(str(quote_fee_collected)) if quote_fee_collected else None, - status="submitted" + base_fee_collected=Decimal(str(base_fee_collected)) if base_fee_collected is not None else None, + quote_fee_collected=Decimal(str(quote_fee_collected)) if quote_fee_collected is not None else None, + status=tx_status.lower() ) except HTTPException: @@ -1404,7 +1385,10 @@ async def get_clmm_position_info( chain_network=network, position_address=position_address ) - if isinstance(pos, dict) and "error" in pos: + if pos is None or not isinstance(pos, dict): + # Connection error: the client returns None — a 503, not a crash. + raise HTTPException(status_code=503, detail="Gateway service is not available") + if "error" in pos: status_code = pos.get("status") if status_code in (404, 500): raise HTTPException(status_code=404, detail=f"Position {position_address} not found or closed") @@ -1431,8 +1415,8 @@ async def get_clmm_position_info( current_price=current_price, lower_price=lower_price, upper_price=upper_price, - base_fee_amount=Decimal(str(pos.get("baseFeeAmount", 0))) if pos.get("baseFeeAmount") else None, - quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, + base_fee_amount=Decimal(str(pos["baseFeeAmount"])) if pos.get("baseFeeAmount") is not None else None, + quote_fee_amount=Decimal(str(pos["quoteFeeAmount"])) if pos.get("quoteFeeAmount") is not None else None, lower_bin_id=pos.get("lowerBinId"), upper_bin_id=pos.get("upperBinId"), in_range=in_range @@ -1459,7 +1443,9 @@ async def get_clmm_position_events( Args: position_address: Position NFT address - event_type: Filter by event type (OPEN, ADD_LIQUIDITY, REMOVE_LIQUIDITY, COLLECT_FEES, CLOSE) + event_type: Filter by event type (OPEN, ADD_LIQUIDITY, REMOVE_LIQUIDITY, COLLECT_FEES, CLOSE, + DISCOVERED — written by the poller for positions it found on-chain, with a + synthetic discovered_ transaction hash) limit: Max events to return Returns: @@ -1505,7 +1491,7 @@ async def search_clmm_positions( network: Filter by network (e.g., 'solana-mainnet-beta') connector: Filter by connector (e.g., 'meteora') wallet_address: Filter by wallet address - trading_pair: Filter by trading pair (e.g., 'SOL-USDC') + trading_pair: Filter by trading pair (address-derived identifiers as stored; symbol pairs like 'SOL-USDC' will not match) status: Filter by status (OPEN, CLOSED) position_addresses: Filter by specific position addresses (list of addresses) limit: Max results (default 50, max 1000) diff --git a/routers/gateway_extras.py b/routers/gateway_extras.py index 94148d42..f8ac83de 100644 --- a/routers/gateway_extras.py +++ b/routers/gateway_extras.py @@ -1,6 +1,6 @@ """ -Shared validation for connector-specific extra_params forwarded to Gateway's -unified /trading routes. +Shared helpers for the gateway trading routers: extra_params validation and +Gateway write-response status mapping. Gateway destructures a fixed set of connector-specific keys from each request body and silently ignores everything else, so hapi rejects loudly instead of @@ -11,7 +11,32 @@ from fastapi import HTTPException + +def get_transaction_status_from_response(gateway_response: dict) -> str: + """Map a Gateway write response's status to hapi's transaction vocabulary: + status 1 -> CONFIRMED, negative (-1 failed, -2 dropped) -> FAILED, + 0 or missing -> SUBMITTED. + + Single source: both the swap and CLMM routers import this — two drifting + copies of load-bearing status mapping is how a failed tx gets recorded as + submitted on one surface but not the other. + """ + status = gateway_response.get("status") + + if status == 1: + return "CONFIRMED" + # Gateway's TransactionStatus uses negative values for terminal failures + # (e.g. a failed EVM swap returns status -1 with zeroed amounts). + if isinstance(status, (int, float)) and status < 0: + return "FAILED" + return "SUBMITTED" + + # Spec entry: key -> (allowed value types, connectors that honor the key). +# Deliberate strictness (accepted residual): numeric keys are typed int even though +# Gateway's TypeBox says Number (JS has one number type) — the domains are integral +# (enum values, bin steps, config indexes), so a float like 1.0 gets a loud 400 +# here rather than reaching Gateway; no semantically distinct value is blocked. ExtraParamsSpec = Dict[str, Tuple[Tuple[type, ...], Set[str]]] diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 6df96c4c..8c4dadfd 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -14,7 +14,7 @@ from database.repositories import GatewaySwapRepository from deps import get_accounts_service, get_database_manager from models import SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, SwapQuoteResponse -from routers.gateway_extras import ExtraParamsSpec, validate_extra_params +from routers.gateway_extras import ExtraParamsSpec, get_transaction_status_from_response, validate_extra_params from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error @@ -23,23 +23,6 @@ router = APIRouter(tags=["Gateway Swaps"], prefix="/gateway") -def get_transaction_status_from_response(gateway_response: dict) -> str: - """ - Determine transaction status from Gateway response: - status 1 -> CONFIRMED, negative (-1 failed, -2 dropped) -> FAILED, - 0 or missing -> SUBMITTED. - """ - status = gateway_response.get("status") - - if status == 1: - return "CONFIRMED" - # Gateway's TransactionStatus uses negative values for terminal failures - # (e.g. a failed EVM swap returns status -1 with zeroed amounts). - if isinstance(status, (int, float)) and status < 0: - return "FAILED" - return "SUBMITTED" - - # Gateway's unified /trading/swap routes pass approximateIfNoExactOut only to the # Solana router connectors' quote path; every other provider silently ignores it. SWAP_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { diff --git a/services/gateway_client.py b/services/gateway_client.py index 5e0eb8e2..d5547b3f 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -10,6 +10,26 @@ # All other connectors default to their CLMM route (meteora, orca, raydium, pancakeswap-sol). ROUTER_CONNECTORS = {"jupiter", "0x", "uniswap", "pancakeswap", "dflow", "okx", "titan"} +# The single source for chain -> native gas token. Every writer of gas_token +# columns (routers and the transaction poller) must use this — drifted local +# copies previously produced "MATIC", None, and "UNKNOWN" for the same chain. +_NATIVE_GAS_TOKENS = { + "solana": "SOL", + "ethereum": "ETH", + "polygon": "MATIC", + "avalanche": "AVAX", + "optimism": "ETH", + "arbitrum": "ETH", + "base": "ETH", + "bsc": "BNB", + "cronos": "CRO", +} + + +def get_native_gas_token(chain: str) -> str: + """Native gas token symbol for a chain (e.g. 'solana' -> 'SOL').""" + return _NATIVE_GAS_TOKENS.get(chain.lower(), "UNKNOWN") + class GatewayError(Exception): """A Gateway HTTP request that completed with a non-OK status.""" @@ -87,8 +107,10 @@ async def get_wallet_address_or_default(self, chain: str, wallet_address: Option default_wallet = await self.get_default_wallet_address(chain) if not default_wallet: raise ValueError(f"No wallet configured for chain '{chain}'") - # Skip placeholder wallet addresses (e.g., "ethereum-default-wallet", "solana-default-wallet") - if default_wallet.endswith("-default-wallet"): + # Gateway's fresh config templates write "" / + # "" as the placeholder — passing that literal + # string on would surface as an opaque Gateway address-validation error. + if default_wallet.startswith("<") and default_wallet.endswith(">"): raise ValueError(f"No valid wallet configured for chain '{chain}' (found placeholder: {default_wallet})") return default_wallet @@ -195,13 +217,18 @@ async def get_wallets(self) -> List[Dict]: return await self._request("GET", "wallet") async def get_default_wallet_address(self, chain: str) -> Optional[str]: - """Get default wallet address for a chain from Gateway config""" - try: - config = await self._request("GET", "config", params={"namespace": chain}) - return config.get("defaultWallet") - except Exception as e: - logger.error(f"Error getting default wallet for chain {chain}: {e}") - return None + """Get default wallet address for a chain from Gateway config. + + Raises GatewayError(503) when Gateway is unreachable — an unreachable + Gateway must not masquerade as "no wallet configured" (a 400 that sends + the operator chasing the wrong problem). + """ + config = await self._request("GET", "config", params={"namespace": chain}) + if config is None: + raise GatewayError("Gateway service is not available", status=503) + if isinstance(config, dict) and set(config.keys()) == {"error", "status"}: + raise GatewayError(str(config["error"]), status=config.get("status", 502)) + return config.get("defaultWallet") async def get_all_wallet_addresses(self, chain: Optional[str] = None) -> Dict[str, List[str]]: """ @@ -226,7 +253,9 @@ async def get_all_wallet_addresses(self, chain: Optional[str] = None) -> Dict[st if chain and wallet_chain != chain: continue - addresses = wallet.get("walletAddresses", []) + # Hardware (Ledger) wallets live in a separate list — without them + # the discovery/balance sweeps never see hardware-held positions. + addresses = list(wallet.get("walletAddresses", [])) + list(wallet.get("hardwareWalletAddresses", [])) if addresses and wallet_chain: result[wallet_chain] = addresses @@ -596,7 +625,7 @@ async def clmm_remove_liquidity( chain_network: str, wallet_address: str, position_address: str, - percentage: float, + percentage_to_remove: float, slippage_pct: Optional[float] = None ) -> Dict: """Remove liquidity from a CLMM position (partial). @@ -608,7 +637,7 @@ async def clmm_remove_liquidity( "chainNetwork": chain_network, "walletAddress": wallet_address, "positionAddress": position_address, - "percentageToRemove": percentage + "percentageToRemove": percentage_to_remove } if slippage_pct is not None: payload["slippagePct"] = slippage_pct diff --git a/services/gateway_transaction_poller.py b/services/gateway_transaction_poller.py index 0bf5902f..4589203c 100644 --- a/services/gateway_transaction_poller.py +++ b/services/gateway_transaction_poller.py @@ -15,7 +15,7 @@ from database import AsyncDatabaseManager from database.models import GatewayCLMMPosition from database.repositories import GatewayCLMMRepository, GatewaySwapRepository -from services.gateway_client import GatewayClient +from services.gateway_client import GatewayClient, get_native_gas_token logger = logging.getLogger(__name__) @@ -48,6 +48,10 @@ def __init__( self._poll_task: Optional[asyncio.Task] = None self._position_poll_task: Optional[asyncio.Task] = None self._last_position_poll: Optional[datetime] = None + # Consecutive position-info misses per position. A single 404/500 can be a + # transient RPC problem (Gateway 500s on upstream hiccups), so a position is + # only marked CLOSED after MISSING_STRIKES_TO_CLOSE consecutive misses. + self._position_missing_strikes: Dict[str, int] = {} async def start(self): """Start the polling service.""" @@ -99,9 +103,28 @@ async def _poll_loop(self): except asyncio.CancelledError: break + # A tx reported NOT_FOUND (-2) is only terminal once its blockhash can no longer + # be valid (~90s on Solana); before that, -2 can just mean "not visible yet". + DROPPED_GRACE_SECONDS = 180 + + # Consecutive position-info misses before a position is marked CLOSED + # (mirrors the lp_executor's external-close gate). + MISSING_STRIKES_TO_CLOSE = 3 + + # Don't reopen a DB-CLOSED position seen in positions-owned if it was closed + # within this window: the listing RPC node may simply lag the close. + REOPEN_GRACE_SECONDS = 300 + async def _poll_pending_transactions(self): """Poll all pending transactions and update their status.""" try: + # One availability gate per cycle. When Gateway is unreachable nothing is + # polled AND nothing is aged out: the age timeout must never fire on a + # transaction we could not actually check (it may have confirmed on-chain). + if not await self.gateway_client.ping(): + logger.warning("Gateway not available; skipping transaction poll cycle") + return + async with self.db_manager.get_session_context() as session: swap_repo = GatewaySwapRepository(session) clmm_repo = GatewayCLMMRepository(session) @@ -111,18 +134,6 @@ async def _poll_pending_transactions(self): logger.debug(f"Found {len(pending_swaps)} pending swaps") for swap in pending_swaps: - # Skip if too old (likely failed without proper error) - age = (datetime.now(timezone.utc) - swap.timestamp).total_seconds() - if age > self.max_retry_age: - logger.warning(f"Swap {swap.transaction_hash} exceeded max retry age, marking as FAILED") - await swap_repo.update_swap_status( - transaction_hash=swap.transaction_hash, - status="FAILED", - error_message="Transaction confirmation timeout" - ) - continue - - # Poll transaction status await self._poll_swap_transaction(swap, swap_repo) # Get pending CLMM events @@ -130,18 +141,6 @@ async def _poll_pending_transactions(self): logger.debug(f"Found {len(pending_events)} pending CLMM events") for event in pending_events: - # Skip if too old - age = (datetime.now(timezone.utc) - event.timestamp).total_seconds() - if age > self.max_retry_age: - logger.warning(f"CLMM event {event.transaction_hash} exceeded max retry age, marking as FAILED") - await clmm_repo.update_event_status( - transaction_hash=event.transaction_hash, - status="FAILED", - error_message="Transaction confirmation timeout" - ) - continue - - # Poll transaction status await self._poll_clmm_event_transaction(event, clmm_repo) except Exception as e: @@ -158,31 +157,59 @@ async def _poll_swap_transaction(self, swap, swap_repo: GatewaySwapRepository): chain, network = parts - # Check transaction status on Gateway/blockchain - # Note: This is a placeholder - actual implementation depends on Gateway API status_result = await self._check_transaction_status( chain=chain, network=network, tx_hash=swap.transaction_hash ) + if status_result is None: + # Transient (Gateway/RPC hiccup): no information, no state change. + return - if status_result: - if status_result["status"] == "CONFIRMED": - logger.info(f"Swap transaction confirmed: {swap.transaction_hash}") - await swap_repo.update_swap_status( - transaction_hash=swap.transaction_hash, - status="CONFIRMED", - gas_fee=Decimal(str(status_result.get("gas_fee", 0))) if status_result.get("gas_fee") else None, - gas_token=status_result.get("gas_token") - ) - elif status_result["status"] == "FAILED": - logger.warning(f"Swap transaction failed: {swap.transaction_hash}") - await swap_repo.update_swap_status( - transaction_hash=swap.transaction_hash, - status="FAILED", - error_message=status_result.get("error_message", "Transaction failed on-chain") - ) - # If status is still pending, do nothing and retry later + age = (datetime.now(timezone.utc) - swap.timestamp).total_seconds() + status = status_result["status"] + gas_fee_raw = status_result.get("gas_fee") + gas_fee = Decimal(str(gas_fee_raw)) if gas_fee_raw is not None else None + + if status == "CONFIRMED": + # Accepted residual: amounts/price are NOT backfilled from the poll's + # txData — a swap recorded while pending keeps its request-side leg + # and 0 placeholders after confirmation. Backfilling requires parsing + # balance changes from txData (deferred by design). + logger.info(f"Swap transaction confirmed: {swap.transaction_hash}") + await swap_repo.update_swap_status( + transaction_hash=swap.transaction_hash, + status="CONFIRMED", + gas_fee=gas_fee, + gas_token=status_result.get("gas_token") + ) + elif status == "FAILED": + # A landed-but-failed tx still paid gas — record it. + logger.warning(f"Swap transaction failed: {swap.transaction_hash}") + await swap_repo.update_swap_status( + transaction_hash=swap.transaction_hash, + status="FAILED", + error_message=status_result.get("error_message", "Transaction failed on-chain"), + gas_fee=gas_fee, + gas_token=status_result.get("gas_token") + ) + elif status == "DROPPED" and age > self.DROPPED_GRACE_SECONDS: + logger.warning(f"Swap transaction dropped (not found on-chain): {swap.transaction_hash}") + await swap_repo.update_swap_status( + transaction_hash=swap.transaction_hash, + status="FAILED", + error_message="Transaction not found on-chain (dropped after blockhash expiry)" + ) + elif status == "PENDING" and age > self.max_retry_age: + # Genuinely still unconfirmed after a successful poll — only now may + # the age timeout fire. + logger.warning(f"Swap {swap.transaction_hash} exceeded max retry age, marking as FAILED") + await swap_repo.update_swap_status( + transaction_hash=swap.transaction_hash, + status="FAILED", + error_message="Transaction confirmation timeout" + ) + # PENDING within age / DROPPED within grace: retry next cycle. except Exception as e: logger.error(f"Error polling swap transaction {swap.transaction_hash}: {e}") @@ -205,33 +232,54 @@ async def _poll_clmm_event_transaction(self, event, clmm_repo: GatewayCLMMReposi chain, network = parts - # Check transaction status status_result = await self._check_transaction_status( chain=chain, network=network, tx_hash=event.transaction_hash ) + if status_result is None: + # Transient (Gateway/RPC hiccup): no information, no state change. + return - if status_result: - if status_result["status"] == "CONFIRMED": - logger.info(f"CLMM event transaction confirmed: {event.transaction_hash}") - await clmm_repo.update_event_status( - transaction_hash=event.transaction_hash, - status="CONFIRMED", - gas_fee=Decimal(str(status_result.get("gas_fee", 0))) if status_result.get("gas_fee") else None, - gas_token=status_result.get("gas_token") - ) - - # Update position state based on event type - await self._update_position_from_event(event, clmm_repo) - - elif status_result["status"] == "FAILED": - logger.warning(f"CLMM event transaction failed: {event.transaction_hash}") - await clmm_repo.update_event_status( - transaction_hash=event.transaction_hash, - status="FAILED", - error_message=status_result.get("error_message", "Transaction failed on-chain") - ) + age = (datetime.now(timezone.utc) - event.timestamp).total_seconds() + status = status_result["status"] + gas_fee_raw = status_result.get("gas_fee") + gas_fee = Decimal(str(gas_fee_raw)) if gas_fee_raw is not None else None + + if status == "CONFIRMED": + logger.info(f"CLMM event transaction confirmed: {event.transaction_hash}") + await clmm_repo.update_event_status( + transaction_hash=event.transaction_hash, + status="CONFIRMED", + gas_fee=gas_fee, + gas_token=status_result.get("gas_token") + ) + # Update position state based on event type + await self._update_position_from_event(event, clmm_repo) + elif status == "FAILED": + logger.warning(f"CLMM event transaction failed: {event.transaction_hash}") + await clmm_repo.update_event_status( + transaction_hash=event.transaction_hash, + status="FAILED", + error_message=status_result.get("error_message", "Transaction failed on-chain"), + gas_fee=gas_fee, + gas_token=status_result.get("gas_token") + ) + elif status == "DROPPED" and age > self.DROPPED_GRACE_SECONDS: + logger.warning(f"CLMM event transaction dropped (not found on-chain): {event.transaction_hash}") + await clmm_repo.update_event_status( + transaction_hash=event.transaction_hash, + status="FAILED", + error_message="Transaction not found on-chain (dropped after blockhash expiry)" + ) + elif status == "PENDING" and age > self.max_retry_age: + logger.warning(f"CLMM event {event.transaction_hash} exceeded max retry age, marking as FAILED") + await clmm_repo.update_event_status( + transaction_hash=event.transaction_hash, + status="FAILED", + error_message="Transaction confirmation timeout" + ) + # PENDING within age / DROPPED within grace: retry next cycle. except Exception as e: logger.error(f"Error polling CLMM event transaction {event.transaction_hash}: {e}") @@ -247,10 +295,37 @@ async def _update_position_from_event(self, event, clmm_repo: GatewayCLMMReposit return if event.event_type == "CLOSE": + # Fee booking happens exactly once, on confirmation: the endpoints + # only mutate the position when Gateway confirmed the tx inline, and + # leave submitted-not-confirmed booking to this path. + if event.base_fee_collected is not None or event.quote_fee_collected is not None: + new_base = float(position.base_fee_collected or 0) + float(event.base_fee_collected or 0) + new_quote = float(position.quote_fee_collected or 0) + float(event.quote_fee_collected or 0) + await clmm_repo.update_position_fees( + position_address=position.position_address, + base_fee_collected=Decimal(str(new_base)), + quote_fee_collected=Decimal(str(new_quote)), + base_fee_pending=Decimal("0"), + quote_fee_pending=Decimal("0") + ) await clmm_repo.close_position(position.position_address) + elif event.event_type == "ADD_LIQUIDITY": + # Added capital raises the PnL baseline. Event amounts may be the + # requested figures (recorded at submit time) rather than on-chain + # actuals — the accepted residual is that pending-tx amounts are not + # backfilled from txData; requested amounts are the best available. + if event.base_token_amount or event.quote_token_amount: + await clmm_repo.add_to_initial_amounts( + position_address=position.position_address, + base_delta=Decimal(str(event.base_token_amount or 0)), + quote_delta=Decimal(str(event.quote_token_amount or 0)), + ) + elif event.event_type == "COLLECT_FEES": - # Add collected fees to cumulative total + # Add collected fees to cumulative total (endpoints book inline only + # for txs Gateway confirmed at submit time — those events are created + # CONFIRMED and never reach this path, so there is no double count). if event.base_fee_collected or event.quote_fee_collected: new_base_collected = float(position.base_fee_collected or 0) + float(event.base_fee_collected or 0) new_quote_collected = float(position.quote_fee_collected or 0) + float(event.quote_fee_collected or 0) @@ -276,16 +351,14 @@ async def _check_transaction_status( Check transaction status on blockchain via Gateway. Returns: - Dict with status, gas_fee, gas_token, and error_message if available. - None if transaction not yet confirmed or pending. + Dict with status ("CONFIRMED" | "FAILED" | "DROPPED" | "PENDING"), + gas_fee, gas_token, and error_message. + None only when no information could be obtained (Gateway/RPC hiccup) — + callers must treat that as "no state change", never as pending-with-age. """ try: - # Check if Gateway is available - if not await self.gateway_client.ping(): - logger.warning("Gateway not available for transaction polling") - return None - # Reconstruct network_id from chain and network + # (Gateway availability is gated once per cycle by the caller.) network_id = f"{chain}-{network}" # Poll transaction status from Gateway @@ -310,71 +383,62 @@ async def _check_transaction_status( logger.debug(f"Polled transaction {tx_hash} on {network_id}: txStatus={result.get('txStatus')}") - # Parse the response with defensive checks + # Classify on txStatus ALONE. Gateway deliberately returns txStatus 0 + # (pending) WITH a non-null `error` for transient poll failures — "the + # caller should poll again, not give up" — so the error field must never + # promote a pending transaction to FAILED. tx_status = result.get("txStatus") + gas_token = get_native_gas_token(chain) + gas_fee = result.get("fee") - # Determine gas token based on chain - gas_token = { - "solana": "SOL", - "ethereum": "ETH", - "arbitrum": "ETH", - "optimism": "ETH", - "polygon": "MATIC", - "avalanche": "AVAX" - }.get(chain, "UNKNOWN") - - # Transaction is confirmed if txStatus == 1 if tx_status == 1: return { "status": "CONFIRMED", - "gas_fee": result.get("fee", 0), + "gas_fee": gas_fee, "gas_token": gas_token, "error_message": None } - # Transaction failed if txStatus == -1 or there's an error field - # Gateway now returns parsed error messages like "SLIPPAGE_EXCEEDED (0x1771): ..." - error_msg = result.get("error") - if tx_status == -1 or error_msg: + if tx_status == -1: + # Landed on-chain but failed. Gateway returns parsed error messages + # like "SLIPPAGE_EXCEEDED (0x1771): ..."; fall back to meta.err. + error_msg = result.get("error") if not error_msg: - # Fallback to meta.err if no parsed error tx_data = result.get("txData") or {} meta = tx_data.get("meta") if isinstance(tx_data, dict) else {} raw_error = meta.get("err") if isinstance(meta, dict) else None error_msg = str(raw_error) if raw_error else "Transaction failed on-chain" return { "status": "FAILED", - "gas_fee": result.get("fee", 0), + "gas_fee": gas_fee, "gas_token": gas_token, "error_message": error_msg } - # Transaction still pending (txStatus == 0 or not finalized) - return None + if tx_status == -2: + # NOT_FOUND — terminal on Solana once the blockhash expires; can also + # appear briefly right after submission. The caller applies the grace + # window before treating it as dropped. + return { + "status": "DROPPED", + "gas_fee": None, + "gas_token": gas_token, + "error_message": "Transaction not found on-chain" + } + + # txStatus 0 (or anything unrecognized): a successful poll that says the + # transaction is still unconfirmed — distinct from None (no information). + return { + "status": "PENDING", + "gas_fee": None, + "gas_token": gas_token, + "error_message": None + } except Exception as e: logger.error(f"Error checking transaction status for {tx_hash}: {e}") return None - async def poll_transaction_once(self, tx_hash: str, network_id: str) -> Optional[Dict]: - """ - Poll a specific transaction once (useful for immediate status checks). - - Args: - tx_hash: Transaction hash - network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta') - - Returns: - Transaction status dict or None if pending - """ - parts = network_id.split('-', 1) - if len(parts) != 2: - logger.error(f"Invalid network format: {network_id}") - return None - - chain, network = parts - return await self._check_transaction_status(chain, network, tx_hash) - # ============================================ # Position State Polling & Discovery # ============================================ @@ -469,6 +533,8 @@ async def _discover_positions_from_gateway(self) -> int: open_positions = await clmm_repo.get_position_addresses_set(status="OPEN") # Get CLOSED positions (to potentially reopen if still on-chain) closed_positions = await clmm_repo.get_position_addresses_set(status="CLOSED") + # Positions closed moments ago are exempt from reopening (lag guard) + recently_closed = await clmm_repo.get_recently_closed_addresses(self.REOPEN_GRACE_SECONDS) # Poll each supported connector/chain/wallet combination for config in self.SUPPORTED_CLMM_CONFIGS: @@ -506,6 +572,13 @@ async def _discover_positions_from_gateway(self) -> int: # Check if position was incorrectly marked as CLOSED if position_address in closed_positions: + if position_address in recently_closed: + # Just closed — the positions-owned RPC node may + # lag the close confirmation; reopening now would + # flap the record CLOSED -> OPEN -> CLOSED. + logger.debug(f"Position {position_address} closed recently; " + "skipping reopen (listing may lag the close)") + continue # Position exists on-chain but is CLOSED in DB → reopen it async with self.db_manager.get_session_context() as session: clmm_repo = GatewayCLMMRepository(session) @@ -684,11 +757,6 @@ async def _update_all_open_positions(self): except Exception as e: logger.error(f"Error updating open positions: {e}", exc_info=True) - # Legacy method name for backwards compatibility - async def _poll_open_positions(self): - """Poll all open CLMM positions and update their state. (Legacy wrapper)""" - await self._poll_and_discover_positions() - async def _refresh_position_state(self, position: GatewayCLMMPosition, clmm_repo: GatewayCLMMRepository): """ Refresh a single position's state from Gateway. @@ -735,12 +803,23 @@ async def _refresh_position_state(self, position: GatewayCLMMPosition, clmm_repo if "error" in result: status_code = result.get("status") - # Gateway returns 500 instead of 404 when position doesn't exist (closed) - # Treat any error (404 or 500) on position-info as "position closed" + # Some connectors 500 instead of 404 for a nonexistent position, + # but Gateway ALSO 500s on transient RPC problems — so a miss only + # counts as a strike, and the position closes after + # MISSING_STRIKES_TO_CLOSE consecutive misses, never on one. if status_code in (404, 500): - logger.info(f"Position {position.position_address} not found on Gateway " - f"(status: {status_code}), marking as CLOSED") - await clmm_repo.close_position(position.position_address) + strikes = self._position_missing_strikes.get(position.position_address, 0) + 1 + self._position_missing_strikes[position.position_address] = strikes + if strikes >= self.MISSING_STRIKES_TO_CLOSE: + logger.info(f"Position {position.position_address} missing from Gateway " + f"{strikes} consecutive times (last status: {status_code}), " + "marking as CLOSED") + await clmm_repo.close_position(position.position_address) + self._position_missing_strikes.pop(position.position_address, None) + else: + logger.debug(f"Position {position.position_address} miss " + f"{strikes}/{self.MISSING_STRIKES_TO_CLOSE} " + f"(status: {status_code}), not closing yet") return # Other errors → skip update, don't close logger.debug(f"Gateway error for position {position.position_address}: " @@ -752,6 +831,9 @@ async def _refresh_position_state(self, position: GatewayCLMMPosition, clmm_repo logger.warning(f"Invalid response for position {position.position_address}, missing 'address' field") return + # Successful read: the position exists — reset the miss counter. + self._position_missing_strikes.pop(position.position_address, None) + except Exception as e: logger.warning(f"Error fetching position {position.position_address} from Gateway: {e}") return diff --git a/services/gateway_wallet_service.py b/services/gateway_wallet_service.py index c12d84e3..b06e07c1 100644 --- a/services/gateway_wallet_service.py +++ b/services/gateway_wallet_service.py @@ -5,7 +5,7 @@ from fastapi import HTTPException -from services.gateway_client import GatewayClient, check_gateway_error +from services.gateway_client import GatewayClient, GatewayError, check_gateway_error from services.gecko_price_source import GeckoPriceSource # Create module-specific logger @@ -74,11 +74,16 @@ async def get_gateway_wallets(self) -> List[Dict]: try: wallets = check_gateway_error(await self.gateway_client.get_wallets()) - # Enrich with default wallet info for each chain + # Enrich with default wallet info for each chain; a per-chain config + # error degrades that chain's default to "" rather than failing the list. for wallet_group in wallets: chain = wallet_group.get("chain") if chain: - default_wallet = await self.gateway_client.get_default_wallet_address(chain) + try: + default_wallet = await self.gateway_client.get_default_wallet_address(chain) + except GatewayError as e: + logger.warning(f"Could not read default wallet for {chain}: {e}") + default_wallet = None wallet_group["default_address"] = default_wallet or "" return wallets @@ -103,6 +108,9 @@ async def add_gateway_wallet(self, chain: str, private_key: str, set_default: bo try: result = await self.gateway_client.add_wallet(chain, private_key, set_default=set_default) + if result is None: + # Connection error: the client returns None — 503, not a crash on `in None`. + raise HTTPException(status_code=503, detail="Gateway service is not available") if "error" in result: raise HTTPException(status_code=400, detail=f"Gateway error: {result['error']}") @@ -131,6 +139,9 @@ async def remove_gateway_wallet(self, chain: str, address: str) -> Dict: try: result = await self.gateway_client.remove_wallet(chain, address) + if result is None: + # Connection error: the client returns None — 503, not a crash on `in None`. + raise HTTPException(status_code=503, detail="Gateway service is not available") if "error" in result: raise HTTPException(status_code=400, detail=f"Gateway error: {result['error']}") @@ -169,6 +180,8 @@ async def get_gateway_balances(self, chain: str, address: str, network: Optional # Get balances from Gateway balances_response = await self.gateway_client.get_balances(chain, network, address, tokens=tokens) + if balances_response is None: + raise HTTPException(status_code=503, detail="Gateway service is not available") if "error" in balances_response: raise HTTPException(status_code=400, detail=f"Gateway error: {balances_response['error']}") diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index de32b138..03875c69 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -216,7 +216,7 @@ async def test_clmm_remove_liquidity_uses_percentage_to_remove(client_and_calls) client, calls = client_and_calls await client.clmm_remove_liquidity( connector="meteora", chain_network="solana-mainnet-beta", - wallet_address="WALLET", position_address="POS", percentage=50.0, + wallet_address="WALLET", position_address="POS", percentage_to_remove=50.0, ) call = calls[0] assert (call["method"], call["path"]) == ("POST", "trading/clmm/remove") @@ -232,7 +232,7 @@ async def test_clmm_remove_liquidity_sends_slippage_when_set(client_and_calls): client, calls = client_and_calls await client.clmm_remove_liquidity( connector="orca", chain_network="solana-mainnet-beta", - wallet_address="WALLET", position_address="POS", percentage=100.0, + wallet_address="WALLET", position_address="POS", percentage_to_remove=100.0, slippage_pct=0.5, ) assert calls[0]["json"]["slippagePct"] == 0.5 diff --git a/test/test_gateway_error_masking.py b/test/test_gateway_error_masking.py index 0a306428..15f1259d 100644 --- a/test/test_gateway_error_masking.py +++ b/test/test_gateway_error_masking.py @@ -109,7 +109,10 @@ async def test_refresh_does_not_close_position_on_gateway_error(): @pytest.mark.asyncio -async def test_refresh_closes_position_missing_from_valid_list(): +async def test_refresh_skips_position_missing_from_valid_list(): + """Absence from ONE positions-owned read is not proof of closure (RPC lag): + the refresh skips the update and leaves close detection to the poller's + consecutive-miss gate — a single read must never close a live position.""" from routers.gateway_clmm import _refresh_position_data accounts_service = _mock_accounts_service(clmm_positions_owned=[{"address": "OTHER"}]) @@ -120,7 +123,8 @@ async def test_refresh_closes_position_missing_from_valid_list(): ) await _refresh_position_data(_position(), accounts_service, clmm_repo) - clmm_repo.close_position.assert_awaited_once_with("POS") + clmm_repo.close_position.assert_not_awaited() + clmm_repo.update_position_liquidity.assert_not_awaited() # ============================================ @@ -162,3 +166,29 @@ async def test_poller_confirms_transaction(): poller = _poller_with_result({"txStatus": 1, "fee": 0.00001, "error": None}) result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") assert result["status"] == "CONFIRMED" + # fee of exactly 0 must survive (no truthiness drop) + poller = _poller_with_result({"txStatus": 1, "fee": 0, "error": None}) + result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") + assert result["gas_fee"] == 0 + + +@pytest.mark.asyncio +async def test_poller_pending_with_transient_error_stays_pending(): + """Gateway deliberately returns txStatus 0 WITH an error message for transient + poll failures ("poll again, don't give up") — the error field must never + promote a pending transaction to FAILED.""" + poller = _poller_with_result({ + "txStatus": 0, + "error": "Error polling transaction: 429 Too Many Requests", + }) + result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") + assert result["status"] == "PENDING" + + +@pytest.mark.asyncio +async def test_poller_reports_not_found_as_dropped(): + """txStatus -2 is NOT_FOUND — terminal on Solana after blockhash expiry; the + caller applies the grace window, but the classifier must not call it pending.""" + poller = _poller_with_result({"txStatus": -2, "error": None}) + result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") + assert result["status"] == "DROPPED" From 2a56df0c1c5af139b4a9efd2933934bf17fbb939 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:08:01 -0700 Subject: [PATCH 13/18] fix(clmm): finish the M8 falsy-zero sweep on close/collect event fee and gas fields A fee or gas value of exactly 0 was stored as None (unknown) on close and collect events, contradicting the is-not-None convention adopted everywhere else and leaving pending-fee columns stale on the poller's confirm path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- routers/gateway_clmm.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index cb5a56df..15ccdd54 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -508,7 +508,7 @@ async def open_clmm_position( "event_type": "OPEN", "base_token_amount": float(base_amount_added) if base_amount_added is not None else None, "quote_token_amount": float(quote_amount_added) if quote_amount_added is not None else None, - "gas_fee": float(gas_fee) if gas_fee else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, "gas_token": gas_token, "status": tx_status } @@ -915,9 +915,9 @@ async def close_clmm_position( "event_type": "CLOSE", "base_token_amount": float(base_amount_removed) if base_amount_removed is not None else None, "quote_token_amount": float(quote_amount_removed) if quote_amount_removed is not None else None, - "base_fee_collected": float(base_fee_collected) if base_fee_collected else None, - "quote_fee_collected": float(quote_fee_collected) if quote_fee_collected else None, - "gas_fee": float(gas_fee) if gas_fee else None, + "base_fee_collected": float(base_fee_collected) if base_fee_collected is not None else None, + "quote_fee_collected": float(quote_fee_collected) if quote_fee_collected is not None else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, "gas_token": gas_token, "status": tx_status } @@ -1129,9 +1129,9 @@ async def collect_fees_from_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "COLLECT_FEES", - "base_fee_collected": float(base_fee_collected) if base_fee_collected else None, - "quote_fee_collected": float(quote_fee_collected) if quote_fee_collected else None, - "gas_fee": float(gas_fee) if gas_fee else None, + "base_fee_collected": float(base_fee_collected) if base_fee_collected is not None else None, + "quote_fee_collected": float(quote_fee_collected) if quote_fee_collected is not None else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, "gas_token": gas_token, "status": tx_status } From 63707f0dd9ff5697659dc4c2668ac6a17ef693fb Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:20:00 -0700 Subject: [PATCH 14/18] fix(gateway): treat unreachable-Gateway results as failures in update_api_keys A None result from the client (connection error mid-batch) was filtered out as if it succeeded, so the endpoint reported keys updated that never reached Gateway. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- routers/gateway.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/routers/gateway.py b/routers/gateway.py index e864efc8..cab5cbc7 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -306,8 +306,14 @@ async def update_api_keys( results = await accounts_service.gateway_client.update_api_keys(request.api_keys) - # Check for any errors in the results - errors = [r for r in results if r and "error" in r] + # A None result means the request never reached Gateway (connection + # error mid-batch) — that is a failure, not a success to filter out. + if any(r is None for r in results): + raise HTTPException( + status_code=503, + detail="Gateway became unreachable while updating API keys; not all keys were applied", + ) + errors = [r for r in results if "error" in r] if errors: raise HTTPException(status_code=400, detail=f"Failed to update some API keys: {errors}") From ea20c1209f4e2e99502069fb3c6cbfd7aa154565 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:33:22 -0700 Subject: [PATCH 15/18] refactor(amm): drop pool-scoped swap proxies; network param on clmm pools; uppercase side on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway folded /trading/amm/quote-swap and execute-swap into the unified /trading/swap route (connector as name/type, pool resolved internally), so the hapi proxies, models, and client methods go with them — swaps always go through /gateway/swap regardless of connector type. /gateway/clmm/pools takes a network parameter instead of hardcoding mainnet-beta (the last endpoint on the surface without one), and the swap repository serves side uppercase so legacy lowercase rows cannot leak into strict consumers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../repositories/gateway_swap_repository.py | 3 +- models/__init__.py | 6 -- models/gateway_trading.py | 40 -------------- routers/gateway_amm.py | 55 ------------------- routers/gateway_clmm.py | 8 ++- services/gateway_client.py | 48 ---------------- test/test_gateway_client_contract.py | 26 --------- 7 files changed, 8 insertions(+), 178 deletions(-) diff --git a/database/repositories/gateway_swap_repository.py b/database/repositories/gateway_swap_repository.py index 4547cc31..ad407b5e 100644 --- a/database/repositories/gateway_swap_repository.py +++ b/database/repositories/gateway_swap_repository.py @@ -169,7 +169,8 @@ def to_dict(self, swap: GatewaySwap) -> Dict: "trading_pair": swap.trading_pair, "base_token": swap.base_token, "quote_token": swap.quote_token, - "side": swap.side, + # Legacy rows written before normalization may hold lowercase; serve uppercase + "side": (swap.side or "").upper(), "input_amount": float(swap.input_amount), "output_amount": float(swap.output_amount), "price": float(swap.price), diff --git a/models/__init__.py b/models/__init__.py index b0742cf6..e7ef8444 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -94,15 +94,12 @@ AMMAddLiquidityRequest, AMMCreatePoolRequest, AMMCreatePoolResponse, - AMMExecuteSwapRequest, AMMPoolInfoResponse, AMMPositionDetail, AMMPositionInfoResponse, AMMPositionsOwnedRequest, AMMQuoteLiquidityRequest, AMMQuoteLiquidityResponse, - AMMQuoteSwapRequest, - AMMQuoteSwapResponse, AMMRemoveLiquidityRequest, AMMTransactionResponse, CLMMAddLiquidityRequest, @@ -321,9 +318,6 @@ "AMMPoolInfoResponse", "AMMPositionDetail", "AMMPositionInfoResponse", - "AMMQuoteSwapRequest", - "AMMQuoteSwapResponse", - "AMMExecuteSwapRequest", "AMMTransactionResponse", "AMMQuoteLiquidityRequest", "AMMQuoteLiquidityResponse", diff --git a/models/gateway_trading.py b/models/gateway_trading.py index c1ddc1a2..93a8ef66 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -402,46 +402,6 @@ class AMMPositionInfoResponse(BaseModel): model_config = {"populate_by_name": True} -class AMMQuoteSwapRequest(BaseModel): - """Request to quote a swap against a specific AMM pool.""" - connector: str = Field(description="AMM connector (e.g., 'meteora', 'raydium', 'uniswap')") - network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - pool_address: str = Field(description="Pool contract address") - base_token: str = Field(description="Token that defines the swap direction (symbol or address)") - side: str = Field(description="Trade direction: BUY or SELL") - amount: Decimal = Field(description="Amount to swap (of base for SELL, of base to receive for BUY)") - slippage_pct: Optional[Decimal] = Field(default=None, description="Maximum slippage percentage") - - -class AMMQuoteSwapResponse(BaseModel): - """Response with an AMM swap quote.""" - pool_address: str = Field(alias="poolAddress", description="Pool address") - token_in: str = Field(alias="tokenIn", description="Input token address") - token_out: str = Field(alias="tokenOut", description="Output token address") - amount_in: Decimal = Field(alias="amountIn", description="Input amount") - amount_out: Decimal = Field(alias="amountOut", description="Output amount") - price: Decimal = Field(description="Execution price") - min_amount_out: Decimal = Field(alias="minAmountOut", description="Minimum output after slippage") - max_amount_in: Decimal = Field(alias="maxAmountIn", description="Maximum input after slippage") - price_impact_pct: Decimal = Field(alias="priceImpactPct", description="Price impact percentage") - slippage_pct: Optional[Decimal] = Field(default=None, alias="slippagePct", description="Slippage percentage used") - - model_config = {"populate_by_name": True} - - -class AMMExecuteSwapRequest(BaseModel): - """Request to execute a swap against a specific AMM pool.""" - connector: str = Field(description="AMM connector (e.g., 'meteora', 'raydium', 'uniswap')") - network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - pool_address: str = Field(description="Pool contract address") - base_token: str = Field(description="Token that defines the swap direction (symbol or address)") - side: str = Field(description="Trade direction: BUY or SELL") - amount: Decimal = Field(description="Amount to swap") - slippage_pct: Optional[Decimal] = Field( - default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") - wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") - - class AMMTransactionResponse(BaseModel): """Chain-neutral write response. `signature` holds the tx signature (Solana) or tx hash (EVM).""" signature: str = Field(description="Transaction signature (Solana) or transaction hash (EVM)") diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index 92ad1133..607c1a4f 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -21,14 +21,11 @@ AMMAddLiquidityRequest, AMMCreatePoolRequest, AMMCreatePoolResponse, - AMMExecuteSwapRequest, AMMPoolInfoResponse, AMMPositionInfoResponse, AMMPositionsOwnedRequest, AMMQuoteLiquidityRequest, AMMQuoteLiquidityResponse, - AMMQuoteSwapRequest, - AMMQuoteSwapResponse, AMMRemoveLiquidityRequest, AMMTransactionResponse, ) @@ -146,31 +143,6 @@ async def get_amm_positions_owned( raise HTTPException(status_code=500, detail=f"Error getting AMM positions owned: {str(e)}") -@router.post("/amm/quote-swap", response_model=AMMQuoteSwapResponse, response_model_by_alias=False) -async def quote_amm_swap( - request: AMMQuoteSwapRequest, - accounts_service: AccountsService = Depends(get_accounts_service), -): - """Quote a swap against a specific AMM pool (pool-scoped, not router).""" - try: - await _require_gateway(accounts_service) - result = check_gateway_error(await accounts_service.gateway_client.amm_quote_swap( - connector=request.connector, chain_network=request.network, pool_address=request.pool_address, - base_token=request.base_token, side=request.side, amount=float(request.amount), - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, - )) - return AMMQuoteSwapResponse(**result) - except HTTPException: - raise - except GatewayError as e: - raise HTTPException(status_code=e.status, detail=f"Gateway error quoting AMM swap: {e}") - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Error quoting AMM swap: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error quoting AMM swap: {str(e)}") - - @router.post("/amm/quote-liquidity", response_model=AMMQuoteLiquidityResponse, response_model_by_alias=False) async def quote_amm_liquidity( request: AMMQuoteLiquidityRequest, @@ -198,33 +170,6 @@ async def quote_amm_liquidity( # ----------------------------- Writes ----------------------------- -@router.post("/amm/execute-swap", response_model=AMMTransactionResponse) -async def execute_amm_swap( - request: AMMExecuteSwapRequest, - accounts_service: AccountsService = Depends(get_accounts_service), -): - """Execute a swap against a specific AMM pool.""" - try: - await _require_gateway(accounts_service) - wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) - result = check_gateway_error(await accounts_service.gateway_client.amm_execute_swap( - connector=request.connector, chain_network=request.network, wallet_address=wallet_address, - pool_address=request.pool_address, base_token=request.base_token, side=request.side, - amount=float(request.amount), - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, - )) - return AMMTransactionResponse(**result) - except HTTPException: - raise - except GatewayError as e: - raise HTTPException(status_code=e.status, detail=f"Gateway error executing AMM swap: {e}") - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Error executing AMM swap: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error executing AMM swap: {str(e)}") - - @router.post("/amm/add-liquidity", response_model=AMMTransactionResponse) async def add_amm_liquidity( request: AMMAddLiquidityRequest, diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 15ccdd54..211ccc5f 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -210,6 +210,9 @@ async def get_clmm_pool_info( @router.get("/clmm/pools", response_model=CLMMPoolListResponse) async def get_clmm_pools( connector: str, + network: str = Query( + "mainnet-beta", + description="Solana network name (bare, e.g. 'mainnet-beta'); meteora/orca are Solana-only"), page: int = Query(0, ge=0, description="Page number"), limit: int = Query(50, ge=1, le=100, description="Results per page (max 100)"), search_term: Optional[str] = Query(None, description="Search query to filter pools"), @@ -225,6 +228,7 @@ async def get_clmm_pools( Args: connector: CLMM connector (meteora, orca) + network: Solana network name (bare, default 'mainnet-beta') page: Page number (default: 0) limit: Results per page (default: 50, max: 100) search_term: Search query to filter pools (optional) @@ -256,7 +260,7 @@ async def get_clmm_pools( direction = order_by if order_by else "desc" gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( connector="meteora", - network="mainnet-beta", + network=network, limit=limit, query=search_term, sort_by=f"{sort_key}{time_suffix}:{direction}" if sort_key else None, @@ -272,7 +276,7 @@ async def get_clmm_pools( ) gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( connector="orca", - network="mainnet-beta", + network=network, limit=limit, query=search_term, sort_by=sort_key, diff --git a/services/gateway_client.py b/services/gateway_client.py index d5547b3f..824f693a 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -877,54 +877,6 @@ async def amm_positions_owned( "walletAddress": wallet_address, }) - async def amm_quote_swap( - self, - connector: str, - chain_network: str, - pool_address: str, - base_token: str, - side: str, - amount: float, - slippage_pct: Optional[float] = None, - ) -> Dict: - """Quote a swap against a specific AMM pool.""" - params = { - "connector": connector, - "chainNetwork": chain_network, - "poolAddress": pool_address, - "baseToken": base_token, - "side": side.upper(), - "amount": amount, - } - if slippage_pct is not None: - params["slippagePct"] = slippage_pct - return await self._request("GET", "trading/amm/quote-swap", params=params) - - async def amm_execute_swap( - self, - connector: str, - chain_network: str, - wallet_address: str, - pool_address: str, - base_token: str, - side: str, - amount: float, - slippage_pct: Optional[float] = None, - ) -> Dict: - """Execute a swap against a specific AMM pool.""" - payload = { - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "poolAddress": pool_address, - "baseToken": base_token, - "side": side.upper(), - "amount": amount, - } - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct - return await self._request("POST", "trading/amm/execute-swap", json=payload) - async def amm_quote_liquidity( self, connector: str, diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 03875c69..30b860a3 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -373,32 +373,6 @@ async def test_amm_positions_owned_path(client_and_calls): assert c["params"] == {"connector": "meteora", "chainNetwork": NET, "walletAddress": WALLET} -@pytest.mark.asyncio -async def test_amm_quote_swap_path_and_slippage_omitted(client_and_calls): - client, calls = client_and_calls - await client.amm_quote_swap(connector="raydium", chain_network=NET, pool_address=POOL, - base_token="SOL", side="SELL", amount=0.01) - c = calls[0] - assert (c["method"], c["path"]) == ("GET", "trading/amm/quote-swap") - assert c["params"] == {"connector": "raydium", "chainNetwork": NET, "poolAddress": POOL, - "baseToken": "SOL", "side": "SELL", "amount": 0.01} - assert "slippagePct" not in c["params"] - - -@pytest.mark.asyncio -async def test_amm_execute_swap_path(client_and_calls): - client, calls = client_and_calls - await client.amm_execute_swap(connector="uniswap", chain_network="ethereum-mainnet", wallet_address=WALLET, - pool_address=POOL, base_token="WETH", side="buy", amount=1.0, slippage_pct=0.5) - c = calls[0] - assert (c["method"], c["path"]) == ("POST", "trading/amm/execute-swap") - assert c["json"]["walletAddress"] == WALLET - assert c["json"]["chainNetwork"] == "ethereum-mainnet" - assert c["json"]["slippagePct"] == 0.5 - # Gateway's schema enum-rejects lowercase; the client normalizes like the unified swap path - assert c["json"]["side"] == "BUY" - - @pytest.mark.asyncio async def test_amm_add_liquidity_omits_position_when_unset(client_and_calls): client, calls = client_and_calls From 535dd6861094a2cf84c5387b2d9e9de01eeb8353 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:57:28 -0700 Subject: [PATCH 16/18] fix(gateway): resolve swap connector types from Gateway; preserve error codes The hardcoded ROUTER_CONNECTORS roster silently misrouted every connector Gateway added after it was written (a bare name fell through to /clmm and 404'd). Connector trading types now come from Gateway's own config/connectors listing, cached per client, preferring router then clmm then amm; an unknown name raises instead of guessing. Gateway's machine-readable error code (TRANSACTION_TIMEOUT, SLIPPAGE_EXCEEDED, ...) was flattened into prose before GatewayError was raised, leaving callers unable to tell retryable from terminal failures. It now rides GatewayError.code. Also: the swap execute response no longer lower-cases its status (every read surface reports uppercase), and swaps file under the base venue name so 'jupiter' and 'jupiter/router' land in one history bucket. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../repositories/gateway_swap_repository.py | 4 +- routers/gateway_swap.py | 6 +- services/gateway_client.py | 90 +++++++++++++------ test/test_gateway_client_contract.py | 42 ++++++++- 4 files changed, 109 insertions(+), 33 deletions(-) diff --git a/database/repositories/gateway_swap_repository.py b/database/repositories/gateway_swap_repository.py index ad407b5e..1dd29625 100644 --- a/database/repositories/gateway_swap_repository.py +++ b/database/repositories/gateway_swap_repository.py @@ -72,7 +72,9 @@ async def get_swaps( if network: query = query.where(GatewaySwap.network == network) if connector: - query = query.where(GatewaySwap.connector == connector) + # Rows store the base venue name; accept a typed provider + # ("jupiter/router") as the same filter. + query = query.where(GatewaySwap.connector == connector.split("/")[0]) if wallet_address: query = query.where(GatewaySwap.wallet_address == wallet_address) if trading_pair: diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 8c4dadfd..17381bd8 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -198,7 +198,9 @@ async def execute_swap( swap_data = { "transaction_hash": transaction_hash, "network": request.network, - "connector": request.connector, + # Store the base venue name: a swap on "jupiter" and one on + # "jupiter/router" are the same venue and must file together. + "connector": request.connector.split("/")[0], "wallet_address": wallet_address, "trading_pair": request.trading_pair, "base_token": base, @@ -226,7 +228,7 @@ async def execute_swap( amount=request.amount, # "confirmed" / "submitted" / "failed" — a failed EVM swap comes back as # status -1 with zeroed amounts, which must not read as in-flight. - status=tx_status.lower() + status=tx_status ) except HTTPException: diff --git a/services/gateway_client.py b/services/gateway_client.py index 824f693a..037e60fb 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -6,9 +6,11 @@ logger = logging.getLogger(__name__) -# Connectors whose bare name maps to a router-type swap provider on Gateway. -# All other connectors default to their CLMM route (meteora, orca, raydium, pancakeswap-sol). -ROUTER_CONNECTORS = {"jupiter", "0x", "uniswap", "pancakeswap", "dflow", "okx", "titan"} +# When a caller names a connector without a trading type, Gateway's own +# config/connectors listing decides the type — preferring a router route, then +# CLMM, then AMM. A hardcoded roster silently misrouted every connector Gateway +# added after it was written. +_SWAP_TYPE_PREFERENCE = ("router", "clmm", "amm") # The single source for chain -> native gas token. Every writer of gas_token # columns (routers and the transaction poller) must use this — drifted local @@ -34,9 +36,13 @@ def get_native_gas_token(chain: str) -> str: class GatewayError(Exception): """A Gateway HTTP request that completed with a non-OK status.""" - def __init__(self, message: str, status: int): + def __init__(self, message: str, status: int, code: Optional[str] = None): super().__init__(message) self.status = status + # Gateway's machine-readable error code (TRANSACTION_TIMEOUT, + # SLIPPAGE_EXCEEDED, ...). Callers branch on this instead of + # string-matching the message. + self.code = code def check_gateway_error(result: Optional[Any]) -> Any: @@ -50,8 +56,8 @@ def check_gateway_error(result: Optional[Any]) -> Any: """ if result is None: raise GatewayError("No response from Gateway (connection error)", 503) - if isinstance(result, dict) and set(result.keys()) == {"error", "status"}: - raise GatewayError(str(result["error"]), int(result["status"])) + if isinstance(result, dict) and {"error", "status"} <= set(result.keys()) <= {"error", "status", "code"}: + raise GatewayError(str(result["error"]), int(result["status"]), result.get("code")) return result @@ -84,6 +90,8 @@ def __init__( # while the Gateway is simply not started. Logged once on the transition, then suppressed # until certs become available again. self._certs_unavailable_warned = False + # Gateway's connector -> trading_types listing, fetched on first use. + self._connector_trading_types: Optional[Dict[str, List[str]]] = None @staticmethod def parse_network_id(network_id: str) -> tuple[str, str]: @@ -166,23 +174,23 @@ async def _request(self, method: str, path: str, params: Dict = None, json: Dict if method == "GET": async with session.get(url, params=params) as response: if not response.ok: - error_body = await self._get_error_body(response) + error_body, error_code = await self._get_error_body(response) logger.warning(f"Gateway request failed: {method} {url} - {response.status} - {error_body}") - return {"error": error_body, "status": response.status} + return {"error": error_body, "status": response.status, "code": error_code} return await response.json() elif method == "POST": async with session.post(url, params=params, json=json) as response: if not response.ok: - error_body = await self._get_error_body(response) + error_body, error_code = await self._get_error_body(response) logger.warning(f"Gateway request failed: {method} {url} - {response.status} - {error_body}") - return {"error": error_body, "status": response.status} + return {"error": error_body, "status": response.status, "code": error_code} return await response.json() elif method == "DELETE": async with session.delete(url, params=params, json=json) as response: if not response.ok: - error_body = await self._get_error_body(response) + error_body, error_code = await self._get_error_body(response) logger.warning(f"Gateway request failed: {method} {url} - {response.status} - {error_body}") - return {"error": error_body, "status": response.status} + return {"error": error_body, "status": response.status, "code": error_code} return await response.json() except aiohttp.ClientError as e: logger.debug(f"Gateway request error: {method} {url} - {e}") @@ -191,18 +199,23 @@ async def _request(self, method: str, path: str, params: Dict = None, json: Dict logger.debug(f"Gateway request failed: {method} {url} - {e}") raise - async def _get_error_body(self, response: aiohttp.ClientResponse) -> str: - """Extract error message from response body""" + async def _get_error_body(self, response: aiohttp.ClientResponse) -> tuple: + """Extract (message, code) from an error response body. + + Gateway's error envelope is {statusCode, error, message, code?}; the + code is machine-readable (TRANSACTION_TIMEOUT, SLIPPAGE_EXCEEDED, ...) + and is what callers should branch on. + """ try: data = await response.json() if isinstance(data, dict): - return data.get("message") or data.get("error") or str(data) - return str(data) + return (data.get("message") or data.get("error") or str(data), data.get("code")) + return (str(data), None) except Exception: try: - return await response.text() + return (await response.text(), None) except Exception: - return f"HTTP {response.status}" + return (f"HTTP {response.status}", None) async def ping(self) -> bool: """Check if Gateway is online""" @@ -448,18 +461,43 @@ async def delete_pool(self, chain: str, network: str, address: str) -> Dict: # Swap Operations (unified /trading/swap endpoints) # ============================================ - @staticmethod - def normalize_swap_connector(connector: str) -> str: + async def _get_connector_trading_types(self) -> Dict[str, List[str]]: + """Gateway's connector -> trading_types map, fetched once per client.""" + if self._connector_trading_types is None: + listing = check_gateway_error(await self.get_connectors()) + self._connector_trading_types = { + entry["name"]: list(entry.get("trading_types", [])) + for entry in listing.get("connectors", []) + if entry.get("name") + } + return self._connector_trading_types + + async def normalize_swap_connector(self, connector: str) -> str: """ Normalize a connector name to Gateway's 'name/type' swap-provider format. - 'jupiter' -> 'jupiter/router', 'meteora' -> 'meteora/clmm', - 'raydium/amm' -> 'raydium/amm' (already typed, passed through). + An already-typed value passes through untouched ('raydium/amm'). A bare + name takes the connector's most swap-appropriate trading type as Gateway + reports it: router, else clmm, else amm. Unknown names raise rather than + guessing a type that Gateway would reject with an opaque 400. """ if "/" in connector: return connector - connector_type = "router" if connector in ROUTER_CONNECTORS else "clmm" - return f"{connector}/{connector_type}" + trading_types = (await self._get_connector_trading_types()).get(connector) + if trading_types is None: + raise GatewayError( + f"Unknown swap connector '{connector}'. Gateway reports: " + f"{', '.join(sorted((await self._get_connector_trading_types()).keys()))}", + 400, + ) + for candidate in _SWAP_TYPE_PREFERENCE: + if candidate in trading_types: + return f"{connector}/{candidate}" + raise GatewayError( + f"Connector '{connector}' supports no swap trading type " + f"(Gateway reports: {', '.join(trading_types) or 'none'})", + 400, + ) async def quote_swap( self, @@ -485,7 +523,7 @@ async def quote_swap( """ params = { "chainNetwork": chain_network, - "connector": self.normalize_swap_connector(connector), + "connector": await self.normalize_swap_connector(connector), "baseToken": base_asset, "quoteToken": quote_asset, "amount": str(amount), @@ -520,7 +558,7 @@ async def execute_swap( """ payload = { "chainNetwork": chain_network, - "connector": self.normalize_swap_connector(connector), + "connector": await self.normalize_swap_connector(connector), "walletAddress": wallet_address, "baseToken": base_asset, "quoteToken": quote_asset, diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 30b860a3..0cf8b39d 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -18,6 +18,22 @@ from services.gateway_client import GatewayClient, GatewayError, check_gateway_error +# Gateway's own config/connectors listing decides an untyped connector's swap +# type; these trading_types mirror what Gateway reports today. +_CONNECTOR_LISTING = {"connectors": [ + {"name": "jupiter", "trading_types": ["router"]}, + {"name": "0x", "trading_types": ["router"]}, + {"name": "uniswap", "trading_types": ["router", "amm", "clmm"]}, + {"name": "pancakeswap", "trading_types": ["router", "amm", "clmm"]}, + {"name": "dflow", "trading_types": ["router"]}, + {"name": "okx", "trading_types": ["router"]}, + {"name": "titan", "trading_types": ["router"]}, + {"name": "meteora", "trading_types": ["clmm", "amm"]}, + {"name": "orca", "trading_types": ["clmm"]}, + {"name": "raydium", "trading_types": ["clmm", "amm"]}, + {"name": "pancakeswap-sol", "trading_types": ["clmm"]}, +]} + @pytest.fixture def client_and_calls(monkeypatch): @@ -30,6 +46,11 @@ async def fake_request(method, path, params=None, json=None): return {} monkeypatch.setattr(client, "_request", fake_request) + # Pre-seed Gateway's connector listing so swap payload assertions see only + # the swap call itself, not the one-off discovery request behind it. + client._connector_trading_types = { + entry["name"]: entry["trading_types"] for entry in _CONNECTOR_LISTING["connectors"] + } return client, calls @@ -37,13 +58,13 @@ async def fake_request(method, path, params=None, json=None): # Connector normalization # ============================================ + +@pytest.mark.asyncio @pytest.mark.parametrize("connector,expected", [ ("jupiter", "jupiter/router"), ("0x", "0x/router"), ("uniswap", "uniswap/router"), ("pancakeswap", "pancakeswap/router"), - # The full Solana router roster Gateway routes as first-class providers — - # a bare name missing here would misroute to /clmm and 404. ("dflow", "dflow/router"), ("okx", "okx/router"), ("titan", "titan/router"), @@ -56,8 +77,21 @@ async def fake_request(method, path, params=None, json=None): ("meteora/clmm", "meteora/clmm"), ("raydium/amm", "raydium/amm"), ]) -def test_normalize_swap_connector(connector, expected): - assert GatewayClient.normalize_swap_connector(connector) == expected +async def test_normalize_swap_connector(connector, expected): + client = GatewayClient() + client._connector_trading_types = { + entry["name"]: entry["trading_types"] for entry in _CONNECTOR_LISTING["connectors"] + } + assert await client.normalize_swap_connector(connector) == expected + + +@pytest.mark.asyncio +async def test_normalize_swap_connector_rejects_unknown_name(): + client = GatewayClient() + client._connector_trading_types = {"jupiter": ["router"]} + with pytest.raises(GatewayError) as exc: + await client.normalize_swap_connector("nosuchdex") + assert "nosuchdex" in str(exc.value) # ============================================ From bdaa1cfe4f8007455a21efc11c0f606f1f56d063 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:57:39 -0700 Subject: [PATCH 17/18] chore: ignore .DS_Store and the local patched-hummingbot Dockerfile Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 64ad9ca2..dcfc78e0 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,5 @@ bots/conf/ .idea/ improvements bots/gateway-files/ +.DS_Store +Dockerfile.patched-hummingbot From 99cff59c09d3733d75f914980891c3c0155134d2 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 23:36:30 -0700 Subject: [PATCH 18/18] test(gateway): pin the amm quote-liquidity contract The one client verb with no path/payload test; also pins that an omitted slippage stays omitted and an explicit 0 is sent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- test/test_gateway_client_contract.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 0cf8b39d..70e97de0 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -407,6 +407,27 @@ async def test_amm_positions_owned_path(client_and_calls): assert c["params"] == {"connector": "meteora", "chainNetwork": NET, "walletAddress": WALLET} +@pytest.mark.asyncio +async def test_amm_quote_liquidity_path_and_slippage_omitted(client_and_calls): + client, calls = client_and_calls + await client.amm_quote_liquidity(connector="meteora", chain_network=NET, pool_address=POOL, + base_token_amount=1.0, quote_token_amount=100.0) + c = calls[0] + assert (c["method"], c["path"]) == ("GET", "trading/amm/quote-liquidity") + assert c["params"] == {"connector": "meteora", "chainNetwork": NET, "poolAddress": POOL, + "baseTokenAmount": 1.0, "quoteTokenAmount": 100.0} + # Omitted slippage means "use the connector's configured slippagePct" + assert "slippagePct" not in c["params"] + + +@pytest.mark.asyncio +async def test_amm_quote_liquidity_sends_zero_slippage(client_and_calls): + client, calls = client_and_calls + await client.amm_quote_liquidity(connector="meteora", chain_network=NET, pool_address=POOL, + base_token_amount=1.0, quote_token_amount=100.0, slippage_pct=0) + assert calls[0]["params"]["slippagePct"] == 0 + + @pytest.mark.asyncio async def test_amm_add_liquidity_omits_position_when_unset(client_and_calls): client, calls = client_and_calls