Uniswap v3 для квантов: концентрированная ликвидность и математика тиков с нуля
Позиция LP в Uniswap v3 — это замаскированный диапазонный ордер из книги лимитных заявок. Внесите ликвидность между двумя ценами — и вы обязались покупать актив по мере его падения через ваш диапазон и продавать по мере роста, ровно то, что делает сетка отложенных лимитных ордеров на CLOB. Но контракт не хранит ни цен, ни количеств, ни книги ордеров. Он хранит три числа: квадратный корень из цены в формате с фиксированной точкой Q64.96, целочисленный индекс тика и агрегированное значение ликвидности . Если вы хотите рассуждать о позиции v3 так же, как вы рассуждали бы о котировках в модели маркетмейкинга — инвентарь, цены исполнения, неблагоприятный отбор, — вам нужно уметь переводить между этими ончейн-примитивами и торговыми концепциями, которые они кодируют. Эта статья строит такой перевод с нуля, следуя вайтпейперу Uniswap v3 (Adams, Zinsmeister, Salem, Keefer, Robinson, 2021) и основным контрактам, и заканчивается там, где сейчас начинается любое серьезное обсуждение LP: loss-versus-rebalancing.
От x·y = k к виртуальным резервам
Uniswap v2 — это маркетмейкер с постоянным произведением: пул держит резервы токена0 и токена1 и на каждом свапе поддерживает
Маржинальная цена токена0 в единицах токена1 равна , а ликвидность равномерно размазана по всей ценовой оси . Это крайне неэффективно по капиталу: пара стейблкоинов, торгующаяся между 0.999 и 1.001, держит более 99% своего капитала на ценах, которые никогда не напечатаются.
Ход v3 состоит в том, чтобы позволить каждому LP ограничить свой капитал диапазоном . Внутри диапазона позиция должна вести себя ровно как пул v2 — та же бондинг-кривая, то же маржинальное ценообразование, — но используя только те резервы, которые нужны для покрытия этого диапазона. Вайтпейпер формализует это через виртуальные резервы: позиция ведет себя так, будто держит резервы в стиле v2 , лежащие на кривой , тогда как реальные резервы — это виртуальные минус то, что позиция держала бы на границах диапазона:
Это уравнение 2.2 вайтпейпера, и это единственное самое важное уравнение в v3. Сдвинутая кривая касается осей: при реальный резерв достигает нуля (позиция на 100% в токене1), а при реальный резерв достигает нуля (100% токена0). За пределами диапазона позиция становится инертной — фиксированный набор одного токена, не приносящий ничего.

Параметр , называемый ликвидностью, — это инвариант, заменяющий . Он определяется как , и у него есть чистая интерпретация, которую вайтпейпер делает явной: ликвидность — это "виртуальные резервы на единицу квадратного корня из цены". При любой цене внутри диапазона виртуальные резервы равны
Почему переменная состояния — это √P
Ядро v3 не отслеживает цену. Оно отслеживает , хранимую как sqrtPriceX96 — беззнаковое число с фиксированной точкой Q64.96:
где — сырая цена: базовые единицы токена1 на базовую единицу токена0, с учетом десятичных знаков (об этом ниже). Причина использования квадратного корня — не экономия газа, а алгебра. Продифференцируйте тождества для виртуальных резервов, и вы получите два фундаментальных уравнения свапа, реализованных в библиотеке SqrtPriceMath:
Обе дельты токенов линейны по квадратному корню из цены (или обратному ему), с в роли коэффициента пропорциональности. Поэтому свап внутри одного тика не требует ни обращения кривой, ни итераций Ньютона — только одно умножение, чтобы сдвинуть , затем два умножения, чтобы вычислить суммы. Когда свап достаточно велик, чтобы толкнуть цену через инициализированный тик, пул пересекает его, добавляет или убирает чистую ликвидность, привязанную к нему (liquidityNet), и продолжает с новым агрегированным . Глобально пул — это кусочно-постоянно-произведенческий AMM: постоянное между инициализированными тиками, скачки на тиках.
Для маркетмейкера это правильная ментальная модель: профиль агрегированной ликвидности пула — это DEX-эквивалент глубины книги ордеров. Где глубина CLOB котируется в единицах на ценовой уровень, глубина AMM — это на тик, и перевод — это ровно тождество выше.
Тики: логарифмически размеченная ценовая сетка
Диапазоны не могут начинаться и заканчиваться на произвольных ценах; они должны прилипать к тикам. Тик соответствует цене
так что каждый тик отстоит от соседей на один базисный пункт — не в абсолютном, а в относительном выражении. Эта логарифмическая разметка сделана намеренно: фиксированная аддитивная сетка была бы абсурдно грубой для токена, торгующегося по $0.0001, и абсурдно мелкой при $100{,}000, тогда как геометрическая сетка дает равномерное разрешение 1 б.п. на любом ценовом масштабе. Библиотека TickMath конвертирует в обе стороны — getSqrtRatioAtTick и getTickAtSqrtRatio — используя битовые операции над предвычисленными константами вместо вызова экспоненты. Индексы тиков ограничены значениями MIN_TICK = -887272 и MAX_TICK = 887272, что покрывает ценовой диапазон : достаточно широкий для любой пары токенов, которая может существовать в арифметике uint256.
Не всякий тик пригоден к использованию. Каждый уровень комиссии накладывает шаг тика, и позиции могут использовать только тики, кратные ему:
| Уровень комиссии | Шаг тика | Мин. ширина диапазона | Типичное применение |
|---|---|---|---|
| 0.01% | 1 | ~1 б.п. | стейбл/стейбл |
| 0.05% | 10 | ~10 б.п. | стейбл-пары, ETH/стейблы |
| 0.30% | 60 | ~60 б.п. | мейджоры |
| 1.00% | 200 | ~2% | экзотика / волатильные |
Уровни 0.05%/0.30%/1% появились на запуске в мае 2021 года; уровень 0.01% был добавлен голосованием управления в ноябре 2021 года. Более грубый шаг на пулах с высокой комиссией удерживает число потенциально пересекаемых тиков и газовые затраты на свап в ограниченных рамках.
Одна ловушка десятичных знаков, на которую однажды попадается каждый: сырая ончейн-цена — это токен1 на токен0 в базовых единицах. В майннет-пуле USDC/WETH токен0 — это USDC (6 знаков), а токен1 — WETH (18 знаков), так что человеческая цена $3{,}000 за ETH соответствует сырой цене WETH-wei на единицу USDC, которая живет на тике
с . Если ваш дашборд мониторинга показывает тик около 196k для USDC/WETH, теперь вы знаете почему — и что человеческая цена равна с обращением по необходимости.
Количества токенов из (L, ценового диапазона): точные формулы
Основной результат этого раздела: дана позиция с ликвидностью на и текущей ценой — что она держит? Интегрирование уравнений свапа по диапазону дает три случая:
Цена ниже диапазона () — позиция на 100% в токене0:
Цена выше диапазона () — позиция на 100% в токене1:
Цена внутри диапазона ():
Это ровно то, что вычисляют SqrtPriceMath.getAmount0Delta и getAmount1Delta (с направленным округлением — контракт всегда округляет против пользователя, деталь, которая важна, если вы воспроизводите это в бэктестере и удивляетесь расхождениям на уровне 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 торгуется по USDC, и вы хотите внести 1 ETH в диапазон . Квадратные корни: , , .
Плечо ETH фиксирует :
Плечо USDC тогда следует из:
Итак, минтинг этой позиции требует 1 ETH + 3,523.69 USDC, общей стоимостью $6,523.69, и обратите внимание, что плечи не 50/50 — распределение зависит от того, где сидит внутри диапазона (асимметричный диапазон — это ровно способ выразить направленный взгляд или разместить чистый "диапазонный ордер" одним токеном).
Теперь проведем цену к границам:
- При позиция полностью конвертировалась в ETH: ETH стоимостью $5,716.68. Вы купили 1.2867 ETH по мере падения по средневзвешенной по ликвидности цене 3523.69 / 1.2867 \approx \2{,}739$.
- При она полностью конвертировалась в USDC: USDC. Вы продали свой ETH по мере роста в среднем по $3,240.
Вот прочтение диапазонного ордера, доведенное до конкретики: позиция и есть лестница бидов от 3000 вниз до 2500 и асков от 3000 вверх до 3500, с размером на тик, пропорциональным . И концентрация — это то, за что вам платят: полнодиапазонная позиция в стиле v2 с теми же $6,523.69 капитала имела бы — концентрированная позиция котирует в 12.4× больше глубины на тик и (пока в диапазоне) зарабатывает комиссии по ставке в 12.4× выше на доллар капитала.
Учет комиссий: feeGrowthGlobal и feeGrowthInside
Комиссии v3 не капитализируются в позицию (намеренный разрыв с v2, где комиссии реинвестировались в ). Они накапливаются рядом, по каждому токену, и этот учет — маленький шедевр бухгалтерии за , который стоит понять, потому что каждый пайплайн LP-аналитики переизобретает его.
Пул поддерживает два глобальных аккумулятора, feeGrowthGlobal0X128 и feeGrowthGlobal1X128: кумулятивные комиссии на единицу ликвидности с момента создания пула, в формате с фиксированной точкой Q128.128. На каждом свапе сумма комиссии (взятая из входного токена) делится на текущую в-диапазоне и добавляется к аккумулятору. Это механизм, лежащий за кардинальным правилом экономики v3: комиссии начисляются только той ликвидности, которая находится в диапазоне в момент свапа. Позиции вне диапазона — не просто инертный инвентарь: они зарабатывают ровно ноль, пока неактивны.
Чтобы приписать нужную часть глобального роста конечному диапазону, каждый инициализированный тик хранит feeGrowthOutside0/1X128 — рост комиссий, произошедший по другую сторону тика относительно текущей цены (значение переворачивает интерпретацию каждый раз, когда тик пересекается, что и делает схему ). Тогда для позиции на с текущим тиком :
Каждая позиция хранит снимок feeGrowthInsideLast, и несобранные комиссии — это просто
обновляемые лениво всякий раз, когда позицию трогают. Два практических следствия. Первое: дельты feeGrowthInside — единственный честный способ измерить комиссионный доход позиции — семплирование объема на уровне пула и пропорциональное деление по вашей доле TVL дает неверный результат всякий раз, когда цена бродит вокруг границ вашего диапазона. Второе: поскольку комиссии сидят как tokensOwed, а не капитализируются, реализованная доходность LP имеет член денежного простоя, которого не было у v2; хранилища с автокапитализацией существуют именно для того, чтобы арбитражить это (минус их собственная комиссия).

Выплата: короткий стрэддл, за удержание которого вам платят (может быть)
Внутри диапазона стоимость позиции как функция цены равна
Вогнута по — член и есть вся суть. За пределами диапазона она становится линейной: наклон снизу (вы в лонге по фиксированному набору ETH), наклон 0 сверху (вы во флэте в USDC). Прогоним наш разобранный пример по диапазону и сравним с простым удержанием внесенных токенов:
| (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, короткая позиция в наборе опционов со страйками, разложенными по ). Поток комиссий — это премия. Концентрация диапазона — это выбор более узких страйков: больше премии на единицу времени, пока в диапазоне, более быстрое и глубокое расхождение при движении цены. Каждый LP в v3 — трейдер с короткой волатильностью, хотел он этого или нет — ончейн-собрат компромисса инвентарного риска, который Avellaneda–Stoikov формализует для маркетмейкеров книги ордеров, где ширина диапазона играет роль котируемого спреда.

Традиционное название разрыва в этой таблице — непостоянная (дивергентная) потеря, но IL-против-HODL — ущербный бенчмарк: он смешивает потерю, которую вы понесли как поставщик ликвидности, с рыночным риском, который вы несли бы в любом случае. Более резкое разложение приходит от Milionis, Moallemi, Roughgarden и Zhang (2022), "Automated Market Making and Loss-Versus-Rebalancing" (arXiv:2208.06046). Оценивайте LP не против HODL, а против ребалансирующего портфеля, который держит в каждый момент те же количества токенов, что и пул, но торгует по бесфрикционной внешней рыночной цене, а не против арбитражеров. Разница — это LVR ("левер"): компонент P&L LP, который является чистым неблагоприятным отбором, выплачиваемым арбитражерам, снимающим устаревшие котировки AMM после каждого движения цены. При геометрически-броуновской цене с волатильностью LVR накапливается с мгновенной скоростью
— "формула Блэка–Шоулза для AMM", как выражаются авторы, где — маржинальная глубина пула при текущей цене. Для полнодиапазонного пула с постоянным произведением это сворачивается в знаменитые от стоимости пула на единицу времени: при 5% дневной волатильности примерно 3.1 б.п. пула утекает арбитражерам каждый день, есть комиссии или нет. Концентрация умножает — наш пример с 12.4× глубины выше — это также ~12.4×-машина LVR, пока в диапазоне. Комиссии должны обгонять эту утечку, чтобы позиция была +EV, и LVR (в отличие от "IL") — хеджируемая, предсказуемая текущая издержка, что и делает его правильной единицей учета. Полное исчисление прибыльности — комиссионный APR против LVR против реализованной волатильности, когда LP-провайдинг обыгрывает дельта-хеджированные короткие опционы, — это тема предстоящего глубокого разбора непостоянной потери и LVR, а механика хеджирования получает отдельный разбор в стратегиях LP для v3 и хеджировании.
Еще одна издержка живет уровнем ниже: поскольку котировки AMM обновляются только когда кто-то торгует, каждая позиция LP также подвержена мемпул-играм, разыгрываемым вокруг этих сделок — сэндвич-атакам против свапперов, которые платят ваши комиссии, и арбитражным бандлам, реализующим ваш LVR блок за блоком. Эта экосистема разобрана в статье про MEV и сэндвич-атаки.
Чтение состояния пула ончейн
Все вышеизложенное наблюдаемо из трех дешевых вызовов к контракту пула.
slot0() упаковывает горячее состояние в один слот хранилища: sqrtPriceX96, текущий tick, индексы наблюдений оракула и флаги протокольной комиссии/блокировки. liquidity() возвращает текущее в-диапазоне агрегированное — заметьте, это не TVL; это параметр глубины, активный на текущем тике, и он скачкообразно меняется, когда цена пересекает инициализированный тик. ticks(int24) возвращает состояние на тик: liquidityGross (полное , ссылающееся на тик), liquidityNet (знаковое , добавляемое при пересечении слева направо) и аккумуляторы feeGrowthOutside. Итерирование liquidityNet по инициализированным тикам (tickBitmap говорит вам, какие из них существуют, без сканирования всех 1.7 миллиона) реконструирует полный профиль глубины — ваш снимок книги ордеров.
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), возвращающий полный кортеж: токены, уровень комиссии, диапазон, , feeGrowthInsideLast и tokensOwed. Для мониторинга портфеля этот один вызов плюс текущий feeGrowthInside пула (пересчитанный из ticks(), как в предыдущем разделе) дают вам оценку по рынку и накопленные комиссии, не трогая индексер — хотя для чего-либо исторического вам понадобится повтор событий свапа или сабграф, потому что рост комиссий зависит от пути, а сеть хранит только текущие аккумуляторы.
Две операционные детали, которые стоит усвоить перед развертыванием капитала. Первое: шаг тика квантует ваше пространство стратегий: на уровне 0.05% вы можете размещать диапазоны шириной 10 б.п. и запускать нечто близкое к настоящему лимитному ордеру (заминтить чуть выше/ниже спота, собрать комиссию, сжечь после пересечения — вайтпейпер явно формулирует этот сценарий "диапазонного ордера"), тогда как на уровне 1% ваш минимальный диапазон ~2% шириной, и аналогия с LOB становится грубой. Второе: диапазонный ордер — это лимитный ордер без приоритета отмены: если цена пересекает ваш диапазон и возвращается, вы прогоняете инвентарь туда-обратно и отдаете эдж (сохраняя комиссии). Пассивные диапазоны — это котировки, которые вы не можете снять, что ровно и есть причина, почему вопрос частоты ребалансировки и линза LVR для ответа на него так важны.
К чему это вас приводит
Стек v3, сжатый: цены — это тики на геометрической сетке 1.0001; глубина — это , конвертируемое в количества токенов не более чем через разности квадратных корней; комиссии — это аккумулятор на единицу , который вы дифференцируете по границам вашего диапазона; а получающаяся позиция — это диапазонный ордер с короткой волатильностью, текущая издержка которого имеет имя, формулу и arXiv-номер. С примитивами на руках интересные вопросы становятся количественными: насколько широко, как часто ребалансировать и клирят ли комиссии LVR для данного пула и режима — что ровно туда, куда эта серия идет дальше.
Ссылки
- 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.
Авторы
Инженер торговых систем
Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.