← 返回文章列表
July 18, 2026
5 分钟阅读

面向量化的 Uniswap v3:从第一性原理理解集中流动性与 tick 数学

面向量化的 Uniswap v3:从第一性原理理解集中流动性与 tick 数学
#Uniswap
#DeFi
#AMM
#集中流动性
#做市
#LVR
#量化
#以太坊

Uniswap v3 上的一个 LP 头寸,本质上是伪装成区间订单的限价订单簿挂单。你在两个价格之间存入流动性,就等于承诺:价格向下穿越你的区间时买入资产,价格向上穿越时卖出资产——这正是 CLOB 上一组静止的限价挂单所做的事情。但合约并不存储价格、数量或订单簿。它只存储三个数字:一个 Q64.96 定点格式的平方根价格、一个整数 tick 索引,以及一个聚合流动性值 LL。如果你想像分析做市模型中的报价那样去分析一个 v3 头寸——库存、成交价、逆向选择——你就必须能够在这些链上原语与它们所编码的交易概念之间来回转换。本文将从第一性原理构建这套转换,遵循 Uniswap v3 白皮书(Adams、Zinsmeister、Salem、Keefer、Robinson,2021)与核心合约,并落脚于如今每一场严肃的 LP 讨论都会绕到的起点:损失对再平衡(loss-versus-rebalancing)。

从 x·y = k 到虚拟储备

Uniswap v2 是恒定乘积做市商:一个资金池持有 token0 的储备 xx 和 token1 的储备 yy,并在每一次交换时强制满足

xy=kx \cdot y = k

以 token1 计价的 token0 边际价格为 P=y/xP = y/x,而流动性被均匀铺在整条价格轴 (0,)(0, \infty) 上。这在资本效率上是极度浪费的:一个只在 0.999 到 1.001 之间交易的稳定币交易对,会把超过 99% 的资本摆在永远不会成交的价格上。

v3 的做法是让每个 LP 把自己的资本限制在一个区间 [pa,pb][p_a, p_b] 内。在区间之内,该头寸的行为必须与一个 v2 资金池完全一致——相同的绑定曲线、相同的边际定价——但只使用覆盖该区间所需的那部分储备。白皮书用虚拟储备将这一点形式化:头寸表现得仿佛持有 v2 风格的储备 (xv,yv)(x_v, y_v),落在曲线 xvyv=L2x_v \cdot y_v = L^2 上,而真实储备则是虚拟储备减去该头寸在区间边界处会持有的那部分:

(x+Lpb)(y+Lpa)=L2\left(x + \frac{L}{\sqrt{p_b}}\right)\left(y + L\sqrt{p_a}\right) = L^2

这就是白皮书的 2.2 式,也是 v3 中最重要的一个方程。经过平移的曲线与坐标轴相切:在 P=pbP = p_b 处真实 xx 储备归零(头寸 100% 为 token1),在 P=paP = p_a 处真实 yy 储备归零(100% 为 token0)。在区间之外头寸进入惰性状态——变成某一种代币的固定持仓,不再赚取任何收益。

Uniswap v2 恒定乘积曲线与展示虚拟储备和真实储备的 v3 平移曲线的对比

参数 LL 被称为流动性,是取代 kk 的不变量。它被定义为 L=kL = \sqrt{k},并且白皮书给出了一个清晰明确的解释:流动性即"每单位平方根价格所对应的虚拟储备"。在区间内的任意价格 PP 处,虚拟储备为

xv=LP,yv=LPx_v = \frac{L}{\sqrt{P}}, \qquad y_v = L\sqrt{P}

为什么状态变量是 √P

v3 的核心并不追踪价格,而是追踪 P\sqrt{P},以 sqrtPriceX96 存储,这是一个无符号的 Q64.96 定点数:

sqrtPriceX96=P296\texttt{sqrtPriceX96} = \sqrt{P} \cdot 2^{96}

这里的 PP原始价格:每单位 token0 基础单位对应多少 token1 基础单位,包含小数位在内(下文详述)。之所以取平方根,并不是为了省 gas,而是出于代数上的必然。对虚拟储备恒等式求微分,就得到两条基本的交换方程,它们实现在 SqrtPriceMath 库中:

Δy=LΔP,Δx=LΔ ⁣(1P)\Delta y = L \cdot \Delta\sqrt{P}, \qquad \Delta x = L \cdot \Delta\!\left(\frac{1}{\sqrt{P}}\right)

两种代币的增量都是平方根价格(或其倒数)的线性函数,LL 作为比例常数。因此,一次落在单个 tick 内的交换既不需要对曲线求逆,也不需要牛顿迭代——只需一次乘法来移动 P\sqrt{P},再两次乘法来计算数量。当一次交换足够大,把价格推过某个已初始化的 tick 时,资金池会跨越它,加上或减去该处引用的净流动性(liquidityNet),然后以新的聚合 LL 继续。从全局看,资金池是一个分段恒定乘积的 AMM:在相邻的已初始化 tick 之间 LL 为常数,而在 tick 处发生跳变。

对做市商而言,这才是正确的心智模型:资金池的聚合 L(P)L(P) 曲线就是订单簿深度在 DEX 上的等价物。CLOB 深度以每个价位上的单位数量报出,而 AMM 深度是每个 tick 上的 LL——两者之间的换算正是上面那个 Δy=LΔP\Delta y = L\Delta\sqrt{P} 恒等式。

Tick:一张对数间隔的价格网格

区间不能从任意价格开始或结束;它们必须对齐到 tick。tick ii 对应价格

p(i)=1.0001ip(i)=1.0001i/2p(i) = 1.0001^i \quad\Longrightarrow\quad \sqrt{p(i)} = 1.0001^{i/2}

因此每个 tick 与其相邻 tick 相差一个基点——不是绝对意义上的,而是相对意义上的。这种对数间隔是刻意设计的:一张固定加法网格对于一个交易价格为 $0.0001 的代币而言会粗糙得离谱,对于价格为 $100{,}000 的代币又会精细得离谱;而几何网格在任何价格量级上都给出统一的 1 bp 分辨率。TickMath 库支持双向转换——getSqrtRatioAtTickgetTickAtSqrtRatio——它在预计算常量上做位运算,而不是去调用指数函数。tick 索引受限于 MIN_TICK = -887272MAX_TICK = 887272,覆盖了价格区间 [2128,2128][2^{-128}, 2^{128}]:对任何能存在于 uint256 运算中的代币对而言都足够宽。

并非每个 tick 都可用。每个费率档都规定了一个 tick 间隔,头寸只能使用能被它整除的 tick:

费率档 tick 间隔 最小区间宽度 典型用途
0.01% 1 ~1 bp 稳定币/稳定币
0.05% 10 ~10 bp 稳定币对、ETH/稳定币
0.30% 60 ~60 bp 主流币
1.00% 200 ~2% 小众币/高波动币

0.05%/0.30%/1% 三档在 2021 年 5 月上线时就已存在;0.01% 档由治理投票于 2021 年 11 月新增。高费率池采用更粗的间隔,能压低潜在可跨越 tick 的数量,并把交换 gas 成本控制在有限范围内。

有一个小数位陷阱,每个人都会被坑一次:链上的原始价格是以基础单位计的每 token0 对应多少 token1。在主网的 USDC/WETH 池中,token0 是 USDC(6 位小数),token1 是 WETH(18 位小数),因此人类可读的每 ETH $3{,}000 对应的原始价格是 1012/30003.333×10810^{12}/3000 \approx 3.333 \times 10^{8} WETH-wei 每 USDC-单位,落在 tick

i=log1.0001 ⁣(3.333×108)=196,256i = \left\lfloor \log_{1.0001}\!\left(3.333\times10^{8}\right) \right\rfloor = 196{,}256

对应 sqrtPriceX961.4465×1033\texttt{sqrtPriceX96} \approx 1.4465 \times 10^{33}。如果你的监控面板上 USDC/WETH 的 tick 显示在 196k 附近,你现在就知道原因了——而且人类可读价格是 (sqrtPriceX96/296)210dec0dec1\left(\texttt{sqrtPriceX96}/2^{96}\right)^2 \cdot 10^{\,\text{dec}_0 - \text{dec}_1},视需要取倒数。

从 (L, 价格区间) 求代币数量:精确公式

本节的核心交付物:给定一个在 [pa,pb][p_a, p_b] 上、流动性为 LL 的头寸和当前价格 PP,它持有什么?把交换方程在区间上积分,得到三种情形:

价格在区间之下PpaP \le p_a)——头寸 100% 为 token0:

x=L(1pa1pb),y=0x = L\left(\frac{1}{\sqrt{p_a}} - \frac{1}{\sqrt{p_b}}\right), \qquad y = 0

价格在区间之上PpbP \ge p_b)——头寸 100% 为 token1:

x=0,y=L(pbpa)x = 0, \qquad y = L\left(\sqrt{p_b} - \sqrt{p_a}\right)

价格在区间之内pa<P<pbp_a < P < p_b):

x=L(1P1pb),y=L(Ppa)x = L\left(\frac{1}{\sqrt{P}} - \frac{1}{\sqrt{p_b}}\right), \qquad y = L\left(\sqrt{P} - \sqrt{p_a}\right)

这正是 SqrtPriceMath.getAmount0DeltagetAmount1Delta 所计算的内容(带有定向取整——合约始终朝不利于用户的方向取整,如果你在回测器里复现这一逻辑并对 wei 级别的差异感到困惑,这个细节就很重要)。

from math import sqrt

def position_amounts(L: float, pa: float, pb: float, P: float):
    """Token amounts held by a v3 position (float model; core uses Q96 ints)."""
    sa, sb, sp = sqrt(pa), sqrt(pb), sqrt(P)
    if P <= pa:
        return L * (1/sa - 1/sb), 0.0
    if P >= pb:
        return 0.0, L * (sb - sa)
    return L * (1/sp - 1/sb), L * (sp - sa)

完整算例:ETH/USDC,区间 [2500, 3500]

假设 ETH 的交易价格为 P=3000P = 3000 USDC,你想把 1 ETH 存入区间 [2500,3500][2500, 3500]。平方根:2500=50\sqrt{2500} = 503000=54.7723\sqrt{3000} = 54.77233500=59.1608\sqrt{3500} = 59.1608

ETH 这一腿确定了 LL

L=xPpbpbP=1×54.7723×59.160859.160854.7723=738.37L = \frac{x \cdot \sqrt{P}\,\sqrt{p_b}}{\sqrt{p_b} - \sqrt{P}} = \frac{1 \times 54.7723 \times 59.1608}{59.1608 - 54.7723} = 738.37

USDC 这一腿随之得出:

y=L(Ppa)=738.37×(54.772350)=3,523.69 USDCy = L\left(\sqrt{P} - \sqrt{p_a}\right) = 738.37 \times (54.7723 - 50) = 3{,}523.69 \text{ USDC}

于是铸造这个头寸需要 1 ETH + 3,523.69 USDC,总价值 $6,523.69,注意两条腿并非 50/50——其分配取决于 PP 落在区间内的位置(一个不对称区间恰恰是你表达方向性观点、或用单一代币下一个纯"区间订单"的方式)。

现在把价格推向两端边界:

  • P=2500P = 2500头寸已完全转换为 ETH:x=738.37×(1/501/59.1608)=2.2867x = 738.37 \times (1/50 - 1/59.1608) = 2.2867 ETH,价值 $5,716.68。你在下跌途中以流动性加权平均价 3523.69 / 1.2867 \approx \2{,}739$ 买入了 1.2867 ETH。
  • P=3500P = 3500它已完全转换为 USDC:y=738.37×(59.160850)=6,764.06y = 738.37 \times (59.1608 - 50) = 6{,}764.06 USDC。你在上涨途中以平均 $3,240 的价格卖出了你的 ETH。

这就把区间订单的解读落到了实处:该头寸就是一梯从 3000 向下到 2500 的买单、以及从 3000 向上到 3500 的卖单,每个 tick 上的规模与 LL 成正比。而你所获得报酬的来源正是"集中"本身:一个用同样 $6,523.69 资本铺满全区间的 v2 风格头寸,其 Lv2=V/(2P)=59.55L_{v2} = V/(2\sqrt{P}) = 59.55——集中头寸每个 tick 报出的深度多出 12.4 倍,并且(在区间内时)单位资本赚取费用的速率也是 12.4 倍。

费用记账:feeGrowthGlobal 与 feeGrowthInside

v3 的费用不会复利进头寸内(这是相对 v2 的刻意改变,v2 中费用会再投资进 k\sqrt{k})。它们按代币分别、并行地累积,而这套记账是 O(1) 簿记的一件小小的杰作,值得理解,因为每一条 LP 分析流水线都会重新实现它。

资金池维护两个全局累加器,feeGrowthGlobal0X128feeGrowthGlobal1X128:自资金池创建以来每单位流动性累计的费用,以 Q128.128 定点格式存储。每次交换时,费用金额(从输入代币中收取)会除以当前在区间内LL,然后加进累加器。这正是 v3 经济学那条铁律背后的机制:费用只累积给交换发生那一刻处于区间内的流动性。区间外的头寸不只是惰性库存——它们在未激活期间赚取的费用恰好为零。

为了把全局增长中正确的那一片归属给一个有限区间,每个已初始化的 tick ii 存储 feeGrowthOutside0/1X128——相对于当前价格发生在该 tick 另一侧的费用增长(每次跨越该 tick,这个值的含义都会翻转,这正是这套方案能做到 O(1) 的原因)。那么对于一个在 [i,iu][i_\ell, i_u] 上、当前 tick 为 ici_c 的头寸:

fbelow(i)={fo(i)icifgfo(i)ic<ifabove(iu)={fo(iu)ic<iufgfo(iu)iciuf_{\text{below}}(i_\ell) = \begin{cases} f_o(i_\ell) & i_c \ge i_\ell \\ f_g - f_o(i_\ell) & i_c < i_\ell \end{cases} \qquad f_{\text{above}}(i_u) = \begin{cases} f_o(i_u) & i_c < i_u \\ f_g - f_o(i_u) & i_c \ge i_u \end{cases} finside=fgfbelow(i)fabove(iu)f_{\text{inside}} = f_g - f_{\text{below}}(i_\ell) - f_{\text{above}}(i_u)

每个头寸都存储一份快照 feeGrowthInsideLast,而未领取的费用就是

fees owed=Lfinsidenowfinsidelast2128\text{fees owed} = L \cdot \frac{f_{\text{inside}}^{\text{now}} - f_{\text{inside}}^{\text{last}}}{2^{128}}

在头寸每次被触碰时惰性更新。这带来两个实际后果。第一,feeGrowthInside 的增量是衡量一个头寸费用收入唯一诚实的办法——每当价格在你的区间边界附近游走时,对资金池层面的成交量采样、再按你占 TVL 的份额去按比例分摊,都会算错。第二,由于费用是以 tokensOwed 的形式搁置而非复利,已实现的 LP 收益会带有一个 v2 所没有的现金拖累项;自动复利金库存在的意义恰恰就是把这一点套利掉(减去它们自己收取的费用)。

feeGrowthGlobal、区间边界 tick 上的 feeGrowthOutside,以及 feeGrowthInside 相减过程的示意图

损益结构:一份你(也许)能收费持有的卖出跨式

在区间内,头寸价值作为价格的函数为

V(P)=x(P)P+y(P)=L(2PpaPpb)V(P) = x(P) \cdot P + y(P) = L\left(2\sqrt{P} - \sqrt{p_a} - \frac{P}{\sqrt{p_b}}\right)

关于 PP 是凹的——2LP2L\sqrt{P} 这一项就是全部要害。在区间之外它变为线性:下方斜率为 x(pa)x(p_a)(你是一袋固定 ETH 的多头),上方斜率为 0(你在 USDC 中处于平仓状态)。把我们的算例在整个区间上跑一遍,并与单纯持有所存入的代币做对比:

PP(USDC/ETH) LP 价值 HODL 价值 偏离
2500 5,716.68 6,023.69 −307.02
2750 6,200.4 6,273.69 −73.3
3000 6,523.69 6,523.69 0
3250 6,706.3 6,773.69 −67.4
3500 6,764.06 7,023.69 −259.63

LP 在两个方向上都跑输 HODL,只有在铸造价格处才与之持平。上行封顶、下行放大参与、在执行价处相对价值最大……这正是一份卖出跨式的损益结构(更精确地说,一旦把线性尾部相对 HODL 基准做净化处理,就是对一条跨越 [pa,pb][p_a, p_b] 执行价的期权带的空头)。费用流就是权利金。收窄区间就是选择更紧的执行价:在区间内时单位时间的权利金更多,但价格一旦移动,偏离来得更快也更深。每个 v3 LP 都是一名卖出波动率的交易者,无论他们是否主动选择如此——这正是 Avellaneda–Stoikov 为订单簿做市商形式化的库存风险权衡的链上孪生兄弟,其中区间宽度扮演着报价价差的角色。

LP 头寸价值与 HODL 在整个价格区间上的对比,展示出类似卖出跨式的凹形损益结构

那张表里那道缺口的传统名称是无常(偏离)损失,但 IL-对-HODL 是一个有缺陷的基准:它把你作为流动性提供者承担的损失,和你本来无论如何都要背负的市场风险混为一谈。更锋利的分解来自 Milionis、Moallemi、Roughgarden 与 Zhang(2022)的"自动做市与损失对再平衡"(arXiv:2208.06046)。不要拿 LP 去对标 HODL,而要对标一个再平衡组合:它在每一瞬间都持有与资金池相同的代币数量——但按无摩擦的外部市场价格成交,而不是与套利者对手成交。二者之差就是 LVR(读作"lever"):LP 损益中纯属逆向选择的那一部分,是在每次价格移动后付给那些狙击 AMM 陈旧报价的套利者的。在波动率为 σ\sigma 的几何布朗价格下,LVR 以如下瞬时速率累积

(σ,P)=σ2P22x(P)\ell(\sigma, P) = \frac{\sigma^2 P^2}{2}\,|x'(P)|

——用作者们的话说,这是一条"AMM 版的 Black–Scholes 公式",其中 x(P)|x'(P)| 是资金池在当前价格处的边际深度。对于全区间恒定乘积池,它坍缩为那个著名的每单位时间池价值的 σ28\frac{\sigma^2}{8}:在 5% 的日波动率下,资金池每天大约有 3.1 bp 流失给套利者,无论有没有费用都是如此。集中会放大 x(P)|x'(P)|——我们上面那个 12.4 倍深度的例子,在区间内时同样是一台约 12.4 倍的 LVR 机器。费用必须跑赢这道流血,头寸才是正期望值;而 LVR(不同于"IL")是一项可对冲、可预测的运行成本,这正是它成为正确记账单位的原因。完整的盈利性演算——费用 APR 对 LVR 对已实现波动率、以及在什么条件下做 LP 优于 delta 对冲的空头期权——是即将发布的无常损失与 LVR 深度剖析的主题,而对冲机制本身则在 v3 LP 策略与对冲中单独展开。

还有一层成本潜伏在再下一层:因为 AMM 的报价只有在有人交易时才更新,每个 LP 头寸也都暴露在围绕这些交易展开的内存池博弈之下——针对那些支付你费用的交换者的三明治攻击,以及逐块兑现你 LVR 的套利捆绑。那片生态被梳理在 MEV 与三明治攻击文章中。

在链上读取资金池状态

以上一切,都可以通过对资金池合约发起的三次廉价调用观测到。

slot0() 把热点状态打包进一个存储槽:sqrtPriceX96、当前 tick、预言机观测索引,以及协议费/锁定标志位。liquidity() 返回当前在区间内的聚合 LL——注意这不是 TVL;它是在当前 tick 处生效的深度参数,当价格跨越某个已初始化的 tick 时会不连续地跳变。ticks(int24) 返回逐 tick 的状态:liquidityGross(引用该 tick 的总 LL)、liquidityNet(从左向右跨越时加上的带符号 LL),以及 feeGrowthOutside 累加器。在已初始化的 tick 上迭代 liquidityNettickBitmap 会告诉你哪些 tick 存在,无需扫描全部 170 万个)就能重建出完整的深度曲线 L(i)L(i)——也就是你的订单簿快照。

from web3 import Web3

w3 = Web3(Web3.HTTPProvider(RPC_URL))
pool = w3.eth.contract(address=POOL, abi=POOL_ABI)  # USDC/WETH 0.05%

sqrt_price_x96, tick, *_ = pool.functions.slot0().call()
L = pool.functions.liquidity().call()

raw_price = (sqrt_price_x96 / 2**96) ** 2          # token1/token0, base units
eth_usdc  = 1 / (raw_price * 10**(18 - 6))          # human USDC per ETH
depth_1tick = L * (1.0001**0.5 - 1) * (sqrt_price_x96 / 2**96)

头寸本身存在两个地方。核心资金池以 (owner, tickLower, tickUpper) 为键索引它们——每个"所有者-区间"三元组对应一个聚合槽。散户和大多数基金则改为通过外围合约 NonfungiblePositionManager(主网:0xC36442b4a4522E871399CD717aBDD847Ab11FE88)铸造,它把每个头寸包裹进一个 ERC-721 NFT,并暴露 positions(tokenId),返回完整的元组:代币、费率档、区间、LLfeeGrowthInsideLasttokensOwed。对于组合监控,这一次调用加上资金池当前的 feeGrowthInside(如前一节所述,从 ticks() 重新计算)就能在不碰任何索引器的情况下给出按市值计价的价值和累计费用——不过对于任何历史数据,你都会想要交换事件回放或一个 subgraph,因为费用增长是路径依赖的,而链上只存储当前的累加器。

在投入资金之前,有两个运营细节值得内化。第一,tick 间隔会把你的策略空间量化:在 0.05% 档你可以放置 10-bp 宽的区间,跑接近真正限价单的东西(在现价上/下方少量铸造,收取费用,跨越后销毁——白皮书明确地把这种"区间订单"用例作为框架),而在 1% 档你的最小区间约为 2% 宽,LOB 类比就变粗糙了。第二,区间订单是一份没有撤单优先权的限价单:如果价格穿越你的区间又折返回来,你就把库存来回换了一趟,把边际收益吐了回去(但保住了费用)。被动区间是你无法撤回的报价——这恰恰就是为什么再平衡频率这个问题、以及回答它所用的 LVR 视角,如此重要。

这把你带到了哪里

v3 技术栈压缩成一句话:价格是一张 1.0001 几何网格上的 tick;深度是 LL,仅凭平方根之差就能换算成代币数量;费用是一个按 LL 计的累加器,你在自己的区间边界处对它做差;而由此得到的头寸是一个卖出波动率的区间订单,其运行成本有一个名字、一条公式,还有一个 arXiv 编号。手握这些原语之后,有趣的问题就变成了量化问题:区间放多宽、多久再平衡一次,以及对于给定的资金池和市场状态,费用能否覆盖 LVR——而这正是本系列接下来要去的地方。

参考文献

  • Adams, H., Zinsmeister, N., Salem, M., Keefer, R., Robinson, D. (2021). Uniswap v3 Core(白皮书)。
  • Uniswap v3 核心库:TickMath.solSqrtPriceMath.solPosition.solTick.sol
  • Milionis, J., Moallemi, C., Roughgarden, T., Zhang, A. L. (2022). Automated Market Making and Loss-Versus-Rebalancing. arXiv:2208.06046。
免责声明:本文提供的信息仅用于教育和参考目的,不构成财务、投资或交易建议。加密货币交易涉及重大损失风险。

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

紧跟市场步伐

订阅我们的时事通讯,获取独家 AI 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。