Uniswap v3 for Quants: Concentrated Liquidity and Tick Math from First Principles
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 . 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 of token0 and of token1 and enforces
on every swap. The marginal price of token0 in units of token1 is , and liquidity is spread uniformly over the entire price axis . 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 . 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 lying on a curve , while the real reserves are the virtual ones minus what the position would hold at the range boundaries:
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 the real reserve hits zero (position is 100% token1), and at the real reserve hits zero (100% token0). Outside the range the position goes inert — a fixed bag of one token, earning nothing.

The parameter , called liquidity, is the invariant that replaces . It is defined as , and it has a clean interpretation the whitepaper makes explicit: liquidity is "virtual reserves per unit of square-root price". At any price inside the range, the virtual reserves are
Why the state variable is √P
v3's core does not track price. It tracks , stored as sqrtPriceX96, an unsigned Q64.96 fixed-point number:
where 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:
Both token deltas are linear in the square-root price (or its reciprocal), with as the constant of proportionality. A swap within one tick therefore needs no curve inversion, no Newton iteration — just one multiplication to move , 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 . Globally, the pool is a piecewise-constant-product AMM: constant between initialized ticks, jumps at the ticks.
For a market maker, this is the right mental model: the pool's aggregate profile is the DEX equivalent of order-book depth. Where CLOB depth is quoted in units per price level, AMM depth is per tick — and the conversion is exactly the identity above.
Ticks: a log-spaced price grid
Ranges cannot start and end at arbitrary prices; they must snap to ticks. Tick corresponds to price
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 : 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 WETH-wei per USDC-unit, which lives at tick
with . If your monitoring dashboard shows a tick near 196k for USDC/WETH, you now know why — and that the human price is inverted as needed.
Token amounts from (L, price range): the exact formulas
The core deliverable of this section: given a position with liquidity on and current price , what does it hold? Integrating the swap equations across the range gives three cases:
Price below range () — the position is 100% token0:
Price above range () — the position is 100% token1:
Price in range ():
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 USDC and you want to deposit 1 ETH into the range . Square roots: , , .
The ETH leg pins down :
The USDC leg then follows:
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 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 the position has fully converted to ETH: 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 it has fully converted to USDC: 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 . And concentration is what you are paid in: a full-range v2-style position with the same $6,523.69 of capital would have — 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 ). 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 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 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 with current tick :
Each position stores a snapshot feeGrowthInsideLast, and uncollected fees are simply
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).

The payoff: a short straddle you get paid to hold (maybe)
Inside the range, position value as a function of price is
Concave in — the term is the whole story. Outside the range it goes linear: slope 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:
| (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 ). 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.

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 , LVR accrues at the instantaneous rate
— a "Black–Scholes formula for AMMs", as the authors put it, with the marginal depth of the pool at the current price. For the full-range constant-product pool this collapses to the famous 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 — 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 — 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 referencing the tick), liquidityNet (signed 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 — 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, , 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 , convertible to token amounts through nothing more than differences of square roots; fees are a per- 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.
Authors
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.