Якщо ваша налаштування MEV не виглядає так, ви не ngmi
Ця стаття є частиною серії про побудову арбітражного бота. Метою цієї серії є надання пошагового посібника з побудови автоматизованого робота з торгівлі MEV, який може знаходити та виконувати арбітражні можливості на популярних децентралізованих біржах.
У цій статті ми виконуємо попередній відбір токенів інтересу. Потім ми отримуємо математичну формулу для знаходження оптимального арбітражу між двома пулами однакових токенів. Нарешті, ми реалізуємо формулу у коді та повертаємо список потенційних можливостей для арбітражу.
Перш ніж ми розпочнемо пошук можливостей для арбітражу, ми повинні чітко визначити периметр нашого бота для арбітражу. Зокрема, якого типу арбітражі ми хочемо виконувати. Найбезпечніший вид арбітражу - це між пулами, які включають ETH. Оскільки ETH - це актив, за допомогою якого сплачується газ наших транзакцій, завжди хочеться завершити арбітраж з ETH. Але всі спокусилися думати так. Пам'ятайте, що в торгівлі вчасні можливості стають менш прибутковими, коли на них реагує все більше людей.
З метою спрощення ми зосередимося на арбітражних можливостях між пулами, які включають ETH. Ми будемо шукати можливості між двома пулами однакової пари токенів. Ми не будемо торгувати можливостями, які включають більше 2 пулів в торговому маршруті (так звані можливості з багатьма кроками). Зверніть увагу, що покращення цієї стратегії до ризикованішої є першим кроком, який вам слід зробити для покращення прибутковості вашого бота.
Для покращення цієї стратегії ви можете, наприклад, зберігати частину інвентарю в стейблкоїнах та використовувати арбітражні можливості, які приносять стейблкоїни. Те ж саме можна зробити для набагато ризикованіших активів, таких як shitcoins (з необхідними заходами обережності), та періодично перебалансувати свій портфель на користь ETH для оплати газу.
Ще одним напрямком було б відмовитися від неявного припущення про атомарність, яке ми зробили, і ввести статистичне мислення в нашу стратегію. Наприклад, купуючи один токен у пулі, коли ціна перемістилася вигідно більше якогось кількості стандартних відхилень, і продавши його пізніше (стратегія середнього повернення). Це було б ідеальним для shitcoins, які не включені в багато ефективніших централізованих бірж, або тих, які є, але ціна яких не вірно відстежується на ланцюжку. Це включає в себе набагато більше рухомих частин і виходить за межі цієї серії.
Тепер, коли ми визначили периметр нашого арбітражного бота, нам потрібно вибрати токен-пари, на яких ми хочемо торгувати. Ось 2 критерії вибору, які ми використовуватимемо:
Повторне використання коду з стаття 2: Ефективне читання цін на пул, у нас є наступний код, який перелічує всі токен-пари, які були розгорнуті контрактами фабрики, що були надані:
# [...]# Завантажте адреси контрактів фабрик за допомогою відкриття ("FactoriesV2.json", "r") як 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 dictionary 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))} пулами.')# Розподіл кількості пулів на пару, дециліперелік_кількості_пулів = [len(pool_list) for pool_list in pool_dict.values()]перелік_кількості_пулів.sort(reverse=True)print(f'Кількість пулів на пару, у децилях: {перелік_кількості_пулів[::int(len(перелік_кількості_пулів)/10)]}')# Розподіл кількості пулів на пару, перцентилі (децилі першого децилю)перелік_кількості_пулів.sort(reverse=True)print(f'Кількість пулів на пару, у перцентилях: {перелік_кількості_пулів[::int(len(перелік_кількості_пулів)/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/buduvannya-arbitrazhnogo-bota-avtomatizovani-market-meykeri-ta-uniswap-2d208215d8c2">стаття 1 як обчислити вихід обміну через пул, враховуючи резерви цього пулу та вхідну суму.
Для того, щоб знайти оптимальний розмір угоди, ми спочатку знаходимо формулу для виходу двох послідовних обмінів, враховуючи певну вхідну суму та резерви двох пулів, які беруть участь у обмінах.
Ми припускаємо, що вхід першої обміну є у токені0, а вхід другої обміну є у токені1, що нарешті призводить до виходу у токені0.
Нехай x - це сума введення, (a1, b1) - резерви першого пула, а (a2, b2) - резерви другого пула. комісія - це комісія, яку беруть пули, і припускається, що вона однакова для обох пулів (зазвичай 0,3%).
Ми визначаємо функцію, яка обчислює вихід обміну при введенні x та резервів (a, b):
f(x, a, b) = b (1 - a/(a + x(1-комісія)))
Тоді ми знаємо, що вихід першої заміни:
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-fee)x(b1(1-відсоток)+b2))^2
Оскільки ми хочемо знайти значення x, що максимізує прибуток (який є out2(x) - x), нам потрібно знайти значення x, де похідна дорівнює 1 (а не 0).
Wolfram Alpha дає наступне рішення для x у рівнянні dout2(x)/dx = 1:
x = (sqrt(a1b1a2b2(1-fee)^4 (b1(1-fee)+b2)^2) - a1b2(1-внесок)(b1(1-збір)+b2)) / ((1-збір) (b1(1-fee) + b2))^2
Зі значеннями резервів, які ми використовували на графіку вище, ми отримуємо x_optimal = 0.0607203782551, що підтверджує нашу формулу (порівняно зі значенням на графіку 0.0607).
Хоча ця формула не дуже читабельна, в коді її легко реалізувати. Ось реалізація цієї формули на Python для обчислення виходу двох обмінів та оптимального розміру угоди:
# Допоміжні функції для розрахунку оптимального розміру угоди# Вихід одного обміну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():for pair_object в pool_list: to_fetch.append(pair_object["pair"]) # Додайте адресу poolprint(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# Перебір всіх пулів паридля пулуA в pool_list: для пулу B в pool_list: # Пропустити, якщо це той самий пул, якщо poolA["pair"] == poolB["pair"]: continue # Пропустити, якщо один із резервів дорівнює 0 (ділення на 0), якщо 0 у пулі A["резерви"] або 0 у пулі B["резерви"]: continue # Перевпорядкуйте резерви так, щоб WETH завжди був першим токеном, якщо 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["резерви"][0]) res_B = (poolB["резерви"][1], poolB["резерви"][0]) # Обчислити значення оптимального входу за формулою x = optimal_trade_size(res_A, res_B) # Пропустити, якщо оптимальний вхід від'ємний (порядок пулів зворотний), якщо x < 0: continue # Обчислити валовий прибуток у Wei (до вартості газу) прибуток = trade_profit(x, res_A, res_B) # Зберігайте деталі можливості. Значення вказані в ETH. (1e18 Wei = 1 ETH) opps.append({ "profit": profit / 1e18, "input": x / 1e18, "pair": pair, "poolA": poolA, "poolB": poolB, })print(f"Found {len(opps)} можливостей.")
Яка виводить наступний результат:
Отримання резервів 3081 пулів.
Знайдено 1791 можливість.
У нас тепер є список всіх можливостей. Нам просто потрібно оцінити їхню прибутковість. Зараз ми просто будемо припускати постійну вартість газу для торгівлі можливістю.
Ми повинні використовувати нижню межу вартості газу для обміну на Uniswap V2. Експериментально ми встановили, що це значення близьке до 43 тис. газу.
Використання можливості потребує 2 обмінів, а виконання транзакції на Ethereum коштує фіксованих 21 тис. газу, що в сумі складає 107 тис. газу на можливість.
Тут поданий код, який обчислює очікуваний чистий прибуток кожної можливості:
# [...]# Використовуйте жорстко закодовану вартість газу 107 тис. газу на можливість gp = w3.eth.gas_priceдля 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 opportunities 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)
Введення: 1.7958289984719014 ETH ($3412.075097096613)
Басейн A: 0x1498bd576454159Bb81B5Ce532692a8752D163e8
Pool B: 0x7D7E813082eF6c143277c71786e5bE626ec77b20
{‘прибуток’: 4.9374642090282865, ‘вхід’: 1.7958(…)
Прибуток: 4.756587769768892 ETH ($9037.516762560894)
Введення: 0.32908348765283796 ETH ($625.2586265403921)
Pool A: 0x486c1609f9605fA14C28E311b7D708B0541cd2f5
Басейн В: 0x5e81b946b61F3C7F73Bf84dd961dE3A0A78E8c33
{‘прибуток’: 4.7580262529381505, ‘введення’: 0.329(…)
Прибуток: 0.8147203063054365 ETH ($1547.9685819803292)
Введення: 0.6715171730669338 ETH ($1275.8826288271744)
Пул A: 0x1f1B4836Dde1859e2edE1C6155140318EF5931C2
Pool B: 0x1f7efDcD748F43Fc4BeAe6897e5a6DDd865DcceA
{‘прибуток’: 0.8161587894746954, ‘вхід’: 0.671(…)
(…)
Які підозріло високі прибутки. Першим кроком, який слід зробити, є перевірка правильності коду. Після обережної перевірки коду ми виявили, що код є правильним.
Чи ці прибутки реальні? Виявляється, ні. Ми розкидалися занадто широко, коли вибирали, які пулы включити в нашу стратегію, і отримали в свої руки пули токсичних токенів.
Стандарт токена ERC20 лише описує інтерфейс для взаємодії. Будь-хто може розгорнути токен, який реалізує цей інтерфейс, і вибрати реалізацію незвичайної поведінки, що саме тут відбувається.
Деякі творці токенів створюють свої ERC20 таким чином, що пули, на яких вони торгуються, не можуть продати, а лише купують токен. Деякі контракти на токени навіть мають механізми автоматичного аварійного відключення, які дозволяють творцю тягнути килим для всіх своїх користувачів.
У нашому боті MEV ці токсичні токени повинні бути відфільтровані. Це буде розглянуто в майбутній статті.
Якщо ми вручну відфільтруємо очевидно токсичні токени, то залишиться лише наступні 42 можливості:
Прибуток: 0.004126583158496902 ETH ($7.840508001144114)
Введення: 0.008369804833786892 ETH ($15.902629184195094)
Pool A: 0xdF42388059692150d0A9De836E4171c7B9c09CBf
Pool B: 0xf98fCEB2DC0Fa2B3f32ABccc5e8495E961370B23
{‘прибуток’: 0.005565066327755902, (...)}
Прибуток: 0.004092580415474992 ETH ($7.775902789402485)
Вхід: 0.014696360216108083 ETH ($27.92308441060536)
Басейн A: 0xfDBFb4239935A15C2C348400570E34De3b044c5F
Басейн B: 0x0F15d69a7E5998252ccC39Ad239Cef67fa2a9369
{‘прибуток’: 0.005531063584733992, (...)
Прибуток: 0.003693235163284344 ETH ($7.017146810240254)
Введення: 0.1392339178514088 ETH ($264.5444439176767)
Pool A: 0x2957215d0473d2c811A075725Da3C31D2af075F1
Pool B: 0xF110783EbD020DCFBA91Cd1976b79a6E510846AA
{‘прибуток’: 0.005131718332543344, (…)
Прибуток: 0.003674128918827048 ETH ($6.980844945771391)
Введення: 0.2719041848570484 ETH ($516.617951228392)
Pool A: 0xBa19343ff3E9f496F17C7333cdeeD212D65A8425
Басейн В: 0xD30567f1d084f411572f202ebb13261CE9F46325
{‘прибуток’: 0.005112612088086048, (...)
(…)
Зверніть увагу, що загалом прибуток менший, ніж сума введення, необхідна для виконання транзакції.
Ці прибутки набагато більш розумні. Але пам'ятайте, що вони все ще є прибутками у випадку найкращого сценарію, оскільки ми використовували дуже грубу оцінку вартості газу кожної можливості.
У майбутній статті ми симулюємо виконання нашої угоди, щоб отримати точне значення вартості газу кожної можливості.
Для того щоб симулювати виконання, нам потрібно спочатку розробити смарт-контракт, який виконає угоду. Це тема наступної статті.
У нас тепер є чітке визначення периметру нашого бота для арбітражу MEV.
Ми дослідили математичну теорію за стратегією арбітражу та реалізували її на Python.
У нас є список потенційних можливостей для арбітражу, і нам потрібно симулювати їх виконання, щоб отримати кінцеве значення прибутку. Для цього нам потрібно мати готовий наш торговий смарт-контракт.
У наступній статті ми розробимо такий розумний контракт на мові Solidity та симулюємо нашу першу арбітражну угоду.
Ви можете знайти повний код в репозиторій github, пов'язаний з цією статтеюСценарій найкраще запускати в блокноті Jupyter.
Якщо ваша налаштування MEV не виглядає так, ви не ngmi
Ця стаття є частиною серії про побудову арбітражного бота. Метою цієї серії є надання пошагового посібника з побудови автоматизованого робота з торгівлі MEV, який може знаходити та виконувати арбітражні можливості на популярних децентралізованих біржах.
У цій статті ми виконуємо попередній відбір токенів інтересу. Потім ми отримуємо математичну формулу для знаходження оптимального арбітражу між двома пулами однакових токенів. Нарешті, ми реалізуємо формулу у коді та повертаємо список потенційних можливостей для арбітражу.
Перш ніж ми розпочнемо пошук можливостей для арбітражу, ми повинні чітко визначити периметр нашого бота для арбітражу. Зокрема, якого типу арбітражі ми хочемо виконувати. Найбезпечніший вид арбітражу - це між пулами, які включають ETH. Оскільки ETH - це актив, за допомогою якого сплачується газ наших транзакцій, завжди хочеться завершити арбітраж з ETH. Але всі спокусилися думати так. Пам'ятайте, що в торгівлі вчасні можливості стають менш прибутковими, коли на них реагує все більше людей.
З метою спрощення ми зосередимося на арбітражних можливостях між пулами, які включають ETH. Ми будемо шукати можливості між двома пулами однакової пари токенів. Ми не будемо торгувати можливостями, які включають більше 2 пулів в торговому маршруті (так звані можливості з багатьма кроками). Зверніть увагу, що покращення цієї стратегії до ризикованішої є першим кроком, який вам слід зробити для покращення прибутковості вашого бота.
Для покращення цієї стратегії ви можете, наприклад, зберігати частину інвентарю в стейблкоїнах та використовувати арбітражні можливості, які приносять стейблкоїни. Те ж саме можна зробити для набагато ризикованіших активів, таких як shitcoins (з необхідними заходами обережності), та періодично перебалансувати свій портфель на користь ETH для оплати газу.
Ще одним напрямком було б відмовитися від неявного припущення про атомарність, яке ми зробили, і ввести статистичне мислення в нашу стратегію. Наприклад, купуючи один токен у пулі, коли ціна перемістилася вигідно більше якогось кількості стандартних відхилень, і продавши його пізніше (стратегія середнього повернення). Це було б ідеальним для shitcoins, які не включені в багато ефективніших централізованих бірж, або тих, які є, але ціна яких не вірно відстежується на ланцюжку. Це включає в себе набагато більше рухомих частин і виходить за межі цієї серії.
Тепер, коли ми визначили периметр нашого арбітражного бота, нам потрібно вибрати токен-пари, на яких ми хочемо торгувати. Ось 2 критерії вибору, які ми використовуватимемо:
Повторне використання коду з стаття 2: Ефективне читання цін на пул, у нас є наступний код, який перелічує всі токен-пари, які були розгорнуті контрактами фабрики, що були надані:
# [...]# Завантажте адреси контрактів фабрик за допомогою відкриття ("FactoriesV2.json", "r") як 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 dictionary 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))} пулами.')# Розподіл кількості пулів на пару, дециліперелік_кількості_пулів = [len(pool_list) for pool_list in pool_dict.values()]перелік_кількості_пулів.sort(reverse=True)print(f'Кількість пулів на пару, у децилях: {перелік_кількості_пулів[::int(len(перелік_кількості_пулів)/10)]}')# Розподіл кількості пулів на пару, перцентилі (децилі першого децилю)перелік_кількості_пулів.sort(reverse=True)print(f'Кількість пулів на пару, у перцентилях: {перелік_кількості_пулів[::int(len(перелік_кількості_пулів)/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/buduvannya-arbitrazhnogo-bota-avtomatizovani-market-meykeri-ta-uniswap-2d208215d8c2">стаття 1 як обчислити вихід обміну через пул, враховуючи резерви цього пулу та вхідну суму.
Для того, щоб знайти оптимальний розмір угоди, ми спочатку знаходимо формулу для виходу двох послідовних обмінів, враховуючи певну вхідну суму та резерви двох пулів, які беруть участь у обмінах.
Ми припускаємо, що вхід першої обміну є у токені0, а вхід другої обміну є у токені1, що нарешті призводить до виходу у токені0.
Нехай x - це сума введення, (a1, b1) - резерви першого пула, а (a2, b2) - резерви другого пула. комісія - це комісія, яку беруть пули, і припускається, що вона однакова для обох пулів (зазвичай 0,3%).
Ми визначаємо функцію, яка обчислює вихід обміну при введенні x та резервів (a, b):
f(x, a, b) = b (1 - a/(a + x(1-комісія)))
Тоді ми знаємо, що вихід першої заміни:
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-fee)x(b1(1-відсоток)+b2))^2
Оскільки ми хочемо знайти значення x, що максимізує прибуток (який є out2(x) - x), нам потрібно знайти значення x, де похідна дорівнює 1 (а не 0).
Wolfram Alpha дає наступне рішення для x у рівнянні dout2(x)/dx = 1:
x = (sqrt(a1b1a2b2(1-fee)^4 (b1(1-fee)+b2)^2) - a1b2(1-внесок)(b1(1-збір)+b2)) / ((1-збір) (b1(1-fee) + b2))^2
Зі значеннями резервів, які ми використовували на графіку вище, ми отримуємо x_optimal = 0.0607203782551, що підтверджує нашу формулу (порівняно зі значенням на графіку 0.0607).
Хоча ця формула не дуже читабельна, в коді її легко реалізувати. Ось реалізація цієї формули на Python для обчислення виходу двох обмінів та оптимального розміру угоди:
# Допоміжні функції для розрахунку оптимального розміру угоди# Вихід одного обміну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():for pair_object в pool_list: to_fetch.append(pair_object["pair"]) # Додайте адресу poolprint(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# Перебір всіх пулів паридля пулуA в pool_list: для пулу B в pool_list: # Пропустити, якщо це той самий пул, якщо poolA["pair"] == poolB["pair"]: continue # Пропустити, якщо один із резервів дорівнює 0 (ділення на 0), якщо 0 у пулі A["резерви"] або 0 у пулі B["резерви"]: continue # Перевпорядкуйте резерви так, щоб WETH завжди був першим токеном, якщо 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["резерви"][0]) res_B = (poolB["резерви"][1], poolB["резерви"][0]) # Обчислити значення оптимального входу за формулою x = optimal_trade_size(res_A, res_B) # Пропустити, якщо оптимальний вхід від'ємний (порядок пулів зворотний), якщо x < 0: continue # Обчислити валовий прибуток у Wei (до вартості газу) прибуток = trade_profit(x, res_A, res_B) # Зберігайте деталі можливості. Значення вказані в ETH. (1e18 Wei = 1 ETH) opps.append({ "profit": profit / 1e18, "input": x / 1e18, "pair": pair, "poolA": poolA, "poolB": poolB, })print(f"Found {len(opps)} можливостей.")
Яка виводить наступний результат:
Отримання резервів 3081 пулів.
Знайдено 1791 можливість.
У нас тепер є список всіх можливостей. Нам просто потрібно оцінити їхню прибутковість. Зараз ми просто будемо припускати постійну вартість газу для торгівлі можливістю.
Ми повинні використовувати нижню межу вартості газу для обміну на Uniswap V2. Експериментально ми встановили, що це значення близьке до 43 тис. газу.
Використання можливості потребує 2 обмінів, а виконання транзакції на Ethereum коштує фіксованих 21 тис. газу, що в сумі складає 107 тис. газу на можливість.
Тут поданий код, який обчислює очікуваний чистий прибуток кожної можливості:
# [...]# Використовуйте жорстко закодовану вартість газу 107 тис. газу на можливість gp = w3.eth.gas_priceдля 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 opportunities 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)
Введення: 1.7958289984719014 ETH ($3412.075097096613)
Басейн A: 0x1498bd576454159Bb81B5Ce532692a8752D163e8
Pool B: 0x7D7E813082eF6c143277c71786e5bE626ec77b20
{‘прибуток’: 4.9374642090282865, ‘вхід’: 1.7958(…)
Прибуток: 4.756587769768892 ETH ($9037.516762560894)
Введення: 0.32908348765283796 ETH ($625.2586265403921)
Pool A: 0x486c1609f9605fA14C28E311b7D708B0541cd2f5
Басейн В: 0x5e81b946b61F3C7F73Bf84dd961dE3A0A78E8c33
{‘прибуток’: 4.7580262529381505, ‘введення’: 0.329(…)
Прибуток: 0.8147203063054365 ETH ($1547.9685819803292)
Введення: 0.6715171730669338 ETH ($1275.8826288271744)
Пул A: 0x1f1B4836Dde1859e2edE1C6155140318EF5931C2
Pool B: 0x1f7efDcD748F43Fc4BeAe6897e5a6DDd865DcceA
{‘прибуток’: 0.8161587894746954, ‘вхід’: 0.671(…)
(…)
Які підозріло високі прибутки. Першим кроком, який слід зробити, є перевірка правильності коду. Після обережної перевірки коду ми виявили, що код є правильним.
Чи ці прибутки реальні? Виявляється, ні. Ми розкидалися занадто широко, коли вибирали, які пулы включити в нашу стратегію, і отримали в свої руки пули токсичних токенів.
Стандарт токена ERC20 лише описує інтерфейс для взаємодії. Будь-хто може розгорнути токен, який реалізує цей інтерфейс, і вибрати реалізацію незвичайної поведінки, що саме тут відбувається.
Деякі творці токенів створюють свої ERC20 таким чином, що пули, на яких вони торгуються, не можуть продати, а лише купують токен. Деякі контракти на токени навіть мають механізми автоматичного аварійного відключення, які дозволяють творцю тягнути килим для всіх своїх користувачів.
У нашому боті MEV ці токсичні токени повинні бути відфільтровані. Це буде розглянуто в майбутній статті.
Якщо ми вручну відфільтруємо очевидно токсичні токени, то залишиться лише наступні 42 можливості:
Прибуток: 0.004126583158496902 ETH ($7.840508001144114)
Введення: 0.008369804833786892 ETH ($15.902629184195094)
Pool A: 0xdF42388059692150d0A9De836E4171c7B9c09CBf
Pool B: 0xf98fCEB2DC0Fa2B3f32ABccc5e8495E961370B23
{‘прибуток’: 0.005565066327755902, (...)}
Прибуток: 0.004092580415474992 ETH ($7.775902789402485)
Вхід: 0.014696360216108083 ETH ($27.92308441060536)
Басейн A: 0xfDBFb4239935A15C2C348400570E34De3b044c5F
Басейн B: 0x0F15d69a7E5998252ccC39Ad239Cef67fa2a9369
{‘прибуток’: 0.005531063584733992, (...)
Прибуток: 0.003693235163284344 ETH ($7.017146810240254)
Введення: 0.1392339178514088 ETH ($264.5444439176767)
Pool A: 0x2957215d0473d2c811A075725Da3C31D2af075F1
Pool B: 0xF110783EbD020DCFBA91Cd1976b79a6E510846AA
{‘прибуток’: 0.005131718332543344, (…)
Прибуток: 0.003674128918827048 ETH ($6.980844945771391)
Введення: 0.2719041848570484 ETH ($516.617951228392)
Pool A: 0xBa19343ff3E9f496F17C7333cdeeD212D65A8425
Басейн В: 0xD30567f1d084f411572f202ebb13261CE9F46325
{‘прибуток’: 0.005112612088086048, (...)
(…)
Зверніть увагу, що загалом прибуток менший, ніж сума введення, необхідна для виконання транзакції.
Ці прибутки набагато більш розумні. Але пам'ятайте, що вони все ще є прибутками у випадку найкращого сценарію, оскільки ми використовували дуже грубу оцінку вартості газу кожної можливості.
У майбутній статті ми симулюємо виконання нашої угоди, щоб отримати точне значення вартості газу кожної можливості.
Для того щоб симулювати виконання, нам потрібно спочатку розробити смарт-контракт, який виконає угоду. Це тема наступної статті.
У нас тепер є чітке визначення периметру нашого бота для арбітражу MEV.
Ми дослідили математичну теорію за стратегією арбітражу та реалізували її на Python.
У нас є список потенційних можливостей для арбітражу, і нам потрібно симулювати їх виконання, щоб отримати кінцеве значення прибутку. Для цього нам потрібно мати готовий наш торговий смарт-контракт.
У наступній статті ми розробимо такий розумний контракт на мові Solidity та симулюємо нашу першу арбітражну угоду.
Ви можете знайти повний код в репозиторій github, пов'язаний з цією статтеюСценарій найкраще запускати в блокноті Jupyter.