← Back to articles
July 18, 2026
5 min read

Uniswap v3 for Quants: Concentrated Liquidity and Tick Math from First Principles

Uniswap v3 for Quants: Concentrated Liquidity and Tick Math from First Principles
#uniswap
#defi
#amm
#concentrated liquidity
#market making
#lvr
#quant
#ethereum

An LP position on Uniswap v3 is a limit-order-book range order in disguise. Deposit liquidity between two prices and you have committed to buy the asset on the way down through your range and sell it on the way up — exactly what a grid of resting limit orders does on a CLOB. But the contract does not store prices, quantities, or an order book. It stores three numbers: a square-root price in Q64.96 fixed point, an integer tick index, and an aggregate liquidity value LL. If you want to reason about a v3 position the way you would reason about quotes in a market-making model — inventory, fill prices, adverse selection — you have to be able to translate between those on-chain primitives and the trading concepts they encode. This article builds that translation from first principles, following the Uniswap v3 whitepaper (Adams, Zinsmeister, Salem, Keefer, Robinson, 2021) and the core contracts, and ends where every serious LP discussion now starts: loss-versus-rebalancing.

From x·y = k to virtual reserves

Uniswap v2 is the constant-product market maker: a pool holds reserves xx of token0 and yy of token1 and enforces

xy=kx \cdot y = k

on every swap. The marginal price of token0 in units of token1 is P=y/xP = y/x, and liquidity is spread uniformly over the entire price axis (0,)(0, \infty). That is capital-inefficient in the extreme: a stablecoin pair that trades between 0.999 and 1.001 keeps more than 99% of its capital positioned at prices that will never print.

v3's move is to let each LP restrict their capital to a range [pa,pb][p_a, p_b]. Inside the range, the position must behave exactly like a v2 pool — same bonding curve, same marginal pricing — but using only the reserves needed to cover that range. The whitepaper formalizes this with virtual reserves: the position acts as if it holds v2-style reserves (xv,yv)(x_v, y_v) lying on a curve xvyv=L2x_v \cdot y_v = L^2, while the real reserves are the virtual ones minus what the position would hold at the range boundaries:

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

This is equation 2.2 of the whitepaper, and it is the single most important equation in v3. The translated curve touches the axes: at P=pbP = p_b the real xx reserve hits zero (position is 100% token1), and at P=paP = p_a the real yy reserve hits zero (100% token0). Outside the range the position goes inert — a fixed bag of one token, earning nothing.

Uniswap v2 constant-product curve versus the v3 translated curve showing virtual and real reserves

The parameter LL, called liquidity, is the invariant that replaces kk. It is defined as L=kL = \sqrt{k}, and it has a clean interpretation the whitepaper makes explicit: liquidity is "virtual reserves per unit of square-root price". At any price PP inside the range, the virtual reserves are

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

Why the state variable is √P

v3's core does not track price. It tracks P\sqrt{P}, stored as sqrtPriceX96, an unsigned Q64.96 fixed-point number:

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

where PP is the raw price: token1 base units per token0 base unit, decimals included (more on that below). The reason for the square root is not gas golf, it is algebra. Differentiate the virtual reserve identities and you get the two fundamental swap equations, implemented in the SqrtPriceMath library:

Δ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)

Both token deltas are linear in the square-root price (or its reciprocal), with LL as the constant of proportionality. A swap within one tick therefore needs no curve inversion, no Newton iteration — just one multiplication to move P\sqrt{P}, then two multiplications to compute the amounts. When a swap is large enough to push the price across an initialized tick, the pool crosses it, adds or removes the net liquidity referenced there (liquidityNet), and continues with the new aggregate LL. Globally, the pool is a piecewise-constant-product AMM: constant LL between initialized ticks, jumps at the ticks.

For a market maker, this is the right mental model: the pool's aggregate L(P)L(P) profile is the DEX equivalent of order-book depth. Where CLOB depth is quoted in units per price level, AMM depth is LL per tick — and the conversion is exactly the Δy=LΔP\Delta y = L\Delta\sqrt{P} identity above.

Ticks: a log-spaced price grid

Ranges cannot start and end at arbitrary prices; they must snap to ticks. Tick ii corresponds to price

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

so each tick is one basis point away from its neighbors — not in absolute terms, but relative terms. This log spacing is deliberate: a fixed additive grid would be absurdly coarse for a token trading at $0.0001 and absurdly fine at $100{,}000, while a geometric grid gives uniform 1 bp resolution at every price scale. The TickMath library converts both ways — getSqrtRatioAtTick and getTickAtSqrtRatio — using bit-twiddling over precomputed constants rather than calling an exponential. Tick indices are bounded by MIN_TICK = -887272 and MAX_TICK = 887272, which covers the price range [2128,2128][2^{-128}, 2^{128}]: wide enough for any token pair that can exist in uint256 arithmetic.

Not every tick is usable. Each fee tier imposes a tick spacing, and positions may only use ticks divisible by it:

Fee tier Tick spacing Min range width Typical use
0.01% 1 ~1 bp stable/stable
0.05% 10 ~10 bp stable pairs, ETH/stables
0.30% 60 ~60 bp majors
1.00% 200 ~2% exotic / volatile

The 0.05%/0.30%/1% tiers shipped at launch in May 2021; the 0.01% tier was added by governance vote in November 2021. Coarser spacing on high-fee pools keeps the number of potentially crossable ticks down and swap gas costs bounded.

One decimals trap that bites everyone once: the raw on-chain price is token1 per token0 in base units. In the mainnet USDC/WETH pool, token0 is USDC (6 decimals) and token1 is WETH (18 decimals), so a human price of $3{,}000 per ETH corresponds to a raw price of 1012/30003.333×10810^{12}/3000 \approx 3.333 \times 10^{8} WETH-wei per USDC-unit, which lives at tick

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

with sqrtPriceX961.4465×1033\texttt{sqrtPriceX96} \approx 1.4465 \times 10^{33}. If your monitoring dashboard shows a tick near 196k for USDC/WETH, you now know why — and that the human price is (sqrtPriceX96/296)210dec0dec1\left(\texttt{sqrtPriceX96}/2^{96}\right)^2 \cdot 10^{\,\text{dec}_0 - \text{dec}_1} inverted as needed.

Token amounts from (L, price range): the exact formulas

The core deliverable of this section: given a position with liquidity LL on [pa,pb][p_a, p_b] and current price PP, what does it hold? Integrating the swap equations across the range gives three cases:

Price below range (PpaP \le p_a) — the position is 100% token0:

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

Price above range (PpbP \ge p_b) — the position is 100% token1:

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

Price in range (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)

These are exactly what SqrtPriceMath.getAmount0Delta and getAmount1Delta compute (with directed rounding — the contract always rounds against the user, a detail that matters if you replicate this in a backtester and wonder about wei-level discrepancies).

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)

Worked example: ETH/USDC, range [2500, 3500]

Suppose ETH trades at P=3000P = 3000 USDC and you want to deposit 1 ETH into the range [2500,3500][2500, 3500]. Square roots: 2500=50\sqrt{2500} = 50, 3000=54.7723\sqrt{3000} = 54.7723, 3500=59.1608\sqrt{3500} = 59.1608.

The ETH leg pins down 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

The USDC leg then follows:

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}

So minting this position takes 1 ETH + 3,523.69 USDC, total value $6,523.69, and note the legs are not 50/50 — the split depends on where PP sits inside the range (an asymmetric range is precisely how you express a directional view, or place a pure "range order" with one token).

Now walk the price to the boundaries:

  • At P=2500P = 2500 the position has fully converted to ETH: x=738.37×(1/501/59.1608)=2.2867x = 738.37 \times (1/50 - 1/59.1608) = 2.2867 ETH, worth $5,716.68. You bought 1.2867 ETH on the way down at a liquidity-weighted average price of 3523.69 / 1.2867 \approx \2{,}739$.
  • At P=3500P = 3500 it has fully converted to USDC: y=738.37×(59.160850)=6,764.06y = 738.37 \times (59.1608 - 50) = 6{,}764.06 USDC. You sold your ETH on the way up at an average of $3,240.

That is the range-order reading made concrete: the position is a ladder of bids from 3000 down to 2500 and asks from 3000 up to 3500, with size per tick proportional to LL. And concentration is what you are paid in: a full-range v2-style position with the same $6,523.69 of capital would have Lv2=V/(2P)=59.55L_{v2} = V/(2\sqrt{P}) = 59.55 — the concentrated position quotes 12.4× more depth per tick, and (while in range) earns fees at 12.4× the rate per dollar of capital.

Fee accounting: feeGrowthGlobal and feeGrowthInside

v3 fees do not compound into the position (a deliberate break from v2, where fees reinvested into k\sqrt{k}). They accrue side-by-side, per token, and the accounting is a small masterpiece of O(1) bookkeeping worth understanding because every LP analytics pipeline reimplements it.

The pool maintains two global accumulators, feeGrowthGlobal0X128 and feeGrowthGlobal1X128: cumulative fees per unit of liquidity since pool inception, in Q128.128 fixed point. On each swap, the fee amount (taken from the input token) is divided by the current in-range LL and added to the accumulator. This is the mechanism behind the cardinal rule of v3 economics: fees accrue only to liquidity that is in range at the moment of the swap. Out-of-range positions are not merely inert inventory — they earn exactly zero while inactive.

To attribute the right slice of global growth to a finite range, each initialized tick ii stores feeGrowthOutside0/1X128 — the fee growth that occurred on the other side of the tick relative to the current price (the value flips interpretation each time the tick is crossed, which is what makes the scheme O(1)). Then for a position on [i,iu][i_\ell, i_u] with current 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)

Each position stores a snapshot feeGrowthInsideLast, and uncollected fees are simply

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

updated lazily whenever the position is touched. Two practical consequences. First, feeGrowthInside deltas are the only honest way to measure a position's fee income — sampling pool-level volume and prorating by your share of TVL gets it wrong whenever price wanders around your range boundaries. Second, because fees sit as tokensOwed rather than compounding, realized LP returns have a cash-drag term that v2 didn't have; auto-compounding vaults exist precisely to arbitrage this away (minus their own fee).

Diagram of feeGrowthGlobal, feeGrowthOutside at range boundary ticks, and the feeGrowthInside subtraction

The payoff: a short straddle you get paid to hold (maybe)

Inside the range, position value as a function of price is

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)

Concave in PP — the 2LP2L\sqrt{P} term is the whole story. Outside the range it goes linear: slope x(pa)x(p_a) below (you're long a fixed ETH bag), slope 0 above (you're flat in USDC). Run our worked example across the range and compare against simply holding the deposited tokens:

PP (USDC/ETH) LP value HODL value Divergence
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

The LP underperforms HODL in both directions and matches it only at the minting price. Capped upside, amplified downside participation, maximum relative value at the strike... this is the payoff of a short straddle (more precisely, once the linear tails are netted against the HODL benchmark, a short position in a strip of options struck across [pa,pb][p_a, p_b]). The fee stream is the premium. Concentrating the range is choosing tighter strikes: more premium per unit time while in range, faster and deeper divergence when price moves. Every v3 LP is a short-volatility trader, whether they chose to be or not — the on-chain sibling of the inventory-risk trade-off that Avellaneda–Stoikov formalizes for order-book market makers, with the range width playing the role of the quoted spread.

LP position value versus HODL across the price range showing the concave short-straddle-like payoff

The traditional name for the gap in that table is impermanent (divergence) loss, but IL-versus-HODL is a flawed benchmark: it conflates the loss you took as a liquidity provider with the market risk you'd have carried anyway. The sharper decomposition comes from Milionis, Moallemi, Roughgarden, and Zhang (2022), "Automated Market Making and Loss-Versus-Rebalancing" (arXiv:2208.06046). Benchmark the LP not against HODL but against a rebalancing portfolio that holds, at every instant, the same token quantities as the pool — but trades at the frictionless external market price instead of against arbitrageurs. The difference is LVR ("lever"): the component of LP P&L that is pure adverse selection, paid to arbitrageurs who pick off the AMM's stale quotes after every price move. Under a geometric-Brownian price with volatility σ\sigma, LVR accrues at the instantaneous rate

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

— a "Black–Scholes formula for AMMs", as the authors put it, with x(P)|x'(P)| the marginal depth of the pool at the current price. For the full-range constant-product pool this collapses to the famous σ28\frac{\sigma^2}{8} of pool value per unit time: at 5% daily volatility, roughly 3.1 bp of the pool bleeds to arbitrageurs every day, fees or no fees. Concentration multiplies x(P)|x'(P)| — our 12.4× depth example above is also a ~12.4× LVR machine while in range. Fees must outrun that bleed for the position to be +EV, and LVR (unlike "IL") is a hedgeable, predictable running cost, which is what makes it the right unit of account. The full profitability calculus — fee APR versus LVR versus realized volatility, when LPing beats delta-hedged short options — is the subject of the upcoming impermanent loss and LVR deep dive, and the hedging mechanics get their own treatment in v3 LP strategies and hedging.

One more cost lives one layer down: because AMM quotes update only when someone trades, every LP position is also exposed to the mempool games played around those trades — sandwich attacks against the swappers who pay your fees, and arbitrage bundles that realize your LVR block by block. That ecosystem is mapped in the MEV and sandwich-attack article.

Reading pool state on-chain

Everything above is observable from three cheap calls against the pool contract.

slot0() packs the hot state into one storage slot: sqrtPriceX96, the current tick, oracle observation indices, and the protocol-fee/lock flags. liquidity() returns the current in-range aggregate LL — note this is not TVL; it is the depth parameter active at the current tick and it jumps discontinuously when the price crosses an initialized tick. ticks(int24) returns per-tick state: liquidityGross (total LL referencing the tick), liquidityNet (signed LL added when crossing left-to-right), and the feeGrowthOutside accumulators. Iterating liquidityNet over initialized ticks (the tickBitmap tells you which ones exist without scanning all 1.7 million) reconstructs the full depth profile L(i)L(i) — your order book snapshot.

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)

Positions themselves live in two places. The core pool keys them by (owner, tickLower, tickUpper) — one aggregate slot per owner-range triple. Retail and most funds instead mint through the periphery NonfungiblePositionManager (mainnet: 0xC36442b4a4522E871399CD717aBDD847Ab11FE88), which wraps each position in an ERC-721 NFT and exposes positions(tokenId) returning the full tuple: tokens, fee tier, range, LL, feeGrowthInsideLast, and tokensOwed. For portfolio monitoring, that one call plus the pool's current feeGrowthInside (recomputed from ticks() as in the previous section) gives you mark-to-market value and accrued fees without touching an indexer — though for anything historical you will want swap-event replay or a subgraph, because fee growth is path-dependent and the chain only stores the current accumulators.

Two operational details worth internalizing before deploying capital. First, tick spacing quantizes your strategy space: on the 0.05% tier you can place 10-bp-wide ranges and run something close to a true limit order (mint just above/below spot, collect the fee, burn after the crossing — the whitepaper explicitly frames this "range order" use case), while on the 1% tier your minimum range is ~2% wide and the LOB analogy gets coarse. Second, a range order is a limit order without cancellation priority: if price crosses your range and comes back, you round-trip the inventory and give back the edge (keeping the fees). Passive ranges are quotes you cannot pull — which is exactly why the rebalancing-frequency question, and the LVR lens for answering it, matter so much.

Where this leaves you

The v3 stack, compressed: prices are ticks on a 1.0001-geometric grid; depth is LL, convertible to token amounts through nothing more than differences of square roots; fees are a per-LL accumulator you difference across your range boundaries; and the resulting position is a short-vol range order whose running cost has a name, a formula, and an arXiv number. With the primitives in hand, the interesting questions become quantitative: how wide, how often to rebalance, and whether fees clear LVR for a given pool and regime — which is precisely where this series goes next.

References

  • Adams, H., Zinsmeister, N., Salem, M., Keefer, R., Robinson, D. (2021). Uniswap v3 Core (whitepaper).
  • Uniswap v3 core libraries: TickMath.sol, SqrtPriceMath.sol, Position.sol, Tick.sol.
  • Milionis, J., Moallemi, C., Roughgarden, T., Zhang, A. L. (2022). Automated Market Making and Loss-Versus-Rebalancing. arXiv:2208.06046.
Disclaimer: The information provided in this article is for educational and informational purposes only and does not constitute financial, investment, or trading advice. Trading cryptocurrencies involves significant risk of loss.

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

Stay Ahead of the Market

Subscribe to our newsletter for exclusive AI trading insights, market analysis, and platform updates.

We respect your privacy. Unsubscribe at any time.