Раздел 30 · Web3 на Rust

Боты: арбитраж, ликвидации и умение не стрелять

senior~55 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Боты: арбитраж, ликвидации и умение не стрелять

Весь блок мы строили то, что стоит и ждёт: пул, который котирует по формуле, и стакан, который держит очередь заявок. Теперь садимся по другую сторону и пишем того, кто приходит. Бот это не «скрипт, который зарабатывает»: это три отдельные способности, и путать их дорого. Увидеть возможность, честно её посчитать и решить, стоит ли по ней стрелять. Третья способность приносит больше денег, чем первые две, потому что она про то, когда не стрелять.

Три слоя, которые нельзя смешивать

Поиск. Смотрит на состояние и находит места, где правила уже посчитали одно, а рынок думает другое. Расхождение цен между пулами, позиция с плохим здоровьем, заявка по цене хуже рынка.

Оценка. Считает, сколько денег принесёт действие с учётом проскальзывания, комиссий и газа. Это самая скучная часть и самая частая причина убытков: возможность была, а после всех вычетов её не было.

Риск. Решает, можно ли отправлять. Размер, дневной лимит, стоп после серии неудач.

Смешивать их в один цикл соблазнительно и вредно: в смеси невозможно ответить на вопрос, что именно сломалось, когда бот потерял деньги.

Арбитраж: где берётся прибыль

Возьмём два пула на одну пару с разъехавшейся ценой. В первом за 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 00011 7801 780
25 00028 5743 574
50 00054 4464 446
75 00077 9792 979
100 00099 479минус 521
200 000169 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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.