Концентрированная ликвидность: пул как карта диапазонов
В прошлом уроке мы построили пул, который размазывает ликвидность по всей кривой от нуля до бесконечности. Это красиво и почти целиком бесполезно: цена стейблкоина никогда не будет равна ста долларам, а деньги, зарезервированные под этот случай, лежат мёртвым грузом. Uniswap v3 разрешил поставщику выбрать диапазон, и пул из пары чисел превратился в карту. Урок длинный, потому что вместе с этим правом приходит вся сложность: тики, активная ликвидность, своп кусками и позиция, которая перестаёт работать, когда цена ушла.
Сколько капитала работает в v2
Посчитаем то, о чём обычно не задумываются. В пуле x*y=k при цене 1 половина резервов обслуживает диапазон цен от 0.5 до 2, а остальное распределено по всему остальному диапазону, вплоть до нуля и бесконечности.
Если пара торгуется в коридоре плюс-минус десять процентов (а стейблкоины и производные вроде обёрнутого эфира так и торгуются), то работает примерно двадцатая часть капитала. Остальное честно ждёт события, которого не будет.
Отсюда идея v3: пусть поставщик сам скажет, где его деньги должны работать. Внутри этого диапазона капитал ведёт себя так, будто в пуле лежит гораздо больше денег, за пределами не участвует вовсе.
Тики: сетка цен в логарифмическом масштабе
Диапазон нельзя задавать произвольным числом: пулу пришлось бы хранить бесконечно много разных границ. Поэтому цены дискретизованы в тики.
/// Основание сетки тиков: соседние тики отличаются по цене на 0.01%.
pub const TICK_BASE: f64 = 1.0001;
/// Цена в тике: `1.0001^tick`.
pub fn price_at_tick(tick: i32) -> f64 {
TICK_BASE.powi(tick)
}
/// Корень из цены в тике. Именно эту величину хранит пул: в корнях цены
/// формулы свопа становятся линейными.
pub fn sqrt_price_at_tick(tick: i32) -> f64 {
TICK_BASE.powf(tick as f64 / 2.0)
}
/// Тик, в котором лежит цена. Округление вниз, как в контракте.
pub fn tick_at_price(price: f64) -> i32 {
(price.ln() / TICK_BASE.ln()).floor() as i32
}
| Цена | Тик |
|---|---|
| 0.5 | -6932 |
| 0.9 | -1054 |
| 1.0 | 0 |
| 1.1 | 953 |
| 2.0 | 6931 |
Удвоение цены это примерно 6931 тик, и это число одинаково везде: от 1 к 2 и от 1000 к 2000. Логарифмическая сетка тем и хороша.
Пул не разрешает ставить границы на любом тике: они обязаны лежать на сетке с шагом tick spacing. У пула с комиссией 0.3% шаг 60 тиков, то есть границы можно двигать с разрешением 0.6% по цене.
Почему корень цены, а не цена
Самое неочевидное решение v3: пул хранит не цену, а корень из неё. Причина чисто вычислительная, и её видно по формулам.
Пусть на отрезке между двумя границами ликвидность постоянна и равна L. Тогда резервы связаны с корнем цены линейно:
x = L / sqrt(p)
y = L * sqrt(p)
Отсюда для движения цены с sqrt(p1) на sqrt(p2):
dx = L * (1/sqrt(p2) - 1/sqrt(p1))
dy = L * (sqrt(p2) - sqrt(p1))
Обе формулы линейны по корню цены. Работай пул с ценой напрямую, в каждой формуле стоял бы корень, и его пришлось бы считать при каждом свопе. Хранение корня переносит эту работу на момент открытия позиции, то есть с горячего пути на холодный.
Отсюда же вылезает вся арифметика v3 в контракте: sqrtPriceX96, число с фиксированной точкой в формате Q64.96. У нас в эталоне честный f64, и это осознанный размен: формулы читаются, но повторить контракт до последнего бита такой код не может.
Три случая позиции
Позиция это ликвидность L на отрезке [lower, upper). Сколько токенов она стоит, зависит от того, где сейчас цена:
/// Сколько token0 и token1 стоит ликвидность `l` на диапазоне при цене `sp`.
///
/// Три случая. Цена ниже диапазона: позиция целиком в token0, она ждёт, что
/// цена вырастет и token0 обменяют. Цена выше: позиция целиком в token1.
/// Цена внутри: смесь, и доля меняется по мере движения цены.
pub fn amounts_for_liquidity(sp: f64, sa: f64, sb: f64, l: f64) -> (f64, f64) {
if sp <= sa {
(l * (1.0 / sa - 1.0 / sb), 0.0)
} else if sp >= sb {
(0.0, l * (sb - sa))
} else {
(l * (1.0 / sp - 1.0 / sb), l * (sp - sa))
}
}
Проследим за позицией в диапазоне от 0.9 до 1.15, открытой при цене 1:
| Цена | token0 | token1 | Стоимость в котировке |
|---|---|---|---|
| 0.80 | 9007 | 0 | 7206 |
| 0.90 | 9007 | 0 | 8106 |
| 1.00 | 5000 | 3802 | 8802 |
| 1.10 | 1553 | 7417 | 9125 |
| 1.15 | 0 | 9163 | 9163 |
| 1.30 | 0 | 9163 | 9163 |
Читай таблицу так. Пока цена внутри, позиция плавно перекладывается из одного токена в другой: пул продаёт то, что дорожает. На верхней границе весь token0 распродан, и дальше рост цены позицию не касается вообще. Ниже нижней границы наоборот: на руках только token0, и падение бьёт по позиции в полную силу.
Это ровно тот профиль, который мы видели в уроке 44 у продавца опциона: ограниченная прибыль сверху, неограниченный убыток снизу, премия в виде комиссий. Концентрация делает и то, и другое резче.
Обратная задача и эффективность капитала
Сколько ликвидности даст заданный капитал, считается обращением тех же формул:
/// Обратная задача: какая ликвидность получится из внесённых сумм.
///
/// Внутри диапазона считаем по обоим токенам и берём меньшее: пул примет
/// ровно ту пропорцию, которая соответствует текущей цене, лишнее вернётся.
pub fn liquidity_for_amounts(sp: f64, sa: f64, sb: f64, amount0: f64, amount1: f64) -> f64 {
if sp <= sa {
amount0 * (sa * sb) / (sb - sa)
} else if sp >= sb {
amount1 / (sb - sa)
} else {
let l0 = amount0 * (sp * sb) / (sb - sp);
let l1 = amount1 / (sp - sa);
l0.min(l1)
}
}
Теперь главное число всего урока. Возьмём капитал 5000 плюс 5000 при цене 1 и посмотрим, сколько ликвидности он даёт на разных диапазонах:
| Диапазон | Тики | Ликвидность | Против полного |
|---|---|---|---|
| ±1% | -100 .. 99 | очень высокая | 201x |
| ±5% | -488 .. 487 | высокая | 41.5x |
| ±10% | -954 .. 953 | заметная | 21.5x |
| ±25% | -2232 .. 2231 | умеренная | 9.5x |
| ±50% | -4055 .. 4054 | небольшая | 5.4x |
Диапазон плюс-минус процент даёт в двести раз больше глубины, чем те же деньги в v2. Это и есть то, ради чего строилась вся конструкция, и по этой же причине пулы стейблкоинов в v3 показывают спреды, недостижимые для v2.
Потрогай сам: двигай границы и текущую цену, смотри на активную ликвидность и на состав позиции.
Пулы Solana: сначала модель, потом название биржи
В Solana ты встретишь те же задачи под другими именами. При этом название биржи не задаёт единственную формулу: у Raydium есть и CPMM, и CLMM. Перед расчётом котировки выясни, с какой программой пула работаешь.
| Модель | Примеры в Solana | Из чего получается текущая цена |
|---|---|---|
| CPMM, постоянное произведение | Raydium CPMM | Отношение торговых резервов y / x |
| CLMM, концентрированная ликвидность | Raydium CLMM, Orca Whirlpools | Корень цены в состоянии пула; для свопа нужна активная ликвидность и границы тиков |
| DLMM, дискретные ценовые бины | Meteora DLMM | Цена активного бина; крупный своп может перейти в соседние |
Формула y / x из предыдущего урока не превращается в универсальный способ прочитать цену любой DEX. Для CLMM нельзя просто разделить суммарные балансы хранилищ: они включают ликвидность разных диапазонов. Для DLMM нужно знать, в каких бинах лежит доступный объём.
DLMM: цена стоит на ступеньке
Бин отличается от отрезка Uniswap v3. В v3 цена непрерывно движется внутри отрезка с постоянной L; в бине DLMM она фиксирована. Соседние цены связаны шагом s в базисных пунктах: P[i + 1] = P[i] × (1 + s / 10000).
Например, шаг 100 bps означает разницу 1% между соседними ценами. В учебном пуле есть два бина:
| Бин | Цена, Y за X | Доступно X на продажу |
|---|---|---|
| Активный | 1 | 5 |
| Следующий | 1.01 | 5 |
Ты покупаешь X за 7 Y. Без комиссии и округлений первые 5 Y дадут 5 X, оставшиеся 2 Y дадут 2 / 1.01 ≈ 1.980198 X. Всего получится примерно 6.980198 X, средняя цена составит 1.002837 Y за X, а активной станет ступенька с ценой 1.01.
Вот граница обещания «без влияния на цену внутри бина»: пока хватает объёма одного бина. Для большой сделки средняя цена меняется при переходах между бинами. Это также не обещание нулевой комиссии или защиты от изменения котировки до включения транзакции.
Что в DLMM действительно динамическое
Комиссия DLMM состоит из базовой и, если она включена настройками пула, переменной части, зависящей от волатильности. Поэтому одного постоянного процента из конфигурации недостаточно для точной котировки во всех состояниях.
Не путай смену активного бина с переносом позиции поставщика. Свопы переключают активный бин, но выбранное LP распределение ликвидности не переезжает за ценой автоматически. Если цена ушла из диапазона позиции, она перестаёт зарабатывать торговые комиссии. Поставщик может ждать возврата цены или отдельно перебалансировать позицию, сам либо с помощью управляющей стратегии.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.