如何通过Uniswap V3子图计算指定池各tick单代币TVL及占比

Uniswap V3 单tick TVL计算方案与偏差修复
核心需求
通过Uniswap V3 Subgraph API计算指定交易池每个tick中两种代币各自的总锁仓价值(TVL),以及各tick TVL占全池总TVL的比例。
现有实现进展
当前通过GraphQL分页拉取指定池的所有tick数据及对应liquidityNet字段,查询语句如下:
var query = "{ticks(first:1000,orderBy:tickIdx,orderDirection:asc,where:{tickIdx_gt:" + lastTickIdx +",poolAddress:\""+id+"\"}){tickIdx liquidityNet}}";
其中lastTickIdx为已拉取的最大tickIdx,作为分页游标使用。
初始计算思路为:从当前tick(currentTick)对应的池全局流动性出发,向价格高低两个方向遍历所有tick,逐tick调整流动性数值,再通过价格公式计算每个tick锁定的代币数量。使用的基础计算公式如下,其中p0、p1为相邻tick对应的价格点,d0、d1为两种代币的精度:
// 计算tick锁定的token0数量 tvl00 = (Math.Sqrt(p0) - Math.Sqrt(p1)) * loopLiquidity * Math.Pow(10, 0 - d0); // 计算tick锁定的token1数量 tvl11 = tvl00 * (Math.Pow(1.0001, curr)) * Math.Pow(10, d0 - d1);
以currentTick=5为例,初始设计的流动性迭代规则为:
2 -> loopLiquidity = tick3处的loopLiquidity - tick2的LiquidityNet 3 -> loopLiquidity = tick4处的loopLiquidity - tick3的LiquidityNet 4 -> loopLiquidity = tick5处的loopLiquidity - tick4的LiquidityNet currentTick 5 -> loopLiquidity = 池全局流动性 6 -> loopLiquidity = tick5处的loopLiquidity + tick6的LiquidityNet 7 -> loopLiquidity = tick6处的loopLiquidity + tick7的LiquidityNet
现存问题
当前tick附近的单tick锁仓值计算结果和Uniswap官方流动性图表展示数值匹配,但全池累加得到的token0、token1总TVL明显低于子图返回的官方TVL,存在固定偏差。
当前使用的实现代码如下:
// 池当前tick double currTick = Convert.ToDouble(currTickx); // 池费率等级 double feeTier = Convert.ToDouble(feeTierx); // token0精度 double d0 = Convert.ToDouble(d0x); // token1精度 double d1 = Convert.ToDouble(d1x); // 存储tickIdx与liquidityNet映射关系的字典 Dictionary<double, double> dict = new Dictionary<double, double>(); foreach (var tick in extractedTicks) { dict[tick.tickIdx] = Convert.ToDouble(tick.liquidityNet); } // tickSpacing(相邻两个有效tick的间隔) double tickSpacing = feeTier / 50; // 当前tick的前一个有效tick double upperTick = currTick - ((currTick % tickSpacing)+tickSpacing)%tickSpacing; // 当前tick的后一个有效tick double lowerTick = upperTick + tickSpacing; // 获取最小、最大tick索引 double minTick = extractedTicks[0].tickIdx; double maxTick = extractedTicks[extractedTicks.Length - 1].tickIdx; // total0为全池token0总TVL,total1为全池token1总TVL double total0 = 0, total1 = 0; // 指定池的全局总流动性 double loopLiquidity = Convert.ToDouble(poolLiquidity); //从lowerTick遍历到maxTick -> 计算锁定的token0价值 for(double curr = lowerTick; curr <= maxTick; curr += tickSpacing) { if (dict.ContainsKey(curr) == true) { loopLiquidity += dict[curr]; loopLiquidity1 += dict1[curr]; } double p0 = Math.Pow(1.0001, curr-tickSpacing); p0 = 1 / p0; double p1 = Math.Pow(1.0001, curr); p1 = 1 / p1; //该tick中token0的锁仓价值 var tvl00 = (Math.Sqrt(p0) - Math.Sqrt(p1)) * loopLiquidity * Math.Pow(10, 0 - d0); // 该tick中token1的锁仓价值 var tvl11 = tvl00 * (Math.Pow(1.0001, curr)) * Math.Pow(10, d0 - d1); total0 += tvl00; } //从upperTick遍历到minTick -> 计算锁定的token1价值 loopLiquidity = Convert.ToDouble(poolLiquidity); dict[upperTick + tickSpacing] = 0; for (double curr = upperTick; curr>=minTick; curr -= tickSpacing) { double k = curr + tickSpacing; if (dict.ContainsKey(curr + tickSpacing) == true) { loopLiquidity -= dict[curr + tickSpacing];} double p0 = Math.Pow(1.0001, curr-tickSpacing); p0 = 1 / p0; double p1 = Math.Pow(1.0001, curr ); p1 = 1 / p1; //该tick中token0的锁仓价值 var tvl00 = (Math.Sqrt(p0) - Math.Sqrt(p1)) * loopLiquidity * Math.Pow(10, 0 - d0); //该tick中token1的锁仓价值 var tvl11 = tvl00 * (Math.Pow(1.0001, curr)) * Math.Pow(10, d0 - d1); total1 += tvl11; } // totalValuelocked0、totalValuelocked1为直接从子图查询到的池官方TVL数值 Console.WriteLine("token0 from lower half => " + total0 + " " + totalValuelocked0); Console.WriteLine("token1 from upper half => " + total1 + " " + totalValuelocked1); // 计算目标为获取Uniswap页面悬浮提示框中展示的每个tick对应的锁仓价值
偏差原因与修正方法
总TVL计算偏低由4个核心错误导致:
- 遍历逻辑漏算活跃区间持仓:把遍历拆成高于currentTick、低于currentTick两段,仅在高价段计算token0、低价段计算token1,完全漏掉了
upperTick到lowerTick之间(即当前价格所处的活跃区间)的持仓,这个区间内是同时持有两种代币的。 - 价格计算逻辑颠倒:Uniswap V3的价格公式为
P = 1.0001^tick,代码中对p0、p1都做了取倒数操作,颠倒了价格上下限,近tick误差不明显,但跨大量tick累加后偏差会被显著放大。 - 代币计算未区分区间场景:每个tick区间的持仓分三种情况:区间完全在当前价格上方时仅持有token0,完全在当前价格下方时仅持有token1,包含当前价格时同时持有两种代币。原有逻辑在非活跃区间同时计算两种代币导致重复累加,在活跃区间又漏算对应代币。
- 代码笔误:代码中出现了未定义的
loopLiquidity1、dict1变量,会直接导致流动性迭代结果错误。
修正后核心计算逻辑
不要拆分上下段遍历,从最小tick到最大tick单次遍历即可,计算时按区间和当前价格的位置关系分别计算代币量,同时用decimal类型替代double避免浮点精度误差:
// 计算当前价格的平方根 decimal sqrtPriceCurrent = (decimal)Math.Sqrt(Math.Pow(1.0001, currTick)); decimal loopLiquidity = Convert.ToDecimal(poolLiquidity); // 存储每个tick的TVL Dictionary<decimal, (decimal tvl0, decimal tvl1)> tickTvlMap = new(); decimal totalTvl0 = 0, totalTvl1 = 0; // 从最小tick单次遍历到最大tick for (decimal curr = minTick; curr <= maxTick; curr += (decimal)tickSpacing) { // 跨过tick下界时更新流动性 if (dict.ContainsKey((double)curr)) { loopLiquidity += (decimal)dict[(double)curr]; } decimal sqrtPriceLower = (decimal)Math.Sqrt(Math.Pow(1.0001, (double)curr)); decimal sqrtPriceUpper = (decimal)Math.Sqrt(Math.Pow(1.0001, (double)(curr + tickSpacing))); decimal amount0 = 0; decimal amount1 = 0; // 分场景计算当前区间的代币数量 if (sqrtPriceCurrent <= sqrtPriceLower) { // 区间在当前价上方,全为token0 amount0 = loopLiquidity * (sqrtPriceUpper - sqrtPriceLower) / (sqrtPriceLower * sqrtPriceUpper); } else if (sqrtPriceCurrent >= sqrtPriceUpper) { // 区间在当前价下方,全为token1 amount1 = loopLiquidity * (sqrtPriceUpper - sqrtPriceLower); } else { // 区间包含当前价,同时持有两种代币 amount0 = loopLiquidity * (sqrtPriceUpper - sqrtPriceCurrent) / (sqrtPriceCurrent * sqrtPriceUpper); amount1 = loopLiquidity * (sqrtPriceCurrent - sqrtPriceLower); } // 按代币精度转换为实际数量 amount0 /= (decimal)Math.Pow(10, d0); amount1 /= (decimal)Math.Pow(10, d1); tickTvlMap[curr] = (amount0, amount1); totalTvl0 += amount0; totalTvl1 += amount1; }
额外注意事项
- 拉取tick数据时要确保拉取到全池所有初始化过(
liquidityNet != 0)的tick,漏拉tick会导致流动性迭代错误。 - 子图返回的官方TVL是按代币当前美元价格折算的总价值,计算得到的代币数量需要乘以当前币价再和官方TVL做对比,不要直接拿数量和价值做比较。
- 计算单tick TVL占比时,用单tick的两种代币价值之和除以全池总TVL即可。
内容的提问来源于stack exchange,提问作者Nishant Goel
相关产品推荐
相关产品推荐

