Боты: арбитраж, ликвидации и умение не стрелять
Весь блок мы строили то, что стоит и ждёт: пул, который котирует по формуле, и стакан, который держит очередь заявок. Теперь садимся по другую сторону и пишем того, кто приходит. Бот это не «скрипт, который зарабатывает»: это три отдельные способности, и путать их дорого. Увидеть возможность, честно её посчитать и решить, стоит ли по ней стрелять. Третья способность приносит больше денег, чем первые две, потому что она про то, когда не стрелять.
Три слоя, которые нельзя смешивать
Поиск. Смотрит на состояние и находит места, где правила уже посчитали одно, а рынок думает другое. Расхождение цен между пулами, позиция с плохим здоровьем, заявка по цене хуже рынка.
Оценка. Считает, сколько денег принесёт действие с учётом проскальзывания, комиссий и газа. Это самая скучная часть и самая частая причина убытков: возможность была, а после всех вычетов её не было.
Риск. Решает, можно ли отправлять. Размер, дневной лимит, стоп после серии неудач.
Смешивать их в один цикл соблазнительно и вредно: в смеси невозможно ответить на вопрос, что именно сломалось, когда бот потерял деньги.
Арбитраж: где берётся прибыль
Возьмём два пула на одну пару с разъехавшейся ценой. В первом за token0 дают меньше, во втором больше. Схема очевидна: купить дешевле, продать дороже.
Неочевидно другое: сколько занести. Мало занесёшь, оставишь деньги на столе. Много занесёшь, сам сдвинешь обе цены и съешь всю разницу.
Выведем оптимум. Пусть g это доля входа, оставшаяся после комиссии. Первая нога даёт
y = g * x * Aout / (Ain + g * x)
Подставим её во вторую ногу и упростим:
z = E * x / (F + G * x),
где E = g^2 * Aout * Bout, F = Ain * Bin, G = g * (Bin + g * Aout)
Прибыль это z - x. Производная обнуляется в одной точке, отсюда закрытая формула:
/// Оптимальный размер арбитражной сделки между двумя пулами.
///
/// Прибыль от прохода через два пула это `E*x/(F + G*x) - x`, где
/// `E = g^2 * Aout * Bout`, `F = Ain * Bin`, `G = g * (Bin + g * Aout)`,
/// а `g` это доля входа, оставшаяся после комиссии. Производная обнуляется
/// в одной точке, отсюда `x = (sqrt(E*F) - F) / G`. Слишком маленькая сделка
/// не выбирает разницу цен, слишком большая сама её и съедает.
pub fn optimal_arbitrage_amount(
first_in: u128,
first_out: u128,
second_in: u128,
second_out: u128,
fee_bps: u32,
) -> u128 {
let g = (BPS_DENOM - u128::from(fee_bps)) as f64 / BPS_DENOM as f64;
let (ain, aout) = (first_in as f64, first_out as f64);
let (bin, bout) = (second_in as f64, second_out as f64);
let e = g * g * aout * bout;
let f = ain * bin;
let gg = g * (bin + g * aout);
let x = ((e * f).sqrt() - f) / gg;
if !x.is_finite() || x <= 0.0 {
return 0;
}
x as u128
}
Проверим на пулах, разъехавшихся примерно на двадцать процентов: первый 1 100 000 на 1 000 000, второй 1 000 000 на 1 100 000, комиссия 0.3% в обоих.
| Вход | Возврат | Прибыль |
|---|---|---|
| 10 000 | 11 780 | 1 780 |
| 25 000 | 28 574 | 3 574 |
| 50 000 | 54 446 | 4 446 |
| 75 000 | 77 979 | 2 979 |
| 100 000 | 99 479 | минус 521 |
| 200 000 | 169 630 | минус 30 370 |
Формула даёт 46 258, и это действительно вершина кривой. Обрати внимание на правый край таблицы: сделка вдвое больше оптимальной уже убыточна, а вчетверо больше теряет тридцать тысяч. Ошибка в размере наказывается сильнее, чем ошибка в направлении.
После исполнения цены сходятся: было 0.9091 и 1.1000, стало 0.9950 и 1.0007. Арбитражёр забрал разницу и заодно сделал рынку полезное дело, о чём мы говорили в уроке про MEV.
Считать симуляцией, а не формулой
Формула выше даёт ориентир, но отправлять транзакцию по ориентиру нельзя. Настоящий ответ даёт прогон сделки по копиям состояния:
/// Прогнать сделку по двум пулам и посчитать, что вернётся.
///
/// Копии пулов, а не оригиналы: оценка не имеет права менять состояние,
/// иначе один просчёт «что если» испортит следующий.
pub fn simulate_arbitrage(
first: &Pool,
second: &Pool,
direction: ArbDirection,
amount_in: u128,
) -> Result<u128, AmmError> {
let (mut a, mut b) = match direction {
ArbDirection::BuyInFirst => (first.clone(), second.clone()),
ArbDirection::BuyInSecond => (second.clone(), first.clone()),
};
// Нога первая: token1 -> token0 там, где token0 дешевле.
let token0 = a.swap(Side::OneForZero, amount_in, 0)?.amount_out;
// Нога вторая: token0 -> token1 там, где token0 дороже.
let token1 = b.swap(Side::ZeroForOne, token0, 0)?.amount_out;
Ok(token1)
}
Три причины, по которым симуляция обязательна.
Целочисленность. Формула считает в дробях, контракт в целых. Разница на границе округления решает, прибыльна сделка или нет.
Реальные правила пула. У пула может быть другая комиссия, поставленный на паузу режим, комиссия протокола или токен, который берёт своё при переводе.
Проверка перед отправкой. Между поиском и отправкой состояние могло измениться. Симуляция на свежем состоянии это последний шанс не отправить убыточную транзакцию.
Отсюда общее правило работы с чужим состоянием: никогда не мутируй то, о чём только размышляешь. В сигнатуре это видно сразу: на вход идут ссылки, внутри клоны.
Газ решает, существует ли возможность
/// Поискать арбитраж в обе стороны и вернуть лучший вариант.
///
/// `gas_cost` в единицах token1: сделка, которая не окупает собственное
/// исполнение, для бота не существует.
pub fn find_arbitrage(first: &Pool, second: &Pool, gas_cost: u128) -> Option<Opportunity> {
[ArbDirection::BuyInFirst, ArbDirection::BuyInSecond]
.into_iter()
.filter_map(|direction| {
let (buy, sell) = match direction {
ArbDirection::BuyInFirst => (first, second),
ArbDirection::BuyInSecond => (second, first),
};
let amount_in = optimal_arbitrage_amount(
buy.reserve1,
buy.reserve0,
sell.reserve0,
sell.reserve1,
buy.fee_bps,
);
if amount_in == 0 {
return None;
}
let amount_back = simulate_arbitrage(first, second, direction, amount_in).ok()?;
let profit = amount_back.checked_sub(amount_in)?.checked_sub(gas_cost)?;
if profit == 0 {
return None;
}
Some(Opportunity { direction, amount_in, amount_back, profit })
})
.max_by_key(|opportunity| opportunity.profit)
}
Смотри на checked_sub: если вычитание ушло в минус, вся ветка отваливается через ? и возможность просто не возвращается. Возможность, не покрывающая газ, для бота не существует, и это не оптимизация, а определение.
И проверяются обе стороны. Разъехавшиеся цены могут требовать движения в любом направлении, а платить за отдельный код под каждое незачем.
Конкуренция: почему прибыли стремятся к нулю
Тут важно понимать экономику, а не только код. Ту же возможность видят все, у кого есть тот же публичный узел.
В открытом мемпуле идёт аукцион. Побеждает тот, кто больше заплатит за приоритет, и в пределе комиссия съедает почти всю прибыль. Отсюда практика связок и приватных каналов, которую мы разбирали в уроке про MEV.
Проигранная гонка стоит денег. На Ethereum откатившаяся транзакция всё равно тратит газ, и серия проигранных гонок разоряет быстрее, чем неудачные сделки.
Задержка это конкурентное преимущество. Кто раньше увидел изменение состояния, тот раньше посчитал. Отсюда прямой мост в следующий блок курса: та же логика, только в микросекундах.
Практический вывод для бота-новичка звучит скучно, зато честно: лучшая стратегия это не самая умная, а самая незаметная. Ниши, где нет десяти конкурентов с колокацией, живут дольше.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.