Если ваша настройка MEV не выглядит так, как здесь, вы ngmi
Эта статья является частью серии о создании арбитражного бота. Цель этой серии - предоставить пошаговое руководство по созданию автоматизированного торгового робота MEV, который может находить и осуществлять арбитражные возможности на популярных децентрализованных биржах.
В этой статье мы проводим предварительный отбор пар токенов, которые представляют интерес. Затем мы выводим математическую формулу для нахождения оптимального арбитража между двумя пулами одинаковых пар токенов. Наконец, мы реализуем формулу в коде и возвращаем список потенциальных возможностей для арбитража.
Прежде чем мы начнем искать возможности для арбитража, нам необходимо четко определить периметр нашего арбитражного бота. Конкретно, на какие типы арбитража мы хотим реагировать. Самый безопасный вид арбитража - между пулами, включающими ETH. Поскольку ETH - это актив, на который оплачивается газ наших транзакций, естественно всегда хотеть оказаться с ETH после арбитража. Но каждый искушен думать так. Помните, что в торговле своевременные возможности становятся менее прибыльными, по мере того как на них реагирует все больше людей.
Для упрощения мы сосредоточимся на арбитражных возможностях между пулами, включающими ETH. Мы будем искать возможности только между двумя пулами одной пары токенов. Мы не будем торговать возможностями, включающими более 2 пулов в торговом маршруте (так называемые мультипрыжковые возможности). Обратите внимание, что обновление этой стратегии до более рискованной является первым шагом, который вы должны предпринять, чтобы улучшить прибыльность вашего бота.
Для улучшения этой стратегии вы могли бы, например, держать часть инвентаря в стейблкоинах и действовать на арбитражные возможности, приносящие стейблкоины. То же самое можно было бы сделать для более рискованных активов, таких как shitcoins (с необходимыми предосторожностями), и периодически балансировать свой портфель в ETH для оплаты газа.
Другим направлением было бы отказаться от подразумеваемого предположения атомарности, которое мы сделали, и ввести статистическое рассуждение в нашу стратегию. Например, путем покупки одного токена в пуле, когда цена двигалась в более благоприятном направлении, чем некоторое количество стандартных отклонений, а затем продавать его позже (стратегия среднего возвращения). Это было бы идеально для shitcoins, которые не перечислены на более эффективных централизованных биржах, или тех, которые, но цена которых неправильно отслеживается on-chain. В этом участвует намного больше движущихся частей и выходит за рамки этой серии.
Теперь, когда мы определили периметр нашего арбитражного бота, нам нужно выбрать токен-пары, на которых мы хотим торговать. Вот 2 критерия выбора, которые мы будем использовать:
Повторное использование кода изстатья 2: Эффективное чтение цен на пул, у нас есть следующий код, который перечисляет все пары токенов, которые были развернуты предоставленными фабричными контрактами:
# [...]# Загрузите адреса фабричных контрактовwith open("FactoriesV2.json", "r") as f:factories = json.load(f)# [...]# Получите список пулов для каждого фабричного контрактаpairDataList = []for factoryName, factoryData in factories.items():events = getPairEvents(w3.eth.contract(address=factoryData['factory'], abi=factory_abi), 0, w3.eth.block_number)print(f'Найдено {len(events)} пулов для {factoryName}')for e in events: pairDataList.append({ "token0": e["args"]["token0"], "token1": e["args"]["token1"], "pair": e["args"]["pair"], "factory": factoryName })
Мы просто инвертируем pairDataList в словарь, где ключи - это токен-пары, а значения - список пулов, торгующих этой парой. При переборе списка мы игнорируем пары, не связанные с ETH. По завершении цикла пары с как минимум 2 пулами будут выбраны и сохранены в списках с как минимум 2 элементами:
# [...]WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"pair_pool_dict = {}for pair_object in pairDataList:# Check for ETH (WETH) in the pair.pair = (pair_object['token0'], pair_object['token1'])if WETH not in pair: continue# Make sure the pair is referenced in the dictionary. if pair not in pair_pool_dict: pair_pool_dict[pair] = []# Add the pool to the list of pools that trade this pair.pair_pool_dict[pair].append(pair_object)# Create the final dictionnary of pools that will be traded on.pool_dict = {}for pair, pool_list in pair_pool_dict.items():if len(pool_list) >= 2: pool_dict[pair] = pool_list
Некоторые статистические данные следует распечатать, чтобы лучше понимать данные, с которыми мы работаем:
# Количество разных пар
print(f'У нас есть {len(pool_dict)} разных пар.')
# Общее количество пулов
print(f'У нас всего {sum([len(pool_list) for pool_list in pool_dict.values()])} пулов.')
# Пара с наибольшим количеством пулов
print(f'Пара с наибольшим количеством пулов - {max(pool_dict, key=lambda k: len(pool_dict[k]))} с {len(max(pool_dict.values(), key=len))} пулами.')
# Распределение количества пулов на пару, децили
pool_count_list = [len(pool_list) for pool_list in pool_dict.values()]
pool_count_list.sort(reverse=True)
print(f'Количество пулов на пару, в децилях: {pool_count_list[::int(len(pool_count_list)/10)]}')
# Распределение количества пулов на пару, процентили (децили первого дециля)
pool_count_list.sort(reverse=True)
print(f'Количество пулов на пару, в процентилях: {pool_count_list[::int(len(pool_count_list)/100)][:10]}')
На момент написания, это выводит следующее:
У нас есть 1431 различных пар.
У нас всего 3081 пул.
Пара с наибольшим количеством пулов - ('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', '0xdAC17F958D2ee523a2206206994597C13D831ec7') с 16 пулами.
Количество пулов на пару, в децилях: [16, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
Количество пулов на пару, в процентилях: [16, 5, 4, 3, 3, 3, 3, 3, 3, 3]
Получение резервов для 3000 пулов можно выполнить менее чем за 1 секунду с общедоступными узлами RPC. Это разумное количество времени.
Теперь, когда у нас есть все данные, которые нам нужны, нам нужно начать находить арбитражные возможности.
Возможность арбитража возникает всякий раз, когда существует расхождение в цене между двумя пулами, которые торгуют одной и той же парой. Однако не все различия в ценах могут быть эксплуатированы: стоимость газа транзакции устанавливает минимальное значение, которое должно быть окуплено сделкой, а ликвидность в каждом пуле ограничивает ценность, которая может быть извлечена из данного различия в цене.
Для того чтобы найти наиболее прибыльную арбитражную возможность, доступную нам, нам нужно рассчитать потенциальную ценность, извлекаемую из каждой разницы в цене, учитывая резервы/ликвидность в каждом пуле, и оценить стоимость газа для транзакции.
Когда используется арбитражная возможность, цена пула, который покупает входной токен, упадет, а цена пула, который продает, вырастет. Движение цен описывается формулой постоянного продукта.
Мы уже видели в @emileamajar/building-an-arbitrage-bot-automated-market-makers-and-uniswap-2d208215d8c2"> article 1 как вычислить вывод обмена через пул, учитывая резервы этого пула и входную сумму.
Для нахождения оптимального размера сделки мы сначала находим формулу для вывода двух последовательных обменов, учитывая некоторую входную сумму и резервы двух пулов, участвующих в обменах.
Мы предполагаем, что ввод первого обмена происходит в token0, а ввод второго обмена - в token1, что в конечном итоге дает вывод в token0.
Пусть x - это входная сумма, (a1, b1) - резервы первого пула, а (a2, b2) - резервы второго пула. Комиссия - это комиссия, взимаемая пулами, и предполагается, что она одинакова для обоих пулов (в большинстве случаев 0,3%).
Мы определяем функцию, которая вычисляет вывод обмена при заданном входе x и резервах (a, b):
f(x, a, b) = b (1 - a/(a + x(1-fee)))
Тогда мы знаем, что результат первого обмена:
out1(x) = f(x, a1, b1)
out1(x) = b1 (1 - a1/(a1 + x(1-fee)))
Результат второго обмена: (обратите внимание на обмененные резервные переменные)
out2(x) = f(out1(x), b2, a2)
out2(x) = f(f(x, a1, b1), b2, a2)
out2(x) = a2 (1 - b2/(b2 + f(x, a1, b1)(1-fee)))
out2(x) = a2 (1 - b2/(b2 + b1 (1 - a1/(a1 + x (1-fee))) (1-fee)))
Мы можем построить эту функцию, используя desmos. При выборе резервных значений так, чтобы мы имитировали первый пул с 1 ETH и 1750 USDC, а второй пул с 1340 USDC и 1 ETH, мы получаем следующий график:
График валовой прибыли торговли как функция входного значения
Обратите внимание, что мы фактически построили out2(x) - x, что представляет собой прибыль сделки, минус входная сумма.
Графически мы видим, что оптимальный размер сделки составляет 0.0607 ETH ввода, что приносит прибыль в размере 0.0085 ETH. В контракте должно быть не менее 0.0607 ETH ликвидности в WETH, чтобы иметь возможность использовать эту возможность.
Эта прибыль составляет 0,0085 ETH (~$16 на момент написания этой статьи) и НЕ является окончательной прибылью от сделки, поскольку нам еще предстоит учесть стоимость газа для транзакции. Об этом будет рассказано в следующей статье.
Мы хотим автоматически вычислить оптимальный размер сделки для нашего MEV бота. Это можно сделать с помощью элементарного исчисления. У нас есть функция одной переменной x, которую мы хотим максимизировать. Функция достигает максимума для значения x, где производная функции равна 0.
Различные бесплатные и онлайн-инструменты могут быть использованы для символического вычисления производной функции, такой как wolfram alpha.
Нахождение производной нашей функции валовой прибыли.
Найти такой производный очень просто с помощью Wolfram Alpha. Вы также можете сделать это вручную, если вы не уверены в своих математических навыках.
Wolfram Alpha выдает следующую производную:
dout2(x)/dx = (a1b1a2b2(1-fee)^2)/(a1b2 + (1-комиссия)x(b1(1-fee)+b2))^2
Поскольку мы хотим найти значение x, максимизирующее прибыль (которая равна out2(x) - x), нам нужно найти значение x, при котором производная равна 1 (а не 0).
Wolfram Alpha дает следующее решение для x в уравнении dout2(x)/dx = 1:
x = (sqrt(a1b1a2b2(1-fee)^4 (b1(1-сбор)+b2)^2) - a1b2(1-fee)(b1(1-fee)+b2)) / ((1-fee) (b1(1-fee) + b2))^2
Со значениями резервов, использованными на графике выше, мы получаем x_optimal = 0.0607203782551, что подтверждает нашу формулу (по сравнению со значением на графике 0.0607).
Хотя эта формула не очень читаема, ее легко реализовать в коде. Вот реализация формулы на Python для вычисления выхода 2 свопов и оптимального размера сделки:
# Вспомогательные функции для расчета оптимального размера сделки# Выход одного обменаdef swap_output(x, a, b, fee=0.003):return b * (1 - a/(a + x*(1-fee)))# Валовая прибыль двух последовательных обменовdef trade_profit(x, reserves1, reserves2, fee=0.003): a1, b1 = reserves1a2, b2 = reserves2return swap_output(swap_output(x, a1, b1, fee), b2, a2, fee) - x# Оптимальный размер входаdef optimal_trade_size(reserves1, reserves2, fee=0.003):a1, b1 = reserves1a2, b2 = reserves2return (math.sqrt(a1*b1*a2*b2*(1-fee)**4 * (b1*(1-fee)+b2)**2) - a1*b2*(1-fee)*(b1*(1-fee)+b2)) / ((1-fee) * (b1*(1-fee) + b2))**2
Теперь, когда мы знаем, как вычислить валовую прибыль от арбитражной возможности между любыми двумя данными пулами одной и той же пары токенов, нам просто нужно перебрать все пары токенов и протестировать по два пула, имеющих одну и ту же пару токенов. Это даст нам валовую прибыль от всех возможных арбитражных возможностей, которые находятся в пределах нашей стратегии.
Для оценки чистой прибыли сделки нам необходимо оценить стоимость газа для использования определенной возможности. Это можно сделать точно, симулируя транзакцию через eth_call к узлу RPC, но это занимает много времени и может быть выполнено только для нескольких десятков возможностей на блок.
Сначала мы сделаем грубую оценку стоимости газа, предполагая фиксированную стоимость газа за транзакцию (на самом деле, это нижняя граница), и отсеем возможности, которые недостаточно прибыльны, чтобы покрыть стоимость газа. Только после этого мы выполним точную оценку стоимости газа для оставшихся возможностей.
Здесь код, который проходит через все пары и все пулы, и сортирует возможности по прибыли:
# [...] # Получить резервы каждого пула в pool_dictto_fetch = [] # Список адресов пулов, для которых необходимо получить резервы.for pair, pool_list в pool_dict.items():для pair_object в pool_list: to_fetch.append(pair_object["pair"]) # Добавляем адрес пулprint(f"Получение резервов {len(to_fetch)} пулов...")# getReservesParallel() из статьи 2 в MEV-боте seriesreserveList = asyncio.get_event_loop().run_until_complete(getReservesParallel(to_fetch, providersAsync))# Строим список торговых возможностейindex = 0opps = []для пары, pool_list в pool_dict.items():# Сохраняем резервы в объектах пула для последующего использованиядля pair_object в pool_list: pair_object["reserves"] = reserveList[index] index += 1# Перебираем все пулы pairдля пула A в pool_list: для poolB в pool_list: # Пропустить, если это один и тот же пул if poolA["pair"] == poolB["pair"]: continue # Пропустить, если один из резервов равен 0 (деление на 0), если 0 в poolA["reserves"] или 0 в poolB["reserves"]: continue # Переупорядочить резервы так, чтобы WETH всегда был первым токеном if poolA["token0"] == WETH: res_A = (poolA["reserves"][0], poolA["reserves"][1]) res_B = (poolB["reserves"][0], poolB["reserves"][1]) else: res_A = (poolA["reserves"][1], poolA["reserves"][0]) res_B = (poolB["reserves"][1], poolB["reserves"][0]) # Вычисляем значение оптимального ввода по формуле x = optimal_trade_size(res_A, res_B) # Пропускаем, если оптимальный вход отрицательный (порядок пулов обратный) if x < 0: continue # Вычисляем валовую прибыль в Wei (до вычета затрат на газ) profit = trade_profit(x, res_A, res_B) # Сохранить сведения о возможной сделке. Значения указаны в ETH. (1e18 Wei = 1 ETH) opps.append({ "profit": прибыль / 1e18, "input": x / 1e18, "pair": пара, "poolA": poolA, "poolB": poolB, })print(f"Найдено {len(opps)} возможностей.")
В результате получается следующий результат:
Получение резервов 3081 пулов.
Найдено 1791 возможность.
У нас есть список всех возможностей. Нам просто нужно оценить их прибыль. Прямо сейчас мы просто предположим постоянную стоимость газа для торговли на возможности.
Нам необходимо использовать нижнюю границу для расходов на газ при обмене на Uniswap V2. Экспериментально мы выяснили, что это значение близко к 43 тыс. газа.
Для использования возможности требуется 2 обмена, а выполнение транзакции на Ethereum стоит фиксированных 21 тыс. газа, в общей сложности 107 тыс. газа на возможность.
Здесь приведен код, который вычисляет ожидаемую чистую прибыль от каждой возможности:
# [...]# Используйте жёстко закодированную стоимость газа в 107 тыс. газа за возможность gp = w3.eth.gas_pricefor opp in opps:opp["net_profit"] = opp["profit"] - 107000 * gp / 1e18# Сортировка по оценочной чистой прибылиopps.sort(key=lambda x: x["net_profit"], reverse=True)# Сохраните положительные возможностиpositive_opps = [opp for opp in opps if opp["net_profit"] > 0]
# Positive возможности countprint(f"Найдено {len(positive_opps)} положительных возможностей.")# Подробности по каждой возможности ETH_PRICE = 1900 # Вы должны динамически извлекать цену ETHfor opp in positive_opps:print(f"Прибыль: {opp['net_profit']} ETH (${opp['net_profit'] * ETH_PRICE})")print(f"Ввод: {opp['input']} ETH (${opp['input'] * ETH_PRICE})")print(f"Пул A: {opp['poolA']['pair']}")print(f"Пул B: {opp['poolB']['pair']}")print()
Здесь вывод скрипта:
Найдено 57 положительных возможностей.
Прибыль: 4.936025725859028 ETH ($9378.448879132153)
Input: 1.7958289984719014 ETH ($3412.075097096613)
Пул A: 0x1498bd576454159Bb81B5Ce532692a8752D163e8
Pool B: 0x7D7E813082eF6c143277c71786e5bE626ec77b20
{‘прибыль’: 4.9374642090282865, ‘ввод’: 1.7958(…)
Прибыль: 4.756587769768892 ETH ($9037.516762560894)
Ввод: 0.32908348765283796 ETH ($625.2586265403921)
Пул A: 0x486c1609f9605fA14C28E311b7D708B0541cd2f5
Pool B: 0x5e81b946b61F3C7F73Bf84dd961dE3A0A78E8c33
{‘profit’: 4.7580262529381505, ‘input’: 0.329(…)
Прибыль: 0.8147203063054365 ETH ($1547.9685819803292)
Input: 0.6715171730669338 ETH ($1275.8826288271744)
Пул A: 0x1f1B4836Dde1859e2edE1C6155140318EF5931C2
Пул B: 0x1f7efDcD748F43Fc4BeAe6897e5a6DDd865DcceA
{‘profit’: 0.8161587894746954, ‘input’: 0.671(…)
(…)
Которые подозрительно высокие прибыли. Первым шагом, который следует предпринять, является проверка правильности кода. После осторожной проверки кода, мы обнаружили, что код правильный.
Эти прибыли реальны? Как оказалось, нет. Мы слишком широко раскинули сеть, выбирая пулы для рассмотрения в нашей стратегии, и получили в свои руки пулы токсичных токенов.
Стандарт токена ERC20 описывает только интерфейс для взаимодействия. Любой может развернуть токен, который реализует этот интерфейс, и выбрать реализацию неортодоксального поведения, вот что происходит здесь.
Некоторые создатели токенов создают свои ERC20 таким образом, что пулы, на которых они торгуются, не могут продавать токен, а могут только покупать его. У некоторых токенов даже есть механизмы аварийного выключения, которые позволяют создателю вытянуть ковер из-под всех его пользователей.
В нашем MEV-боте эти токсичные токены должны быть отфильтрованы. Об этом будет рассказано в одной из следующих статей.
Если мы вручную отфильтруем очевидно токсичные токены, у нас останется следующие 42 возможности:
Прибыль: 0.004126583158496902 ETH ($7.840508001144114)
Ввод: 0.008369804833786892 ETH ($15.902629184195094)
Пул A: 0xdF42388059692150d0A9De836E4171c7B9c09CBf
Пул B: 0xf98fCEB2DC0Fa2B3f32ABccc5e8495E961370B23
{‘profit’: 0.005565066327755902, (…)
Прибыль: 0.004092580415474992 ETH ($7.775902789402485)
Input: 0.014696360216108083 ETH ($27.92308441060536)
Пул A: 0xfDBFb4239935A15C2C348400570E34De3b044c5F
Пул B: 0x0F15d69a7E5998252ccC39Ad239Cef67fa2a9369
{‘profit’: 0.005531063584733992, (…)
Прибыль: 0.003693235163284344 ETH ($7.017146810240254)
Ввод: 0.1392339178514088 ETH ($264.5444439176767)
Pool A: 0x2957215d0473d2c811A075725Da3C31D2af075F1
Пул B: 0xF110783EbD020DCFBA91Cd1976b79a6E510846AA
{‘profit’: 0.005131718332543344, (...)}
Прибыль: 0.003674128918827048 ETH ($6.980844945771391)
Ввод: 0.2719041848570484 ETH ($516.617951228392)
Pool A: 0xBa19343ff3E9f496F17C7333cdeeD212D65A8425
Pool B: 0xD30567f1d084f411572f202ebb13261CE9F46325
{‘profit’: 0.005112612088086048, (…)
(…)
Обратите внимание, что в целом прибыль ниже суммы входа, необходимой для выполнения транзакции.
Эти прибыли намного более разумны. Но помните, что они по-прежнему являются прибылями в лучшем случае, поскольку мы использовали очень грубую оценку стоимости газа для каждой возможности.
В одной из будущих статей мы смоделируем выполнение нашей сделки, чтобы получить точное значение стоимости газа каждой возможности.
Для имитации выполнения нам сначала нужно разработать смарт-контракт, который выполнит сделку. Об этом будет рассказано в следующей статье.
У нас теперь ясное определение периметра нашего бота для арбитража MEV.
Мы изучили математическую теорию арбитражной стратегии и реализовали ее на Python.
У нас есть список потенциальных возможностей для арбитража, и нам нужно смоделировать их выполнение, чтобы получить конечное значение прибыли. Для этого нам нужно иметь готовый торговый смарт-контракт.
В следующей статье мы разработаем такой умный контракт на Solidity и симулируем наш первый арбитражный трейд.
Вы можете найти полный код в репозиторий GitHub, связанный с этой статьей. Сценарий лучше всего запускать в блокноте Jupyter.
Если ваша настройка MEV не выглядит так, как здесь, вы ngmi
Эта статья является частью серии о создании арбитражного бота. Цель этой серии - предоставить пошаговое руководство по созданию автоматизированного торгового робота MEV, который может находить и осуществлять арбитражные возможности на популярных децентрализованных биржах.
В этой статье мы проводим предварительный отбор пар токенов, которые представляют интерес. Затем мы выводим математическую формулу для нахождения оптимального арбитража между двумя пулами одинаковых пар токенов. Наконец, мы реализуем формулу в коде и возвращаем список потенциальных возможностей для арбитража.
Прежде чем мы начнем искать возможности для арбитража, нам необходимо четко определить периметр нашего арбитражного бота. Конкретно, на какие типы арбитража мы хотим реагировать. Самый безопасный вид арбитража - между пулами, включающими ETH. Поскольку ETH - это актив, на который оплачивается газ наших транзакций, естественно всегда хотеть оказаться с ETH после арбитража. Но каждый искушен думать так. Помните, что в торговле своевременные возможности становятся менее прибыльными, по мере того как на них реагирует все больше людей.
Для упрощения мы сосредоточимся на арбитражных возможностях между пулами, включающими ETH. Мы будем искать возможности только между двумя пулами одной пары токенов. Мы не будем торговать возможностями, включающими более 2 пулов в торговом маршруте (так называемые мультипрыжковые возможности). Обратите внимание, что обновление этой стратегии до более рискованной является первым шагом, который вы должны предпринять, чтобы улучшить прибыльность вашего бота.
Для улучшения этой стратегии вы могли бы, например, держать часть инвентаря в стейблкоинах и действовать на арбитражные возможности, приносящие стейблкоины. То же самое можно было бы сделать для более рискованных активов, таких как shitcoins (с необходимыми предосторожностями), и периодически балансировать свой портфель в ETH для оплаты газа.
Другим направлением было бы отказаться от подразумеваемого предположения атомарности, которое мы сделали, и ввести статистическое рассуждение в нашу стратегию. Например, путем покупки одного токена в пуле, когда цена двигалась в более благоприятном направлении, чем некоторое количество стандартных отклонений, а затем продавать его позже (стратегия среднего возвращения). Это было бы идеально для shitcoins, которые не перечислены на более эффективных централизованных биржах, или тех, которые, но цена которых неправильно отслеживается on-chain. В этом участвует намного больше движущихся частей и выходит за рамки этой серии.
Теперь, когда мы определили периметр нашего арбитражного бота, нам нужно выбрать токен-пары, на которых мы хотим торговать. Вот 2 критерия выбора, которые мы будем использовать:
Повторное использование кода изстатья 2: Эффективное чтение цен на пул, у нас есть следующий код, который перечисляет все пары токенов, которые были развернуты предоставленными фабричными контрактами:
# [...]# Загрузите адреса фабричных контрактовwith open("FactoriesV2.json", "r") as f:factories = json.load(f)# [...]# Получите список пулов для каждого фабричного контрактаpairDataList = []for factoryName, factoryData in factories.items():events = getPairEvents(w3.eth.contract(address=factoryData['factory'], abi=factory_abi), 0, w3.eth.block_number)print(f'Найдено {len(events)} пулов для {factoryName}')for e in events: pairDataList.append({ "token0": e["args"]["token0"], "token1": e["args"]["token1"], "pair": e["args"]["pair"], "factory": factoryName })
Мы просто инвертируем pairDataList в словарь, где ключи - это токен-пары, а значения - список пулов, торгующих этой парой. При переборе списка мы игнорируем пары, не связанные с ETH. По завершении цикла пары с как минимум 2 пулами будут выбраны и сохранены в списках с как минимум 2 элементами:
# [...]WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"pair_pool_dict = {}for pair_object in pairDataList:# Check for ETH (WETH) in the pair.pair = (pair_object['token0'], pair_object['token1'])if WETH not in pair: continue# Make sure the pair is referenced in the dictionary. if pair not in pair_pool_dict: pair_pool_dict[pair] = []# Add the pool to the list of pools that trade this pair.pair_pool_dict[pair].append(pair_object)# Create the final dictionnary of pools that will be traded on.pool_dict = {}for pair, pool_list in pair_pool_dict.items():if len(pool_list) >= 2: pool_dict[pair] = pool_list
Некоторые статистические данные следует распечатать, чтобы лучше понимать данные, с которыми мы работаем:
# Количество разных пар
print(f'У нас есть {len(pool_dict)} разных пар.')
# Общее количество пулов
print(f'У нас всего {sum([len(pool_list) for pool_list in pool_dict.values()])} пулов.')
# Пара с наибольшим количеством пулов
print(f'Пара с наибольшим количеством пулов - {max(pool_dict, key=lambda k: len(pool_dict[k]))} с {len(max(pool_dict.values(), key=len))} пулами.')
# Распределение количества пулов на пару, децили
pool_count_list = [len(pool_list) for pool_list in pool_dict.values()]
pool_count_list.sort(reverse=True)
print(f'Количество пулов на пару, в децилях: {pool_count_list[::int(len(pool_count_list)/10)]}')
# Распределение количества пулов на пару, процентили (децили первого дециля)
pool_count_list.sort(reverse=True)
print(f'Количество пулов на пару, в процентилях: {pool_count_list[::int(len(pool_count_list)/100)][:10]}')
На момент написания, это выводит следующее:
У нас есть 1431 различных пар.
У нас всего 3081 пул.
Пара с наибольшим количеством пулов - ('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', '0xdAC17F958D2ee523a2206206994597C13D831ec7') с 16 пулами.
Количество пулов на пару, в децилях: [16, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
Количество пулов на пару, в процентилях: [16, 5, 4, 3, 3, 3, 3, 3, 3, 3]
Получение резервов для 3000 пулов можно выполнить менее чем за 1 секунду с общедоступными узлами RPC. Это разумное количество времени.
Теперь, когда у нас есть все данные, которые нам нужны, нам нужно начать находить арбитражные возможности.
Возможность арбитража возникает всякий раз, когда существует расхождение в цене между двумя пулами, которые торгуют одной и той же парой. Однако не все различия в ценах могут быть эксплуатированы: стоимость газа транзакции устанавливает минимальное значение, которое должно быть окуплено сделкой, а ликвидность в каждом пуле ограничивает ценность, которая может быть извлечена из данного различия в цене.
Для того чтобы найти наиболее прибыльную арбитражную возможность, доступную нам, нам нужно рассчитать потенциальную ценность, извлекаемую из каждой разницы в цене, учитывая резервы/ликвидность в каждом пуле, и оценить стоимость газа для транзакции.
Когда используется арбитражная возможность, цена пула, который покупает входной токен, упадет, а цена пула, который продает, вырастет. Движение цен описывается формулой постоянного продукта.
Мы уже видели в @emileamajar/building-an-arbitrage-bot-automated-market-makers-and-uniswap-2d208215d8c2"> article 1 как вычислить вывод обмена через пул, учитывая резервы этого пула и входную сумму.
Для нахождения оптимального размера сделки мы сначала находим формулу для вывода двух последовательных обменов, учитывая некоторую входную сумму и резервы двух пулов, участвующих в обменах.
Мы предполагаем, что ввод первого обмена происходит в token0, а ввод второго обмена - в token1, что в конечном итоге дает вывод в token0.
Пусть x - это входная сумма, (a1, b1) - резервы первого пула, а (a2, b2) - резервы второго пула. Комиссия - это комиссия, взимаемая пулами, и предполагается, что она одинакова для обоих пулов (в большинстве случаев 0,3%).
Мы определяем функцию, которая вычисляет вывод обмена при заданном входе x и резервах (a, b):
f(x, a, b) = b (1 - a/(a + x(1-fee)))
Тогда мы знаем, что результат первого обмена:
out1(x) = f(x, a1, b1)
out1(x) = b1 (1 - a1/(a1 + x(1-fee)))
Результат второго обмена: (обратите внимание на обмененные резервные переменные)
out2(x) = f(out1(x), b2, a2)
out2(x) = f(f(x, a1, b1), b2, a2)
out2(x) = a2 (1 - b2/(b2 + f(x, a1, b1)(1-fee)))
out2(x) = a2 (1 - b2/(b2 + b1 (1 - a1/(a1 + x (1-fee))) (1-fee)))
Мы можем построить эту функцию, используя desmos. При выборе резервных значений так, чтобы мы имитировали первый пул с 1 ETH и 1750 USDC, а второй пул с 1340 USDC и 1 ETH, мы получаем следующий график:
График валовой прибыли торговли как функция входного значения
Обратите внимание, что мы фактически построили out2(x) - x, что представляет собой прибыль сделки, минус входная сумма.
Графически мы видим, что оптимальный размер сделки составляет 0.0607 ETH ввода, что приносит прибыль в размере 0.0085 ETH. В контракте должно быть не менее 0.0607 ETH ликвидности в WETH, чтобы иметь возможность использовать эту возможность.
Эта прибыль составляет 0,0085 ETH (~$16 на момент написания этой статьи) и НЕ является окончательной прибылью от сделки, поскольку нам еще предстоит учесть стоимость газа для транзакции. Об этом будет рассказано в следующей статье.
Мы хотим автоматически вычислить оптимальный размер сделки для нашего MEV бота. Это можно сделать с помощью элементарного исчисления. У нас есть функция одной переменной x, которую мы хотим максимизировать. Функция достигает максимума для значения x, где производная функции равна 0.
Различные бесплатные и онлайн-инструменты могут быть использованы для символического вычисления производной функции, такой как wolfram alpha.
Нахождение производной нашей функции валовой прибыли.
Найти такой производный очень просто с помощью Wolfram Alpha. Вы также можете сделать это вручную, если вы не уверены в своих математических навыках.
Wolfram Alpha выдает следующую производную:
dout2(x)/dx = (a1b1a2b2(1-fee)^2)/(a1b2 + (1-комиссия)x(b1(1-fee)+b2))^2
Поскольку мы хотим найти значение x, максимизирующее прибыль (которая равна out2(x) - x), нам нужно найти значение x, при котором производная равна 1 (а не 0).
Wolfram Alpha дает следующее решение для x в уравнении dout2(x)/dx = 1:
x = (sqrt(a1b1a2b2(1-fee)^4 (b1(1-сбор)+b2)^2) - a1b2(1-fee)(b1(1-fee)+b2)) / ((1-fee) (b1(1-fee) + b2))^2
Со значениями резервов, использованными на графике выше, мы получаем x_optimal = 0.0607203782551, что подтверждает нашу формулу (по сравнению со значением на графике 0.0607).
Хотя эта формула не очень читаема, ее легко реализовать в коде. Вот реализация формулы на Python для вычисления выхода 2 свопов и оптимального размера сделки:
# Вспомогательные функции для расчета оптимального размера сделки# Выход одного обменаdef swap_output(x, a, b, fee=0.003):return b * (1 - a/(a + x*(1-fee)))# Валовая прибыль двух последовательных обменовdef trade_profit(x, reserves1, reserves2, fee=0.003): a1, b1 = reserves1a2, b2 = reserves2return swap_output(swap_output(x, a1, b1, fee), b2, a2, fee) - x# Оптимальный размер входаdef optimal_trade_size(reserves1, reserves2, fee=0.003):a1, b1 = reserves1a2, b2 = reserves2return (math.sqrt(a1*b1*a2*b2*(1-fee)**4 * (b1*(1-fee)+b2)**2) - a1*b2*(1-fee)*(b1*(1-fee)+b2)) / ((1-fee) * (b1*(1-fee) + b2))**2
Теперь, когда мы знаем, как вычислить валовую прибыль от арбитражной возможности между любыми двумя данными пулами одной и той же пары токенов, нам просто нужно перебрать все пары токенов и протестировать по два пула, имеющих одну и ту же пару токенов. Это даст нам валовую прибыль от всех возможных арбитражных возможностей, которые находятся в пределах нашей стратегии.
Для оценки чистой прибыли сделки нам необходимо оценить стоимость газа для использования определенной возможности. Это можно сделать точно, симулируя транзакцию через eth_call к узлу RPC, но это занимает много времени и может быть выполнено только для нескольких десятков возможностей на блок.
Сначала мы сделаем грубую оценку стоимости газа, предполагая фиксированную стоимость газа за транзакцию (на самом деле, это нижняя граница), и отсеем возможности, которые недостаточно прибыльны, чтобы покрыть стоимость газа. Только после этого мы выполним точную оценку стоимости газа для оставшихся возможностей.
Здесь код, который проходит через все пары и все пулы, и сортирует возможности по прибыли:
# [...] # Получить резервы каждого пула в pool_dictto_fetch = [] # Список адресов пулов, для которых необходимо получить резервы.for pair, pool_list в pool_dict.items():для pair_object в pool_list: to_fetch.append(pair_object["pair"]) # Добавляем адрес пулprint(f"Получение резервов {len(to_fetch)} пулов...")# getReservesParallel() из статьи 2 в MEV-боте seriesreserveList = asyncio.get_event_loop().run_until_complete(getReservesParallel(to_fetch, providersAsync))# Строим список торговых возможностейindex = 0opps = []для пары, pool_list в pool_dict.items():# Сохраняем резервы в объектах пула для последующего использованиядля pair_object в pool_list: pair_object["reserves"] = reserveList[index] index += 1# Перебираем все пулы pairдля пула A в pool_list: для poolB в pool_list: # Пропустить, если это один и тот же пул if poolA["pair"] == poolB["pair"]: continue # Пропустить, если один из резервов равен 0 (деление на 0), если 0 в poolA["reserves"] или 0 в poolB["reserves"]: continue # Переупорядочить резервы так, чтобы WETH всегда был первым токеном if poolA["token0"] == WETH: res_A = (poolA["reserves"][0], poolA["reserves"][1]) res_B = (poolB["reserves"][0], poolB["reserves"][1]) else: res_A = (poolA["reserves"][1], poolA["reserves"][0]) res_B = (poolB["reserves"][1], poolB["reserves"][0]) # Вычисляем значение оптимального ввода по формуле x = optimal_trade_size(res_A, res_B) # Пропускаем, если оптимальный вход отрицательный (порядок пулов обратный) if x < 0: continue # Вычисляем валовую прибыль в Wei (до вычета затрат на газ) profit = trade_profit(x, res_A, res_B) # Сохранить сведения о возможной сделке. Значения указаны в ETH. (1e18 Wei = 1 ETH) opps.append({ "profit": прибыль / 1e18, "input": x / 1e18, "pair": пара, "poolA": poolA, "poolB": poolB, })print(f"Найдено {len(opps)} возможностей.")
В результате получается следующий результат:
Получение резервов 3081 пулов.
Найдено 1791 возможность.
У нас есть список всех возможностей. Нам просто нужно оценить их прибыль. Прямо сейчас мы просто предположим постоянную стоимость газа для торговли на возможности.
Нам необходимо использовать нижнюю границу для расходов на газ при обмене на Uniswap V2. Экспериментально мы выяснили, что это значение близко к 43 тыс. газа.
Для использования возможности требуется 2 обмена, а выполнение транзакции на Ethereum стоит фиксированных 21 тыс. газа, в общей сложности 107 тыс. газа на возможность.
Здесь приведен код, который вычисляет ожидаемую чистую прибыль от каждой возможности:
# [...]# Используйте жёстко закодированную стоимость газа в 107 тыс. газа за возможность gp = w3.eth.gas_pricefor opp in opps:opp["net_profit"] = opp["profit"] - 107000 * gp / 1e18# Сортировка по оценочной чистой прибылиopps.sort(key=lambda x: x["net_profit"], reverse=True)# Сохраните положительные возможностиpositive_opps = [opp for opp in opps if opp["net_profit"] > 0]
# Positive возможности countprint(f"Найдено {len(positive_opps)} положительных возможностей.")# Подробности по каждой возможности ETH_PRICE = 1900 # Вы должны динамически извлекать цену ETHfor opp in positive_opps:print(f"Прибыль: {opp['net_profit']} ETH (${opp['net_profit'] * ETH_PRICE})")print(f"Ввод: {opp['input']} ETH (${opp['input'] * ETH_PRICE})")print(f"Пул A: {opp['poolA']['pair']}")print(f"Пул B: {opp['poolB']['pair']}")print()
Здесь вывод скрипта:
Найдено 57 положительных возможностей.
Прибыль: 4.936025725859028 ETH ($9378.448879132153)
Input: 1.7958289984719014 ETH ($3412.075097096613)
Пул A: 0x1498bd576454159Bb81B5Ce532692a8752D163e8
Pool B: 0x7D7E813082eF6c143277c71786e5bE626ec77b20
{‘прибыль’: 4.9374642090282865, ‘ввод’: 1.7958(…)
Прибыль: 4.756587769768892 ETH ($9037.516762560894)
Ввод: 0.32908348765283796 ETH ($625.2586265403921)
Пул A: 0x486c1609f9605fA14C28E311b7D708B0541cd2f5
Pool B: 0x5e81b946b61F3C7F73Bf84dd961dE3A0A78E8c33
{‘profit’: 4.7580262529381505, ‘input’: 0.329(…)
Прибыль: 0.8147203063054365 ETH ($1547.9685819803292)
Input: 0.6715171730669338 ETH ($1275.8826288271744)
Пул A: 0x1f1B4836Dde1859e2edE1C6155140318EF5931C2
Пул B: 0x1f7efDcD748F43Fc4BeAe6897e5a6DDd865DcceA
{‘profit’: 0.8161587894746954, ‘input’: 0.671(…)
(…)
Которые подозрительно высокие прибыли. Первым шагом, который следует предпринять, является проверка правильности кода. После осторожной проверки кода, мы обнаружили, что код правильный.
Эти прибыли реальны? Как оказалось, нет. Мы слишком широко раскинули сеть, выбирая пулы для рассмотрения в нашей стратегии, и получили в свои руки пулы токсичных токенов.
Стандарт токена ERC20 описывает только интерфейс для взаимодействия. Любой может развернуть токен, который реализует этот интерфейс, и выбрать реализацию неортодоксального поведения, вот что происходит здесь.
Некоторые создатели токенов создают свои ERC20 таким образом, что пулы, на которых они торгуются, не могут продавать токен, а могут только покупать его. У некоторых токенов даже есть механизмы аварийного выключения, которые позволяют создателю вытянуть ковер из-под всех его пользователей.
В нашем MEV-боте эти токсичные токены должны быть отфильтрованы. Об этом будет рассказано в одной из следующих статей.
Если мы вручную отфильтруем очевидно токсичные токены, у нас останется следующие 42 возможности:
Прибыль: 0.004126583158496902 ETH ($7.840508001144114)
Ввод: 0.008369804833786892 ETH ($15.902629184195094)
Пул A: 0xdF42388059692150d0A9De836E4171c7B9c09CBf
Пул B: 0xf98fCEB2DC0Fa2B3f32ABccc5e8495E961370B23
{‘profit’: 0.005565066327755902, (…)
Прибыль: 0.004092580415474992 ETH ($7.775902789402485)
Input: 0.014696360216108083 ETH ($27.92308441060536)
Пул A: 0xfDBFb4239935A15C2C348400570E34De3b044c5F
Пул B: 0x0F15d69a7E5998252ccC39Ad239Cef67fa2a9369
{‘profit’: 0.005531063584733992, (…)
Прибыль: 0.003693235163284344 ETH ($7.017146810240254)
Ввод: 0.1392339178514088 ETH ($264.5444439176767)
Pool A: 0x2957215d0473d2c811A075725Da3C31D2af075F1
Пул B: 0xF110783EbD020DCFBA91Cd1976b79a6E510846AA
{‘profit’: 0.005131718332543344, (...)}
Прибыль: 0.003674128918827048 ETH ($6.980844945771391)
Ввод: 0.2719041848570484 ETH ($516.617951228392)
Pool A: 0xBa19343ff3E9f496F17C7333cdeeD212D65A8425
Pool B: 0xD30567f1d084f411572f202ebb13261CE9F46325
{‘profit’: 0.005112612088086048, (…)
(…)
Обратите внимание, что в целом прибыль ниже суммы входа, необходимой для выполнения транзакции.
Эти прибыли намного более разумны. Но помните, что они по-прежнему являются прибылями в лучшем случае, поскольку мы использовали очень грубую оценку стоимости газа для каждой возможности.
В одной из будущих статей мы смоделируем выполнение нашей сделки, чтобы получить точное значение стоимости газа каждой возможности.
Для имитации выполнения нам сначала нужно разработать смарт-контракт, который выполнит сделку. Об этом будет рассказано в следующей статье.
У нас теперь ясное определение периметра нашего бота для арбитража MEV.
Мы изучили математическую теорию арбитражной стратегии и реализовали ее на Python.
У нас есть список потенциальных возможностей для арбитража, и нам нужно смоделировать их выполнение, чтобы получить конечное значение прибыли. Для этого нам нужно иметь готовый торговый смарт-контракт.
В следующей статье мы разработаем такой умный контракт на Solidity и симулируем наш первый арбитражный трейд.
Вы можете найти полный код в репозиторий GitHub, связанный с этой статьей. Сценарий лучше всего запускать в блокноте Jupyter.