Demeter and suggestion about handling uniswap v3 data

zelos
zelos-research
Published in
7 min readNov 11, 2022

TL;DR

  1. Please consider following when you want to process v3 related data

a. tick or bar

b. total liquidity or liquidity map, namely , all unclosed positions

2. 1-min resample bar data with total liquidity can meet most of use case. And we made a test to prove it!

Introduction of Demeter

With the increasing adoption of UniswapV3, more and more traders are getting involved in this market. Professional market makers need to backtest their trading strategies using historical data to verify validity. Demeter is a pure python backtesting package designed for professional traders. ( https://github.com/zelos-alpha/demeter )

This blog will discuss about how Demeter handles v3 data for backtesting, and make a test to show that Demeter is accurate, lightweight and economical tool for backtesting. We also gives suggestions about the data-related choice for for various uniswap project.

Options in Backtesting and Demeter’s Choice

Tick or Bar?

This issue of whether to use Tick data or Bar data has been discussed in analyzing traditional financial data. Here is a simple analogy to get a clearer picture of their advantages and disadvantages:the bar data is similar to using numerical methods of interation(area under curve), which can significantly reduce the data to be processed, but miss parts of the information.

This issue of whether to use Tick data or Bar data has been discussed in analyzing traditional financial data. Here is a simple analogy to get a clearer picture of their advantages and disadvantages:the bar data is similar to using numerical methods of interation(area under curve), which can significantly reduce the data to be processed, but miss parts of the information.

The data on the tick is 100% accurate, but it can lead to extensive data. We take the data of usdc-eth 0.05% pool of Uniswap v3 in Matic as an example. After our tests, choosing 1min to resample will reduce the data mostly, from 5MB to 172.9KB for daily transactions.

By tick means that every action (like SWAP,increasing / remove liquidity) has to recalculate the state. With tick data, the data you load and the process will increase significantly. But microdata has specific application scenarios, such as studying the price impact of orders, JIT, etc.

For our Demeter, we use 1-min resample data for back-testing so that we can complete back-testing without losing too much information.

Total Liquidity or Liquidity Map?

We also need to decide whether to collect the complete position data to construct the liquidity map (rightmost graph) or capture the current liquidity corresponding to the current price.

Total liquidity is a public variable in the pool contract and logged-in pool events for each swap. Using total liquidity will cause errors, but using liquidity map will not. CLAMM(Centralized Liquidity Auto Market Maker) changes current liquidity while the price moves to another price space edge during one swap execution.

Error cases:

  1. If we make a swap, leading the price to move to the next price space, our slippage calculation will be inaccurate.
  2. The swap leads to a move to the range of LP, and the estimate of the LP’s fee income will be inaccurate.

However, reconstructing the liquidity map needs to manage the life cycle of each position, which also brings a lot of extra overhead in the swap calculation.

Considering that most of Demeter’s backtesting actions provide and remove liquidity with few transactions (like, rebalance), we choose total liquidity to trace fee reward and pool state.

Test

Design

To get the performance of Demeter, we made the following test. We choose usdc-eth 0.05% pool of Uniswap v3 in Matic(https://polygonscan.com/address/0x45dda9cb7c25131df268515131f647d726f50608) for following reasons:

  1. The transaction fee on Polygon is cheaper. It leads to more active than eth mainnet
  2. the usdc-eth-0.05 pool has more active liquidity providers and traders than other pools.

We downloaded all the historical data after the pool’s initialization to measure the accuracy of Demeter’s backtest.

We randomly select 200 positions among those full-burn positions and then redo the operation, including adding/removing liquidity and collecting fee in our Demeter backtest environment.

Finally, check the difference between backtested net value and the actual result. The core test code is as follows:

eth = TokenInfo(name="eth", decimal=18)
usdc = TokenInfo(name="usdc", decimal=6)
pool = PoolBaseInfo(usdc, eth, 0.05, usdc)

class CheckStrategy(Strategy): # a special strategy to replicate all the actions
def __init__(self, strategy_actions):
super().__init__()
self._actions: pd.DataFrame = strategy_actions

def initialize(self): # this will be executed before backtesting start.
for index, action in self._actions.iterrows():
match action.tx_type: # set actions, tirggerd by time.
case "MINT":
self.triggers.append(AtTimeTrigger(action.block_timestamp, self.add_at, action))
case "COLLECT":
self.triggers.append(AtTimeTrigger(action.block_timestamp, self.collect_at, action))
case "BURN":
self.triggers.append(AtTimeTrigger(action.block_timestamp, self.remove_at, action))

def add_at(self, row_data: RowData, *args, **kwargs): # add liquidity
action = args[0]
self.broker.add_liquidity_by_tick(action.tick_lower, # to make a precise simulation, we add by tick
action.tick_upper,
from_unit(action.amount0, usdc),
from_unit(action.amount1, eth),
int(action.sqrtPriceX96)) # price when add liquidity

def remove_at(self, row_data: RowData, *args, **kwargs): # remove liquidity
action = args[0]
self.broker.remove_liquidity(PositionInfo(int(action.tick_lower), int(action.tick_upper)),
liquidity=abs(action.liquidity),
collect=False,
sqrt_price_x96=int(action.sqrtPriceX96))

def collect_at(self, row_data: RowData, *args, **kwargs): # collect fee and funds
action = args[0]
self.broker.collect_fee(PositionInfo(int(action.tick_lower), int(action.tick_upper)))



if __name__ == "__main__":
tokens = pd.read_csv("./chosen_position.csv") # load chosen positions
token_ids = tokens.position_id.unique() # get all position id

for i, token_id in enumerate(token_ids):
actions: pd.DataFrame = tokens.loc[tokens.position_id == token_id].reset_index(drop=True) # get all actions(add,remove liquidity)
start_date: pd.Timestamp = actions.block_timestamp[0]
end_date: pd.Timestamp = actions.block_timestamp[len(actions.index) - 1]
runner = Runner(pool) # init runner
runner.strategy = CheckStrategy(actions) # create a new strategy entity and pass actions to it
usdc_amount, eth_amount = get_mint_sum_amount(actions)
runner.set_assets([Asset(usdc, usdc_amount), Asset(eth, eth_amount)]) # set initial amount
runner.data_path = "../data"
runner.load_data(ChainType.Polygon.name, "0x45dda9cb7c25131df268515131f647d726f50608", start_date.date(),end_date.date()) # load data
runner.run(enable_notify=True) # run starategy
runner.output()

The actual position data is tick data. Demeter simulates it with one-min resample data. The difference between the Demeter net value and the actual net value is calculated as follows:

errorRate = abs(100 * (netValueOnChain — netValueBacktesting) / netValueOnChain)

netValueOnChain = sum(collectAmount0) + finalPrice(sum(collectAmount1))

Result

To get off the influence of position age, we go the distribution by position’s age, shown in the below figure. For the sample, we chose positions with their frequency.

The final test results are highly consistent with the data on the chain. The table below lists the top 30 positions with the most significant error rate among the 200 samples.

As mentioned above, most of the errors are close to 0. Only 6 test samples have an error greater than 0.5%, and 13 test samples have an error greater than 0.1%. The simulation result is satisfactory. The error distribution is shown in the figure above, where the vertical axis is the number of positions. The horizontal axis is the error rate.

Suggestion on Data

When you want to develop or research a v3 related project, you should consider what kind of data you need. You need to weigh the amount of data, data processing speed, and your needs. We have made a matrix to help you make the right decision. For example, in Uniswap’s analysis of JIT, you should consider collecting all tick data from the beginning because the truth you want only exists there.

Considering that our users, quant is not needy for real-time performance and weighing the cost, Demeter chose to resample the 1-minute bar data and only provide the current total liquidity data to meet users’ needs as much as possible. We are delighted with this choice after this test.

Another angle is whether your user needs are general purpose. If needs are very stable, you can consider using an event log to handle them. Although the cost in the early stage will be much higher, you need to understand the relationship between events and related contracts deeply.

Zelos will develop a Docker with Demeter in the future to facilitate the download of one-minute resample data, get rid of the dependence on Bigquery, and broaden the usage scenarios of more EVM chains. A more long-term plan is to use events to reconstruct v3 pools. If you are interested in the discussion above, don’t hesitate to get in touch with zelos@antalpha.com.

Appendix

about Demeter processing speed

Since Demeter uses one-min resampled, the burden on the system is significantly reduced, dramatically improving backtest speed.

Through the progress bar displayed during the backtesting process, you can see the pace of the progress bar in this test’s log.

The laptop we use is Ryzen R7 with a 4800U CPU and 16G memory. The processing speed of the position during the backtest is shown in the figure. It handles 10023 rows of data every minute. It means it only takes 52 seconds to backtest one-year data.

--

--