Skip to main content
Эта страница переведена с помощью ИИ. За эталон принимается английская версия.Открыть английскую версию →
Эта страница содержит рабочие формулы, соглашения о фиксированной точке и пошаговые процедуры, используемые программой CLMM. Для понимания логики самой кривой концентрированной ликвидности — почему L = sqrt(x · y) имеет значение — см. algorithms/clmm-math. На этой странице предполагается, что вы уже прочитали тот материал.

Представление sqrt-цены

CLMM хранит цену как sqrt_price_x64 — квадратный корень из цены token1-за-token0, в виде числа с фиксированной точкой Q64.64: sqrt_price_x64=⌊p⋅264⌋\text{sqrt\_price\_x64} = \lfloor \sqrt{p} \cdot 2^{64} \rfloor где p = token1_amount / token0_amount. Работа с sqrt вместо p линеаризует математику свопа (дельты сумм токенов становятся линейными по Δsqrt_price), а фиксированная точка x64 сохраняет точность при свопах через множество тиков. Преобразование тик ↔ sqrt-цена предвычисляется через bit-by-bit логарифмическую аппроксимацию: sqrt_price_x64(t)≈264⋅(1.0001)t/2\text{sqrt\_price\_x64}(t) \approx 2^{64} \cdot (1.0001)^{t/2} реализованную как поиск по таблице с возведением в степень в tick_math::get_sqrt_price_at_tick.

Ликвидность как каноническая единица

Внутри диапазона [sqrt_a, sqrt_b] (где sqrt_a < sqrt_b) позиция с ликвидностью L отображается на суммы токенов следующим образом. Пусть sqrt_c = sqrt_price_x64 — текущая цена пула. Все три тождества вытекают из инварианта x = L / sqrt_p, y = L · sqrt_p, который концентрированная ликвидность удовлетворяет внутри диапазона. Интеграторы обычно нуждаются в обратном: дан депозит amount0 / amount1, вычислить максимальное L, которое подходит в диапазон. Метод SDK LiquidityMath.getLiquidityFromTokenAmounts делает это. Формула для случая в диапазоне: L0=amount0⋅sqrt_c⋅sqrt_bsqrt_b−sqrt_c,L1=amount1sqrt_c−sqrt_a,L=min⁡(L0,L1)L_0 = \text{amount0} \cdot \frac{\text{sqrt\_c} \cdot \text{sqrt\_b}}{\text{sqrt\_b} - \text{sqrt\_c}}, \qquad L_1 = \frac{\text{amount1}}{\text{sqrt\_c} - \text{sqrt\_a}}, \qquad L = \min(L_0, L_1) Какая сторона ограничивает, определяет фактическое потребленное соотношение; другая сторона может иметь остаток.

Шаг свопа на одном тике

Своп происходит шагами. Каждый шаг либо (a) потребляет всю доступную входную сумму в текущем диапазоне тика без пересечения тика, либо (b) перемещает цену ровно на следующий инициализированный тик. Дан текущее состояние (sqrt_c, L) и своп вниз (token0 входит, token1 выходит, sqrt_price уменьшается — цена котируется как token1/token0, поэтому добавление token0 её снижает), следующий инициализированный тик ниже находится в sqrt_t < sqrt_c. Внутри этого микроинтервала связь между входной суммой и ценой: Δamount0=L⋅(1sqrt_t−1sqrt_c)=L⋅(sqrt_c−sqrt_t)sqrt_c⋅sqrt_t\Delta\text{amount0} = L \cdot \left( \frac{1}{\text{sqrt\_t}} - \frac{1}{\text{sqrt\_c}} \right) = \frac{L \cdot (\text{sqrt\_c} - \text{sqrt\_t})}{\text{sqrt\_c} \cdot \text{sqrt\_t}} и Δamount1=L⋅(sqrt_c−sqrt_t)\Delta\text{amount1} = L \cdot (\text{sqrt\_c} - \text{sqrt\_t}) Своп с входом token1 — зеркальное отражение: sqrt_price растёт в сторону следующего тика выше, и две формулы меняют, какая конечная точка вычитается. Программа кодирует направление как zero_for_one (true, когда входной mint — это token_mint_0), и своп zero_for_one зажимается в сторону MIN_SQRT_PRICE_X64. Программа делает одно из двух:
  • Вся входная сумма подходит? Если оставшаяся входная сумма (после комиссии) меньше Δamount0 для достижения sqrt_t, решить для новой sqrt_c' точно: sqrt_c′=L⋅sqrt_cL+Δinput⋅sqrt_c\text{sqrt\_c}' = \frac{L \cdot \text{sqrt\_c}}{L + \Delta\text{input} \cdot \text{sqrt\_c}} (для свопа с точной входной суммой token0 → token1). Своп завершается на этом шаге без пересечения тика.
  • Входная сумма превышает Δamount0? Установить sqrt_c' = sqrt_t, пересечь тик (применить liquidity_net), уменьшить оставшуюся входную сумму на Δamount0, увеличить выходную сумму на Δamount1 и повторить.
Для противоположного направления (token1 → token0, цена идёт вниз), формулы имеют sqrt_c и sqrt_t поменянными местами и инверсию в другом слоте. Полная реализация на Rust находится в raydium-clmm/programs/amm/src/libraries/swap_math.rs. Логика там соответствует SwapMath.computeSwapStep Uniswap v3 один в один.

Комиссии на каждом шаге

Торговые комиссии берутся с входной суммы на каждом шаге, то же соглашение, что и в CPMM:
Доля LP распределяется между текущей активной ликвидностью путём обновления глобального аккумулятора роста комиссий: fee_growth_globalin+=lp_portion⋅264L\text{fee\_growth\_global}_{\text{in}} \mathrel{+}= \text{lp\_portion} \cdot \frac{2^{64}}{L} — то есть она выражена в комиссиях на единицу ликвидности, Q64.64, так что позиция размером L_i, которая оставалась в диапазоне во время этого свопа, позже прочитает L_i · Δfee_growth_global / 2^{64} причитающихся токенов. Доли протокола и фонда накапливаются в PoolState.protocol_fees_token_{0,1} и PoolState.fund_fees_token_{0,1} соответственно, идентично CPMM. Они собираются инструкциями CollectProtocolFee / CollectFundFee.

Рост комиссий снаружи и внутри

Сложная часть учёта комиссий CLMM: позиция зарабатывает комиссии только пока цена пула находится внутри её диапазона. Пул отслеживает кумулятивные комиссии глобально; позиция должна знать кумулятивные комиссии пока находится внутри своего конкретного диапазона. Решение — аккумулятор на основе тиков. Каждый тик хранит:
В момент инициализации тика:
  • Если цена пула находится выше этого тика (tick_current >= this_tick), fee_growth_outside = fee_growth_global. (Всё заработанное до сих пор — это “снаружи” — то есть ниже — этого тика, относительно текущей цены.)
  • Иначе fee_growth_outside = 0.
Когда цена пересекает тик, программа переворачивает fee_growth_outside этого тика: fee_growth_outside←fee_growth_global−fee_growth_outside\text{fee\_growth\_outside} \gets \text{fee\_growth\_global} - \text{fee\_growth\_outside} Инвариант, который это сохраняет: для любого тика t, fee_growth_outside(t) равна комиссиям, которые накопились пока tick_current находился на противоположной стороне t. Рост комиссий внутри диапазона [tick_lower, tick_upper] затем выводится:
Это формула роста комиссий Uniswap-v3, без изменений.

Что хранит позиция и что она читает

PersonalPositionState хранит fee_growth_inside_0_last_x64 и fee_growth_inside_1_last_x64: значения fee_growth_inside в последний раз, когда позиция была затронута. При любом последующем обращении (увеличение, уменьшение, сбор), программа:
  1. Вычисляет текущие fee_growth_inside_{0,1}_x64 используя формулу выше.
  2. Вычисляет Δ = fee_growth_inside_now − fee_growth_inside_last (модульное вычитание на u128).
  3. Добавляет Δ × position.liquidity / 2^{64} к tokens_fees_owed_{0,1}.
  4. Обновляет fee_growth_inside_last на новое значение.
Токены фактически выходят из хранилищ только при CollectFees / DecreaseLiquidity, против tokens_fees_owed.

Награды

Каждый из до 3 потоков вознаграждений пула использует ту же механику роста-внутри, в своём собственном аккумуляторе reward_growth_global_x64. В момент эмиссии: reward_growth_global+=emission_per_second⋅Δt⋅264L\text{reward\_growth\_global} \mathrel{+}= \text{emission\_per\_second} \cdot \Delta t \cdot \frac{2^{64}}{L} — эмиссии масштабируются обратно пропорционально активной ликвидности, поэтому более плотный пул платит каждой позиции пропорционально меньше в секунду, но в целом большему числу позиций. Причитающееся вознаграждение на позицию: reward_owed=(reward_growth_insidenow−reward_growth_insidelast)⋅L/264\text{reward\_owed} = (\text{reward\_growth\_inside}_{\text{now}} - \text{reward\_growth\_inside}_{\text{last}}) \cdot L / 2^{64} и выплачивается DecreaseLiquidity / DecreaseLiquidityV2 — нет отдельной инструкции сбора вознаграждения. См. products/clmm/fees.

Рабочий пример: своп с точной входной суммой

Предположим:
  • tick_spacing = 60
  • sqrt_price_x64 = 1 × 2^{64} — цена = 1.0, поэтому tick_current = 0.
  • Активная ликвидность L = 1_000_000 × 2^{64}.
  • Следующий инициализированный тик ниже: t = −60 (sqrt_price_b ≈ 0.997005 × 2^{64}). Своп с входом token0 движется вниз, поэтому тик выше здесь не имеет значения.
  • Ставка торговой комиссии: 500 (0.05%).
Пользователь: SwapBaseInput с точной входной суммой 1,000 token0. Шаг 1 — комиссии:
Шаг 2 — подходит ли 999 в текущий диапазон тика?
999 < 3004.4, поэтому вся входная сумма подходит без пересечения тика. Шаг 3 — новая цена:
то есть sqrt_c' приземляется немного ниже sqrt_c, что является правильным направлением: своп token0 → token1 добавляет token0 в пул и поэтому снижает цену token1/token0. Шаг 4 — выходная сумма:
После учёта округления пользователь получает ≈ 998 token1. Комиссия (1 token0) распределяется между LP, протоколом и фондом по trade_fee_rate × protocol_fee_rate / 1e6 (и аналогично для фонда); доля LP поступает в fee_growth_global_0_x64.

Сопоставление лимитных ордеров во время свопа

Когда шаг свопа пересекает тик, на котором открыты лимитные ордера, эти ордера потребляют входную сумму свопа перед кривой LP, по точной цене тика. Сопоставление FIFO внутри тика по когорте order_phase.

Состояние на когорту в TickState

Двухкогортная раскладка существует потому, что новые ордера могут быть открыты на тике пока старая когорта всё ещё заполняется. Вновь открытые ордера присоединяются к orders_amount и наследуют следующий order_phase; они не могут заполниться, пока предыдущая когорта полностью не потреблена.

Шаг сопоставления

Псевдокод для сопоставления, которое происходит при каждом пересечении тика во время свопа:
Выходные токены, идущие владельцам лимитных ордеров, не передаются за каждый своп. Они находятся виртуально в выходном хранилище пула, пока владелец ордера не вызовет SettleLimitOrder (или DecreaseLimitOrder). Пул просто отслеживает, насколько заполнена когорта, через unfilled_ratio_x64. Каждый LimitOrderState хранит свой собственный снимок (order_phase, unfilled_ratio_x64) в момент открытия, поэтому расчёт сводится к:
Этот O(1) расчёт — вся суть дизайна когорты — тик может заполнить произвольно много ордеров без газа на ордер.

Взаимодействие с кривой LP

В шаге свопа сопоставление лимитных ордеров происходит на тике (нулевой Δsqrt_price); потребление кривой LP происходит между тиками. Порядок поэтому:
  1. Пересечь тик t_cross (сначала применить изменение LP liquidity_net, так как это то, как это делает Uniswap-V3).
  2. Заполнить любые лимитные ордера, находящиеся на t_cross.
  3. Продолжить вдоль кривой LP к следующему инициализированному тику или до исчерпания swap_input.
Лимитные ордера таким образом дают трейдерам больше эффективной ликвидности ровно по цене ордера (эффект улучшения цены), ценой того, что LP не зарабатывают комиссии на этой части объёма свопа — часть свопа с лимитным ордером не облагается комиссией для свопера, так как размещающий лимитный ордер действует как мейкер. Динамическая комиссия (если включена) всё ещё применяется к части LP того же свопа.

Вывод динамической комиссии

PoolState.dynamic_fee_info несёт состояние волатильности. Каждый шаг свопа вычисляет ставку комиссии за шаг как: fee_ratetotal=trade_fee_rateconfig+dynamic_fee_control⋅(vol_acc⋅tick_spacing)2Dctrl⋅Svol2⏟динамическая надбавка\text{fee\_rate}_{\text{total}} = \text{trade\_fee\_rate}_{\text{config}} + \underbrace{\frac{\text{dynamic\_fee\_control} \cdot (\text{vol\_acc} \cdot \text{tick\_spacing})^2} {D_{\text{ctrl}} \cdot S_{\text{vol}}^2}}_{\text{динамическая надбавка}} где:
  • Dctrl=100,000D_{\text{ctrl}} = 100{,}000 — DYNAMIC_FEE_CONTROL_DENOMINATOR
  • Svol=10,000S_{\text{vol}} = 10{,}000 — VOLATILITY_ACCUMULATOR_SCALE
  • vol_acc — аккумулятор за своп после применения правила обновления ниже
  • tick_spacing — из PoolState.tick_spacing
Результат зажимается на 100,000/106=10%100{,}000 / 10^6 = 10\%.

Обновление аккумулятора

Два правила применяются каждый своп, по порядку: Затухание. Опорный уровень затухает на основе времени с момента последнего обновления: vol_ref={0если Δt>decay_periodvol_accprev⋅reduction_factor10,000если filter_period<Δt≤decay_periodvol_refprevесли Δt≤filter_period\text{vol\_ref} = \begin{cases} 0 & \text{если } \Delta t > \text{decay\_period} \\ \text{vol\_acc}_{\text{prev}} \cdot \dfrac{\text{reduction\_factor}}{10{,}000} & \text{если } \text{filter\_period} < \Delta t \le \text{decay\_period} \\ \text{vol\_ref}_{\text{prev}} & \text{если } \Delta t \le \text{filter\_period} \end{cases} Накопление. Новый аккумулятор — это опорный уровень плюс расстояние в тиках, пройденное с момента предыдущего опорного индекса: vol_acc=min⁡(vol_ref+∣tref−tnow∣⋅Svol,max_vol_acc)\text{vol\_acc} = \min\left( \text{vol\_ref} + \left| t_{\text{ref}} - t_{\text{now}} \right| \cdot S_{\text{vol}}, \text{max\_vol\_acc} \right) tick_spacing_index_reference (treft_{\text{ref}}) в единицах интервала тиков, не в сырых тиках: tref=⌊tick_current/tick_spacing⌋t_{\text{ref}} = \lfloor \text{tick\_current} / \text{tick\_spacing} \rfloor.

Почему парабола в расстоянии тиков

Возведение аккумулятора в квадрат означает, что комиссия растёт как квадрат того, насколько далеко цена прошла от своей опорной точки. Эмпирически это соответствует масштабированию дисперсии цены при случайном блуждании: 2× отклонение в тиках подразумевает 4× подразумеваемую волатильность, поэтому взимает 4× надбавку. Параметр dynamic_fee_control калибрует абсолютный уровень. Окно filter_period предотвращает крошечные колебания в доли секунды (например, MEV-боты с сэндвичами) от раздувания аккумулятора. Окно decay_period предотвращает один прошлый скачок от взимания комиссий бесконечно долго после того, как рынок успокоился.

Численная устойчивость

  • Все промежуточные произведения проходят через арифметику u128 или u256. CLMM использует помощники U128Sqrt и паттерны FullMath::mulDiv напрямую перенесённые из Uniswap v3.
  • Округление деления выбирается за шаг для обеспечения инварианта k' ≥ k локально. SwapBaseInput округляет выход вниз; SwapBaseOutput округляет вход вверх.
  • Пересечения тиков, которые снижают PoolState.liquidity до нуля, разрешены (цена может пройти через “яму ликвидности”), но своп просто переходит к следующему инициализированному тику без потребления входной суммы, не взимая комиссию.
  • Защита от переполнения: sqrt_price_x64 сохраняется в включающем диапазоне [MIN_SQRT_PRICE_X64, MAX_SQRT_PRICE_X64], соответствующем [MIN_TICK, MAX_TICK]. Своп, который попытался бы выйти за любую границу, откатывается с SqrtPriceLimitOverflow.

Что дальше

  • products/clmm/ticks-and-positions для того, как карта тиков участвует в обходе.
  • products/clmm/fees для подробного разбора математики комиссий/вознаграждений.
  • algorithms/clmm-math для выводов за L = sqrt(x · y) и формулами диапазон-vs-ликвидность.
Источники: