Skip to content
All library documents

Price-Triggered XRPL AMM Liquidity with Range-Based Removal

Article Strategy library · Author: hummingbot

Summary

This strategy monitors a trading pair on the XRPL decentralized exchange and opens an automated market maker liquidity position after price crosses a configured target condition: above the target or below it. Once triggered, it sets a price range around the current pool price and divides the configured quote amount between base and quote tokens for the deposit. The connector handles pool pricing and liquidity operations.

While liquidity is active, the strategy tracks price and pool balance. It is designed to remove liquidity after price moves beyond the position range by a configurable percentage for a specified duration, and it attempts to close an open position when the strategy stops. The excerpt shows configuration and parts of the implementation but omits substantial code, and it offers no backtest or realized return evidence. Outcomes depend on pool behavior, token prices, transaction costs, slippage, and whether the position remains within range; the document does not quantify these risks.

Key ideas

  • A configurable above-target or below-target price condition triggers an XRPL AMM liquidity position.
  • The position range is centered on the observed pool price when entry occurs.
  • The configured quote amount is split between base and quote token contributions.
  • Liquidity removal is triggered when price stays sufficiently outside the range for a configured duration.
  • The excerpt provides no performance evidence and omits part of the implementation.

Tags

Full text
# XRPLTriggeredLiquidity


# XRPLTriggeredLiquidity









This strategy monitors XRPL DEX prices and add liquidity to AMM Pools when the price is within a certain range.
    Remove liquidity if the price is outside the range.
    It uses a connector to get the current price and manage liquidity in AMM Pools

## Source (Apache-2.0)

```python
import os
import time
from decimal import Decimal
from typing import Dict

from pydantic import Field

from hummingbot.connector.connector_base import ConnectorBase
from hummingbot.connector.exchange.xrpl.xrpl_exchange import XrplExchange
from hummingbot.connector.exchange.xrpl.xrpl_utils import (
    AddLiquidityResponse,
    PoolInfo,
    QuoteLiquidityResponse,
    RemoveLiquidityResponse,
)
from hummingbot.core.data_type.common import MarketDict
from hummingbot.core.utils.async_utils import safe_ensure_future
from hummingbot.strategy.strategy_v2_base import StrategyV2Base, StrategyV2ConfigBase


class XRPLTriggeredLiquidityConfig(StrategyV2ConfigBase):
    script_file_name: str = Field(default_factory=lambda: os.path.basename(__file__))
    trading_pair: str = Field(
        "XRP-RLUSD", json_schema_extra={"prompt": "Trading pair (e.g. XRP-RLUSD)", "prompt_on_new": True}
    )
    target_price: Decimal = Field(
        Decimal("1.0"), json_schema_extra={"prompt": "Target price to trigger position opening", "prompt_on_new": True}
    )
    trigger_above: bool = Field(
        False,
        json_schema_extra={
            "prompt": "Trigger when price rises above target? (True for above/False for below)",
            "prompt_on_new": True,
        },
    )
    position_width_pct: Decimal = Field(
        Decimal("10.0"),
        json_schema_extra={
            "prompt": "Position width in percentage (e.g. 5.0 for ±5% around target price)",
            "prompt_on_new": True,
        },
    )
    total_amount_in_quote: Decimal = Field(
        Decimal("1.0"), json_schema_extra={"prompt": "Total amount in quote token", "prompt_on_new": True}
    )
    out_of_range_pct: Decimal = Field(
        Decimal("1.0"),
        json_schema_extra={
            "prompt": "Percentage outside range that triggers closing (e.g. 1.0 for 1%)",
            "prompt_on_new": True,
        },
    )
    out_of_range_secs: int = Field(
        300,
        json_schema_extra={
            "prompt": "Seconds price must be out of range before closing (e.g. 300 for 5 min)",
            "prompt_on_new": True,
        },
    )
    refresh_interval_secs: int = Field(
        15,
        json_schema_extra={
            "prompt": "Refresh interval in seconds",
            "prompt_on_new": True,
        },
    )

    def update_markets(self, markets: MarketDict) -> MarketDict:
        markets["xrpl"] = markets.get("xrpl", set()) | {self.trading_pair}
        return markets


class XRPLTriggeredLiquidity(StrategyV2Base):
    """
    This strategy monitors XRPL DEX prices and add liquidity to AMM Pools when the price is within a certain range.
    Remove liquidity if the price is outside the range.
    It uses a connector to get the current price and manage liquidity in AMM Pools
    """

    def __init__(self, connectors: Dict[str, ConnectorBase], config: XRPLTriggeredLiquidityConfig):
        super().__init__(connectors, config)
        self.config = config
        self.exchange = "xrpl"
        self.base, self.quote = self.config.trading_pair.split("-")

        # State tracking
        self.connector_ready = False
        self.connector_instance: XrplExchange = self.connectors[self.exchange]
        self.position_opened = False
        self.position_opening = False
        self.position_closing = False
        self.wallet_address = None
        self.pool_info = None
        self.pool_balance = None
        self.last_price = None
        self.position_lower_price = None
        self.position_upper_price = None
        self.out_of_range_start_time = None
        self.last_refresh_time = 0  # Track last refresh time

        # Log startup information
        self.logger().info("Starting XRPLTriggeredLiquidity strategy")
        self.logger().info(f"Trading pair: {self.config.trading_pair}")
        self.logger().info(f"Target price: {self.config.target_price}")
        condition = "rises above" if self.config.trigger_above else "falls below"
        self.logger().info(f"Will open position when price {condition} target")
        self.logger().info(f"Position width: ±{self.config.position_width_pct}%")
        self.logger().info(f"Total amount in quote: {self.config.total_amount_in_quote} {self.quote}")
        self.logger().info(
            f"Will close position if price is outside range by {self.config.out_of_range_pct}% for {self.config.out_of_range_secs} seconds"
        )

        # Check connector status
        self.check_connector_status()

    def check_connector_status(self):
        """Check if the connector is ready"""
        if not self.connectors[self.exchange].ready:
            self.logger().info("Connector not ready yet, waiting...")
            self.connector_ready = False
        else:
            self.connector_ready = True
            self.wallet_address = self.connectors[self.exchange].auth.get_wallet().address

    def on_tick(self):
        """Main loop to check price and manage liquidity"""
        current_time = time.time()
        if current_time - self.last_refresh_time < self.config.refresh_interval_secs:
            return
        self.last_refresh_time = current_time

        if not self.connector_ready or not self.wallet_address:
            self.check_connector_status()
            return

        if self.connector_instance is None:
            self.logger().error("Connector instance is not available.")
            return

        # Check price and position status on each tick
        if not self.position_opened and not self.position_opening:
            safe_ensure_future(self.check_price_and_open_position())
        elif self.position_opened and not self.position_closing:
            safe_ensure_future(self.monitor_position())
            safe_ensure_future(self.check_position_balance())

    async def on_stop(self):
        """Stop the strategy and close any open positions"""
        if self.position_opened:
            self.logger().info("Stopping strategy, closing position...")
            safe_ensure_future(self.close_position())
        else:
            self.logger().info("Stopping strategy, no open position to close.")
        await super().on_stop()

    async def check_price_and_open_position(self):
        """Check the current price and open a position if within range"""
        if self.position_opening or self.position_opened:
            return

        if self.connector_instance is None:
            self.logger().error("Connector instance is not available.")
            return

        self.position_opening = True

        try:
            pool_info: PoolInfo = await self.connector_instance.amm_get_pool_info(trading_pair=self.config.trading_pair)
            self.pool_info = pool_info
            self.last_price = pool_info.price

            # Check if price condition is met
            condition_met = False
            if self.config.trigger_above and self.last_price > self.config.target_price:
                condition_met = True
                self.logger().info(f"Price rose above target: {self.last_price} > {self.config.target_price}")
            elif not self.config.trigger_above and self.last_price < self.config.target_price:
                condition_met = True
                self.logger().info(f"Price fell below target: {self.last_price} < {self.config.target_price}")

            if condition_met:
                self.logger().info("Price condition met! Opening position...")
                self.position_opening = False  # Reset flag so open_position can set it
                await self.open_position()
                await self.check_position_balance()
            else:
                self.logger().info(
                    f"Current price: {self.last_price}, Target: {self.config.target_price}, " f"Condition not met yet."
                )
                self.position_opening = False

        except Exception as e:
            self.logger().error(f"Error in check_price_and_open_position: {str(e)}")
            self.position_opening = False

    async def open_position(self):
        """Open a liquidity position around the target price"""
        if self.position_opening or self.position_opened:
            return

        if self.pool_info is None:
            self.logger().error("Cannot open position: Failed to get current pool info")
            self.position_opening = False
            return

        if self.wallet_address is None:
            self.logger().error("Cannot open position: Failed to get wallet address")
            self.position_opening = False
            return

        if self.connector_instance is None:
            self.logger().error("Connector instance is not available.")
            return

        self.position_opening = True

        try:
            if not self.last_price:
                self.logger().error("Cannot open position: Failed to get current pool price")
                self.position_opening = False
                return

            # Calculate position price range based on CURRENT pool price instead of target
            current_price = float(self.last_price)
            width_pct = float(self.config.position_width_pct) / 100.0

            lower_price = current_price * (1 - width_pct)
            upper_price = current_price * (1 + width_pct)

            self.position_lower_price = lower_price
            self.position_upper_price = upper_price

            # Calculate base and quote token amounts from last_price and the total_amount_in_quote
            total_amount_in_quote = float(self.config.total_amount_in_quote)
            quote_amount_per_side = total_amount_in_quote / 2
            if total_amount_in_quote > 0:
                base_token_amount = quote_amount_per_side / current_price
                quote_token_amount = quote_amount_per_side
            else:
                # Log warning if total_amount_in_quote is 0 and return
                self.logger().warning("total_amount_in_quote is 0, cannot calculate base and quote token amounts.")
                self.position_opening = False
                return

            self.logger().info(
                f"Opening position around current price {current_price} with range: {lower_price} to {upper_price}"
            )

            quote: QuoteLiquidityResponse = await self.connector_instance.amm_quote_add_liquidity(
                pool_address=self.pool_info.address,
                base_token_amount=Decimal(base_token_amount),
                quote_token_amount=Decimal(quote_token_amount),
                slippage_pct=Decimal("0.01"),
            )

            add_liquidity_response: AddLiquidityResponse = await self.connector_instance.amm_add_liquidity(
                pool_address=self.pool_info.address,
                wallet_address=self.wallet_address,
                base_token_amount=quote.base_token_amount,
                quote_token_amount=quote.quote_token_amount,
                slippage_pct=Decimal("0.01"),
            )

            # Check if any amount added or not, if not then position has not been opened
            if (
                add_liquidity_response.base_token_amount_added == 0
                and add_liquidity_response.quote_token_amount_added == 0
            ):
                self.logger().error("Failed to open position: No tokens added.")
                self.position_opening = False
                return

            # Update position state
            self.position_opened = True
            self.position_opening = False
            self.logger().info(
                f"Position opened successfully! Base: {add_liquidity_response.base_token_amount_added}, "
                f"Quote: {add_liquidity_response.quote_token_amount_added}"
            )

        except Exception as e:
            self.logger().error(f"Error opening position: {str(e)}")
        finally:
            # Only clear position_opening flag if position is not opened
            if not self.position_opened:
                self.position_opening = False

    async def monitor_position(self):
        """Monitor the position and price to determine if position should be closed"""
        if self.position_closing:
            return

        if self.position_lower_price is None or self.position_upper_price is None:
            self.logger().error("Cannot monitor position: Failed to get position price range")
            return

        if self.connector_instance is None:
            self.logger().error("Connector instance is not available.")
            return

        try:
            # Fetch current pool info to get the latest price
            pool_info: PoolInfo = await self.connector_instance.amm_get_pool_info(trading_pair=self.config.trading_pair)
            self.pool_info = pool_info
            self.last_price = pool_info.price

            if not self.last_price:
                return

            # Check if price is outside position range by more than out_of_range_pct
            out_of_range = False

            lower_bound_with_buffer = self.position_lower_price * (1 - float(self.config.out_of_range_pct) / 100.0)
            upper_bound_with_buffer = self.position_upper_price * (1 + float(self.config.out_of_range_pct) / 100.0)

            if float(self.last_price) < lower_bound_with_buffer:
                out_of_range = True
                out_of_range_amount = (
                    (lower_bound_with_buffer - float(self.last_price)) / self.position_lower_price * 100
                )
                self.logger().info(
                    f"Price {self.last_price} is below position lower bound with buffer {lower_bound_with_buffer} by {out_of_range_amount:.2f}%"
                )
            elif float(self.last_price) > upper_bound_with_buffer:
                out_of_range = True
                out_of_range_amount = (
                    (float(self.last_price) - upper_bound_with_buffer) / self.position_upper_price * 100
                )
                self.logger().info(
                    f"Price {self.last_price} is above position upper bound with buffer {upper_bound_with_buffer} by {out_of_range_amount:.2f}%"
                )

            # Track out-of-range time
            current_time = time.time()
            if out_of_range:
                if self.out_of_range_start_time is None:
                    self.out_of_range_start_time = current_time
                    self.logger().info("Price moved out of range (with buffer). Starting timer...")

                # Check if price has been out of range for sufficient time
                elapsed_seconds = current_time - self.out_of_range_start_time
                if elapsed_seconds >= self.config.out_of_range_secs:
                    self.logger().info(
                        f"Price has been out of range for {elapsed_seconds:.0f} seconds (threshold: {self.config.out_of_range_secs} seconds)"
                    )
                    self.logger().info("Closing position...")
                    await self.close_position()
                else:
                    self.logger().info(
                        f"Price out of range for {elapsed_seconds:.0f} seconds, waiting until {self.config.out_of_range_secs} seconds..."
                    )
            else:
                # Reset timer if price moves back into range
                if self.out_of_range_start_time is not None:
                    self.logger().info("Price moved back into range (with buffer). Resetting timer.")
                    self.out_of_range_start_time = None

                # Add log statement when price is in range
                self.logger().info(
                    f"Price {self.last_price} is within range: {lower_bound_with_buffer:.6f} to {upper_bound_with_buffer:.6f}"
                )

        except Exception as e:
            self.logger().error(f"Error monitoring position: {str(e)}")

    async def close_position(self):
        """Close the concentrated liquidity position"""
        if self.position_closing:
            return

        if self.wallet_address is None:
            self.logger().error("Cannot close position: Failed to get wallet address")
            self.position_closing = False
            return

        if self.connector_instance is None:
            self.logger().error("Connector instance is not available.")
            return

        self.position_closing = True
        position_closed = False

        try:
            if not self.pool_info:
                self.logger().error("Cannot close position: Failed to get current pool info")
                self.position_closing = False
                return

            # Remove liquidity from the pool
            remove_response: RemoveLiquidityResponse = await self.connector_instance.amm_remove_liquidity(
                pool_address=self.pool_info.address,
                wallet_address=self.wallet_address,
                percentage_to_remove=Decimal("100"),
            )

            # Check if any amount removed or not, if not then position has not been closed
            if remove_response.base_token_amount_removed == 0 and remove_response.quote_token_amount_removed == 0:
                self.logger().error("Failed to close position: No tokens removed.")
                self.position_closing = False
                return

            position_closed = True
            self.logger().info(
                f"Position closed successfully! {self.base}: {remove_response.base_token_amount_removed:.6f}, "
                f"{self.quote}: {remove_response.quote_token_amount_removed:.6f}, "
            )

        except Exception as e:
            self.logger().error(f"Error closing position: {str(e)}")

        finally:
            if position_closed:
                self.position_closing = False
                self.position_opened = False
            else:
                self.position_closing = False

    async def check_position_balance(self):
        """Check the balance of the position"""
        if not self.pool_info:
            self.logger().error("Cannot check position balance: Failed to get current pool info")
            return

        if self.wallet_address is None:
            self.logger().error("Cannot check position balance: Failed to get wallet address")
            return

        if self.connector_instance is None:
            self.logger().error("Connector instance is not available.")
            return

        try:
            pool_balance = await self.connector_instance.amm_get_balance(
                pool_address=self.pool_info.address,
                wallet_address=self.wallet_address,
            )
            self.pool_balance = pool_balance

        except Exception as e:
            self.logger().error(f"Error checking position balance: {str(e)}")

    def format_status(self) -> str:
        """Format status message for display in Hummingbot"""
        if not self.connector_ready:
            return "Connector is not available. Please check your connection."

        if not self.wallet_address:
            return "No wallet found yet."

        if self.pool_info is None:
            return "No pool info found yet."

        lines = []

        if self.position_opened:
            lines.append(f"Position is open on XRPL: {self.config.trading_pair} pool {self.pool_info.address}")
            lines.append(f"Position price range: {self.position_lower_price:.6f} to {self.position_upper_price:.6f}")
            lines.append(f"Current price: {self.last_price}")

            if self.out_of_range_start_time:
                elapsed = time.time() - self.out_of_range_start_time
                lines.append(f"Price out of range for {elapsed:.0f}/{self.config.out_of_range_secs} seconds")

            if self.pool_balance:
                lines.append("Pool balance:")
                lines.append(f"  {self.pool_balance['base_token_lp_amount']:.5f} {self.base}")
                lines.append(f"  {self.pool_balance['quote_token_lp_amount']:.5f} {self.quote}")
                lines.append(f"  {self.pool_balance['lp_token_amount']:.5f} LP tokens")
                lines.append(f"  {self.pool_balance['lp_token_amount_pct']:.5f} LP token percentage")
        elif self.position_opening:
            lines.append(f"Opening position on {self.config.trading_pair} pool {self.pool_info.address} ...")
        elif self.position_closing:
            lines.append(f"Closing position on {self.config.trading_pair} pool {self.pool_info.address} ...")
        else:
            lines.append(f"Monitoring {self.config.trading_pair} pool {self.pool_info.address}")
            lines.append(f"Current price: {self.last_price}")
            lines.append(f"Target price: {self.config.target_price}")
            condition = "rises above" if self.config.trigger_above else "falls below"
            lines.append(f"Will open position when price {condition} target")

        return "\n".join(lines)

```

Shown in full with attribution under the source's licence. Licence: Apache-2.0

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.