Tick 与价格:Tick索引与价格关系、Tick间距与流动性粒度、TickBitmap数据结构
好,咱们今天来啃一块硬骨头——Tick。说实话,我刚接触Uniswap V3时,也被Tick这套机制绕得有点晕。但搞懂了它,你才算真正理解了集中流动性的灵魂。
Tick是什么?说白了,它就是价格轴上一个个离散的“刻度”。V2里价格是连续的,你可以在任意价格区间添加流动性。V3不行,价格被切成了无数个小格子,你只能在格子里操作。为什么这么设计?为了效率。你想想看,如果价格是连续的,那系统得记录多少信息?用Tick离散化之后,链上存储和计算都变得可行了。
Tick索引与价格关系
每个Tick都有一个唯一的整数索引,记作 i。价格和Tick索引之间,有一个固定的数学关系:
p(i) = 1.0001^i
也就是说,每移动一个Tick,价格变化0.01%。这个0.01%不是随便选的,它是经过精心计算的。我曾在一次审计中看到有人试图修改这个底数,结果整个流动性计算都崩了——嗯,千万别动它。
举个例子:
- Tick 0 对应价格 1.0
- Tick 1 对应价格 1.0001
- Tick -1 对应价格 0.9999
- Tick 887272 对应价格 ≈ 2^128,这是理论上限
- Tick -887272 对应价格 ≈ 2^-128,这是理论下限
实际交易中,我们很少直接操作Tick索引。用户看到的是价格,系统内部转成Tick。转换公式也很简单:
i = log_{1.0001}(price)
在Solidity里,这个对数运算用 TickMath.getTickAtSqrtRatio 实现。我个人习惯在测试时直接打印Tick值,方便调试。
核心要点:Tick索引是离散的价格刻度,每个Tick间隔0.01%。价格和Tick之间是对数关系。
Tick间距与流动性粒度
这里有个关键问题:所有交易对都用同一个Tick间距吗?当然不是。不同的交易对,价格波动幅度不一样。比如ETH/USDC波动小,MEME币波动大。如果都用0.01%的粒度,高波动币种会产生大量Tick穿越,gas费会高得吓人。
Uniswap V3引入了 tickSpacing 的概念。它由池子创建时指定,通常与费率挂钩:
| 费率 | tickSpacing | 说明 |
|---|---|---|
| 0.05% | 10 | 稳定币对,价格波动小 |
| 0.30% | 60 | 主流币对,中等波动 |
| 1.00% | 200 | 高波动币对,如MEME |
tickSpacing = 10 意味着只有索引能被10整除的Tick才是“可用Tick”。也就是说,你只能在Tick 0、10、20、-10、-20这些位置添加流动性。粒度变粗了,但gas费降低了。
我曾经帮一个项目方优化流动性策略,他们一开始用了0.05%费率配tickSpacing=10,结果发现ETH/USDC对频繁触发Tick穿越,gas费占了收益的30%。后来改成0.30%费率配tickSpacing=60,情况好多了。所以,选tickSpacing是个权衡——粒度越细,价格越精准,但成本越高。
经验之谈:如果你做市的是稳定币对,用0.05%费率+tickSpacing=10。如果是主流币,0.30%+60最稳妥。MEME币?直接上1.00%+200,别犹豫。
TickBitmap数据结构
好,现在我们知道Tick是离散的,也知道tickSpacing控制粒度。但系统怎么知道哪些Tick当前有流动性?遍历所有Tick?那gas费会爆炸。
Uniswap V3用了一个非常巧妙的数据结构——TickBitmap。说白了,它是一个位图,用1个bit表示一个Tick是否有流动性。
具体实现是这样的:
mapping(int16 => uint256) public tickBitmap;
每个 int16 键对应一个“字”(word),每个字包含256个bit。每个bit对应一个Tick。比如:
- tickBitmap[0] 的第0位表示Tick 0
- tickBitmap[0] 的第1位表示Tick 1
- tickBitmap[1] 的第0位表示Tick 256
- tickBitmap[-1] 的第255位表示Tick -1
为什么用位图?两个原因:
- 存储效率高:一个uint256就能覆盖256个Tick的状态,gas费极低。
- 查找速度快:找下一个有流动性的Tick时,可以用位运算直接定位,不需要遍历。
查找下一个Tick的算法大致是:
function nextInitializedTickWithinOneWord(
int24 tick,
int24 tickSpacing,
bool lte
) internal view returns (int24 next, bool initialized)
这个函数会从当前Tick开始,在当前字内找下一个已初始化的Tick。如果当前字没有,就跳到相邻的字继续找。整个过程是O(1)的,非常高效。
注意:TickBitmap只记录“是否有流动性”,不记录流动性数量。流动性数量存在另一个映射 mapping(int24 => TickInfo) public ticks 里。别搞混了。
为了让你更直观地理解,我画了一张图:
这张图展示了tickBitmap[0]的前8个bit。绿色表示该Tick有流动性(bit=1),灰色表示没有(bit=0)。当系统从Tick 0开始向右查找时,它会跳过Tick 1(bit=0),直接定位到Tick 2(bit=1)。整个过程就是几个位运算指令,gas费几乎可以忽略不计。
嗯,这里要注意:TickBitmap的查找是单向的。如果你想向左查找,需要反转位图并重新计算。Uniswap V3的 nextInitializedTickWithinOneWord 函数通过 lte 参数控制方向,内部实现很巧妙,建议你去读读源码。
避坑指南:我曾经在写一个流动性聚合器时,直接用了TickBitmap的原始数据做计算,结果发现不同费率池子的tickSpacing不同,位图的索引偏移也不一样。后来我统一用 tick / tickSpacing 作为位图索引,才解决了问题。记住:位图索引 = Tick索引 / tickSpacing。
总结一下今天的内容:Tick是价格离散化的基础,Tick索引和价格是对数关系;tickSpacing控制流动性粒度,不同费率用不同间距;TickBitmap用位图高效管理流动性状态。这三者合在一起,构成了Uniswap V3集中流动性的核心骨架。
搞懂了这些,你就能理解为什么V3的gas费比V2低那么多,也能明白为什么做市商愿意把流动性集中在窄区间里。下一节我们会深入Tick的另一个重要概念——Tick的交叉与价格更新,到时候你会看到这些数据结构是如何在交易过程中协同工作的。