Active Liquidity Provisioning

Background

Active liquidity provisioning (LP) is a strategy that involves providing liquidity to a decentralized exchange (DEX) like Uniswap and actively managing the liquidity to maximize returns. Unlike passive LPing, where liquidity is distributed across the entire price range of an asset pair, active LPing concentrates liquidity in a specific price range, where trades are most likely to occur. This allows liquidity providers (LPs) to earn higher fees with the same amount of capital by focusing liquidity in a narrower band.

The downside of concentrated liquidity is that the price of the asset can move outside the range where liquidity is concentrated, causing LPs to no longer earn fees and potentially lose money due to impermanent loss. To mitigate this, liquidity providers can actively manage their liquidity by adjusting the tick range of their liquidity position once the price moves outside the range.

Active LP strategy on Uniswap

On Uniswap, ticks correspond to price of the asset. So by tick range, we mean the price range where liquidity is concentrated.

The chart shows the price movement of ETH/USD over time. The black rectangles overlaying the chart represent specific price ranges chosen by our strategy to concentrate the liquidity. The height of the rectangles represent the tick range of the liquidity position and the width is the duration of the position.

It's important to balance the benefits of a narrow tick range with the need to minimize rebalancing. A narrow range maximizes earnings but requires frequent adjustments. A wider range reduces rebalancing frequency and gas costs, keeping liquidity active during volatility, but doesn't earn as much as fees.


Active LP Strategy

The agent can be in one of five states: idle, rebalanced, invested, withdrawn and collected. The agent first rebalances the tokens it holds to the 50/50 ratio, then provides liquidity to the pool in the active tick range. If the price moves outside the tick range, the agent withdraws the liquidity and collects the fees. It then repeats the process of rebalancing the tokens and providing liquidity.

Screenshot of https://compasslabs.ai/dashboard?example=active_lp

How To Run

Installation

Follow our Getting Started guide to install the dojo library and other required tools.

Then clone the dojo_examples repository and go into the relevant directory.

Terminal
git clone https://github.com/CompassLabs/dojo_examples.git
cd dojo_examples/examples/active_lp

Running

Download the dashboard to view the simulation results. To view example simulation data, download active_lp.db from here and click 'Add A Simulation' on the dashboard.

To run the simulation yourself, use the following command.

Terminal
python run.py

This command will setup your local blockchain, contracts, accounts and agents. You can then access your results on your Dojo dashboard by connecting to a running simulation.


Step-By-Step Explanation

Initialization

We create a new class called ActiveConcentratedLP which inherits from the UniswapV3Policy class. We initialize some variables which are used later in the policy such as:

  • state to keep track of the state of the agent,
  • position_info to keep track of the tick range of our LP position,
  • lp_width to adjust the tick range,
  • swap_volume to track the trading volume of our agent,
  • swap_count to track the frequency of rebalancing our tokens and adjusting the tick range,
policy.py
class ActiveConcentratedLP(UniswapV3Policy):
  """Actively managing LP postions to always stay around the current price."""
 
  def __init__(self, lp_width: int) -> None:
      """Initialize the policy.
 
      :param lp_width: How many ticks to the left and right the liquidity will be
          spread.
      """
      super().__init__()
      self.state: State = State.IDLE
      self.position_info: Union[_PositionInfo, None] = None
      self.lp_width = lp_width
      self.swap_volume = Decimal(0)
      self.swap_count = Decimal(0)
      self.wealth_before = Decimal(0)

Rebalancing Assets

The _get_provide_lp_range method retrieves the current tick range of the pool and returns the tick range where we want to provide liquidity.

This tick range is used in the _rebalance method to return a UniswapV3TradeToTickRange object containing the order to rebalance our tokens to the 50/50 ratio.

We then set the state of the agent to REBALANCED so in the next block, the agent can provide liquidity.

Screenshot of https://compasslabs.ai/dashboard?example=active_lp#signals
policy.py
def _get_provide_lp_range(self, obs: UniswapV3Observation) -> tuple[int, int]:
  lower_active_tick, upper_active_tick = obs.active_tick_range(obs.pools[0])
  tick_spacing = obs.tick_spacing(obs.pools[0])
  return (
      lower_active_tick - self.lp_width * tick_spacing,
      upper_active_tick + self.lp_width * tick_spacing,
  )
 
def _rebalance(self, obs: UniswapV3Observation) -> list[BaseUniswapV3Action]:
  token0, token1 = obs.pool_tokens(obs.pools[0])
  portfolio = self.agent.portfolio()
  self.wealth_before = portfolio[token0] + portfolio[token1] * obs.price(
      token1, token0, obs.pools[0]
  )
  action = UniswapV3TradeToTickRange(
      agent=self.agent,
      pool=obs.pools[0],
      quantities=(portfolio[token0], portfolio[token1]),
      tick_range=self._get_provide_lp_range(obs),
  )
  self.state = State.REBALANCED
  return [action]

Providing Concentrated Liquidity

In the _invest method, we provide liquidity to the pool in the tick range we want to concentrate our liquidity by returning a UniswapV3ProvideQuantities object. We then set the state of the agent to INVESTED and set position_info to the current active tick range. This will be used later in the policy to check if the price has moved outside the tick range in which case the LP position will be withdrawn, fees will be collected and the strategy will repeat.

policy.py
provide_tick_range = self._get_provide_lp_range(obs)
action = UniswapV3ProvideQuantities(
  agent=self.agent,
  pool=obs.pools[0],
  amount0=portfolio[token0],
  amount1=portfolio[token1],
  tick_range=provide_tick_range,
)
self.position_info = _PositionInfo(
  lower_tick=provide_tick_range[0],
  upper_tick=provide_tick_range[1],
)
self.state = State.INVESTED

Checking If Price Has Moved Outside Tick Range

We retrieve the active tick range of the pool using the active_tick_range method of the UniswapV3Observation class.

We compare the lower_active_tick and upper_active_tick with the lower and upper bounds of the tick range we provided liquidity in which is stored in self.position_info. If the active tick range is outside the tick range we provided liquidity in, we withdraw our liquidity and set the state of the agent to WITHDRAWN so that the agent can collect the fees and burn the LP position in the next few blocks and repeat this process.

policy.py
def _withdraw_if_neccessary(
  self, obs: UniswapV3Observation
) -> list[BaseUniswapV3Action]:
  lower_active_tick, upper_active_tick = obs.active_tick_range(obs.pools[0])
 
  if not self.position_info:
      return []
 
  if (lower_active_tick > self.position_info.upper_tick) or (
      upper_active_tick < self.position_info.lower_tick
  ):
      position_id = self.agent.erc721_portfolio()["UNI-V3-POS"][-1]
      provided_lp_stats = obs.nft_positions(position_id)
      action = UniswapV3WithdrawLiquidity(
          agent=self.agent,
          position_id=position_id,
          liquidity=provided_lp_stats["liquidity"],
      )
      self.state = State.WITHDRAWN
      return [action]
  else:
      return []

In the run.py file, we create a pool, a Uniswap environment and an agent to implement the active LPing strategy.

Results

You can download the results to this example below.

We offer a dashboard desktop application for visualizing your simulation results. You can download the file for the desktop application here, or just open the results in our hosted dashboard.