Si votre installation MEV ne ressemble pas à ceci, vous êtes ngmi
Cet article fait partie d'une série sur la construction d'un bot d'arbitrage. L'objectif de cette série est de fournir un guide étape par étape pour construire un robot de trading MEV automatisé qui peut trouver et exécuter des opportunités d'arbitrage sur les échanges décentralisés populaires.
Dans cet article, nous effectuons une présélection de paires de jetons d'intérêt. Nous dérivons ensuite la formule mathématique pour trouver l'arbitrage optimal entre deux pools de mêmes paires de jetons. Enfin, nous implémentons la formule en code et retournons une liste d'opportunités d'arbitrage potentielles.
Avant de commencer à rechercher des opportunités d'arbitrage, nous devons définir clairement le périmètre de notre bot d'arbitrage. Plus précisément, quel type d'arbitrages voulons-nous effectuer. Le type d'arbitrage le plus sûr est entre les pools impliquant de l'ETH. Puisque l'ETH est l'actif avec lequel le gaz de nos transactions est payé, il est naturel de vouloir toujours finir avec de l'ETH après un arbitrage. Mais tout le monde est tenté de penser de cette façon. Gardez à l'esprit que, dans le trading, les opportunités ponctuelles deviennent de moins en moins rentables à mesure que plus de gens agissent dessus.
Dans un souci de simplicité, nous nous concentrerons sur les opportunités d'arbitrage entre les pools impliquant de l'ETH. Nous ne rechercherons que les opportunités entre deux pools du même couple de jetons. Nous ne traderons pas sur les opportunités impliquant plus de 2 pools dans l'itinéraire de trading (les soi-disant opportunités multi-sauts). Notez que passer à une stratégie plus risquée est la première étape que vous devriez prendre pour améliorer la rentabilité de votre bot.
Pour améliorer cette stratégie, vous pourriez par exemple conserver une partie de l'inventaire en stablecoins et agir sur les opportunités d'arbitrage qui rapportent des stablecoins. La même chose pourrait être faite pour des actifs beaucoup plus risqués comme les shitcoins (avec les précautions nécessaires), et rééquilibrer périodiquement votre portefeuille en ETH pour payer les frais de gaz.
Une autre direction consisterait à abandonner l'hypothèse implicite d'atomicité que nous avons faite et à introduire un raisonnement statistique dans notre stratégie. Par exemple, en achetant un jeton dans un pool lorsque le prix a évolué de manière favorable de plus d'un certain nombre d'écart-types, et en le vendant plus tard (stratégie de réversion à la moyenne). Cela serait idéal pour les shitcoins qui ne sont pas cotés sur des bourses centralisées beaucoup plus efficaces, ou ceux qui le sont mais dont le prix n'est pas correctement suivi on-chain. Cela implique beaucoup plus de pièces mobiles et sort du cadre de cette série.
Maintenant que nous avons défini le périmètre de notre bot d'arbitrage, nous devons sélectionner les paires de jetons sur lesquelles nous voulons trader. Voici les 2 critères de sélection que nous utiliserons :
Réutiliser le code de article 2: Lecture efficace des prix des pools, nous avons le code suivant qui répertorie tous les paires de jetons qui ont été déployées par les contrats d'usine fournis :
# [...]# Chargez les adresses des contrats d'usineavec open("FactoriesV2.json", "r") as f:factories = json.load(f)# [...]# Récupérez la liste des pools pour chaque contrat d'usinepairDataList = []for factoryName, factoryData in factories.items():events = getPairEvents(w3.eth.contract(address=factoryData['factory'], abi=factory_abi), 0, w3.eth.block_number)print(f'Trouvé {len(events)} pools pour {factoryName}')for e in events: pairDataList.append({ "token0": e["args"]["token0"], "token1": e["args"]["token1"], "pair": e["args"]["pair"], "factory": factoryName })
Nous allons simplement inverser pairDataList en un dictionnaire où les clés sont les paires de jetons, et les valeurs sont la liste des pools qui échangent cette paire. Lors du parcours de la liste, nous ignorons les paires qui n'impliquent pas ETH. Lorsque la boucle est terminée, les paires avec au moins 2 pools sélectionnés seront stockées dans des listes avec au moins 2 éléments:
# [...]WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"pair_pool_dict = {}for pair_object in pairDataList:# Vérifiez la présence d'ETH (WETH) dans la paire.pair = (pair_object['token0'], pair_object['token1'])if WETH not in pair: continue# Assurez-vous que la paire est référencée dans le dictionnaire. if pair not in pair_pool_dict: pair_pool_dict[pair] = []# Ajoutez le pool à la liste des pools échangeant cette paire.pair_pool_dict[pair].append(pair_object)# Créez le dictionnaire final des pools qui seront échangés.pool_dict = {}for pair, pool_list in pair_pool_dict.items():if len(pool_list) >= 2: pool_dict[pair] = pool_list
Certains statistiques devraient être imprimées pour mieux appréhender les données avec lesquelles nous travaillons :
# Nombre de paires différentesprint(f'Nous avons {len(pool_dict)} paires différentes.')# Nombre total de poolsprint(f'Nous avons {sum([len(pool_list) for pool_list in pool_dict.values()])} pools au total.')# Paire avec le plus de pools print(f'La paire avec le plus de pools est {max(pool_dict, key=lambda k: len(pool_dict[k]))} avec {len(max(pool_dict.values(), key=len))} pools.')# Répartition du nombre de pools par paire, décilespool_count_list = [len(pool_list) for pool_list in pool_dict.values()]pool_count_list.sort(reverse=True)print(f'Nombre de pools par paire, en déciles : {pool_count_list[::int(len(pool_count_list)/10)]}')# Répartition du nombre de pools par paire, percentiles (déciles du premier décile)pool_count_list.sort(reverse=True)print(f'Nombre de pools par paire, en percentiles : {pool_count_list[::int(len(pool_count_list)/100)][:10]}')
Au moment de l'écriture, cela génère ce qui suit:
Nous avons 1431 paires différentes.
Nous avons 3081 pools au total.
La paire avec le plus grand nombre de pools est (‘0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2’, ‘0xdAC17F958D2ee523a2206206994597C13D831ec7’) avec 16 pools.
Nombre de pools par paire, en déciles : [16, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
Nombre de pools par paire, en percentiles : [16, 5, 4, 3, 3, 3, 3, 3, 3, 3]
La récupération des réserves pour 3000 pools peut être faite en moins d'une seconde avec des nœuds RPC publics. C'est un laps de temps raisonnable.
Maintenant, nous avons toutes les données dont nous avons besoin, nous devons commencer à trouver des opportunités d'arbitrage.
Il existe une opportunité d'arbitrage chaque fois qu'il y a une divergence de prix entre deux pools qui échangent la même paire. Cependant, toutes les différences de prix ne sont pas exploitables : le coût en gaz de la transaction fixe une valeur minimale qui doit être récupérée par le trade, et la liquidité dans chaque pool limite la valeur qui peut être extraite d'une différence de prix donnée.
Afin de trouver l'opportunité d'arbitrage la plus rentable accessible pour nous, nous devrons calculer la valeur potentielle pouvant être extraite de chaque différence de prix, en tenant compte des réserves/liquidités dans chaque pool, et estimer le coût en gaz de la transaction.
Lorsqu'une opportunité d'arbitrage est exploitée, le prix du pool qui achète le jeton d'entrée baissera, et le prix du pool qui vendra augmentera. Le mouvement des prix est décrit par la formule du produit constant.
Nous avons déjà vu dans @emileamajar/construction-dun-bot-darbitrage-les-fabricants-de-marches-automatises-et-uniswap-2d208215d8c2">article 1 comment calculer la sortie d'un échange via un pool, étant donné les réserves de ce pool et le montant d'entrée.
Afin de trouver la taille de transaction optimale, nous commençons par trouver une formule pour le résultat de deux échanges successifs, étant donné un montant d'entrée et les réserves des deux pools impliqués dans les échanges.
Nous supposons que l'entrée du premier échange est en token0, et que l'entrée du deuxième échange est en token1, ce qui donne finalement une sortie en token0.
Soit x le montant d'entrée, (a1, b1) les réserves du premier pool, et (a2, b2) les réserves du deuxième pool. La commission est la commission prélevée par les pools, et on suppose qu'elle est la même pour les deux pools (0,3 % la plupart du temps).
Nous définissons une fonction qui calcule la sortie d'un échange, étant donné l'entrée x et les réserves (a,b):
f(x, a, b) = b (1 - a/(a + x(1-frais)))
Nous savons alors que la sortie du premier échange est:
out1(x) = f(x, a1, b1)
out1(x) = b1 (1 - a1/(a1 + x(1-fee)))
La sortie du deuxième échange est : (remarquez les variables de réserve échangées)
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-frais)))
out2(x) = a2 (1 - b2/(b2 + b1 (1 - a1/(a1 + x (1-fee))) (1-frais)))
Nous pouvons tracer cette fonction en utilisant desmosEn choisissant les valeurs de réserve de telle sorte que nous simulons le premier pool ayant 1 ETH et 1750 USDC, et le deuxième pool ayant 1340 USDC et 1 ETH, nous obtenons le graphique suivant :
Graphique du bénéfice brut du commerce en fonction de la valeur d'entrée
Notez que nous avons réellement tracé out2(x) - x, qui est le profit de l'échange, moins le montant d'entrée.
Graphiquement, nous pouvons voir que la taille de transaction optimale est de 0,0607 ETH en entrée, ce qui génère un bénéfice de 0,0085 ETH. Le contrat doit avoir au moins 0,0607 ETH de liquidité en WETH afin de pouvoir exploiter cette opportunité.
Cette valeur de profit de 0,0085 ETH (~16 $ lors de la rédaction de cet article) n'est PAS le profit final de la transaction, car nous devons encore prendre en compte le coût du gaz de la transaction. Cela sera discuté dans un article ultérieur.
Nous voulons calculer automatiquement la taille de transaction optimale pour notre bot MEV. Cela peut être fait grâce au calcul élémentaire. Nous avons une fonction d'une variable x que nous voulons maximiser. La fonction atteint son maximum pour une valeur de x où la dérivée de la fonction est 0.
Divers outils gratuits et en ligne peuvent être utilisés pour calculer de manière symbolique la dérivée d'une fonction, tels que wolfram alpha.
Trouver la dérivée de notre fonction de bénéfice brut.
Trouver un tel dérivé est très simple avec Wolfram Alpha. Vous pouvez également le faire à la main si vous manquez de confiance en vos compétences en mathématiques.
Wolfram Alpha donne la dérivée suivante :
dout2(x)/dx = (a1b1a2b2(1-frais)^2)/(a1b2 + (1-frais)x(b1(1-frais)+b2))^2
Puisque nous voulons trouver la valeur de x qui maximise le profit (qui est out2(x) - x), nous devons trouver la valeur de x où la dérivée est 1 (et non pas 0).
Wolfram Alpha donne la solution suivante pour x dans l'équation dout2(x)/dx = 1 :
x = (sqrt(a1b1a2b2(1-frais)^4 (b1(1-frais)+b2)^2) - a1b2(1-fee)(b1(1-frais)+b2)) / ((1-frais) (b1(1-frais) + b2))^2
Avec les valeurs des réserves que nous avons utilisées dans le graphique ci-dessus, nous obtenons x_optimal = 0.0607203782551, ce qui valide notre formule (par rapport à la valeur du graphique de 0.0607).
Bien que cette formule ne soit pas très lisible, elle est facile à implémenter en code. Voici une implémentation en python de la formule pour calculer la sortie des 2 swaps et la taille de transaction optimale :
# Fonctions d'aide pour calculer la taille de transaction optimale# Rendement d'un échange uniquedef swap_output(x, a, b, fee=0.003):return b * (1 - a/(a + x*(1-fee)))# Profit brut de deux échanges successifsdef 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# Montant d'entrée optimaldef 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
Maintenant que nous savons comment calculer le bénéfice brut d'une opportunité d'arbitrage entre deux pools donnés du même couple de jetons, il nous suffit simplement de parcourir tous les couples de jetons, et de tester tous les deux à deux tous les pools ayant le même couple de jetons. Cela nous donnera le bénéfice brut de toutes les opportunités d'arbitrage possibles qui sont dans le périmètre de notre stratégie.
Pour estimer le bénéfice net d'un trade, nous devons estimer le coût en gaz de l'exploitation d'une opportunité donnée. Cela peut être fait précisément en simulant la transaction via un eth_call à un nœud RPC, mais cela prend beaucoup de temps et ne peut être effectué que pour quelques dizaines d'opportunités par bloc.
Nous ferons d'abord une estimation brute du coût du gaz en supposant un coût fixe de gaz de transaction (en fait, un minimum), et éliminerons les opportunités qui ne sont pas assez rentables pour couvrir le coût du gaz. Ce n'est qu'alors que nous ferons une estimation précise du coût du gaz pour les opportunités restantes.
Voici le code qui parcourt tous les paires et tous les pools, et trie les opportunités par profit :
# [...] # Récupère les réserves de chaque pool dans pool_dictto_fetch = [] # Liste des adresses de pool pour lesquelles les réserves doivent être récupérées.for pair, pool_list in pool_dict.items() :for pair_object in pool_list : to_fetch.append(pair_object["pair"]) # Ajout de l’adresse poolprint(f"Récupération des réserves de {len(to_fetch)} pools... »)# getReservesParallel() provient de l’article 2 de la série de bots MEVRESERVEList = asyncio.get_event_loop().run_until_complete(getReservesParallel(to_fetch, providersAsync))# Construire la liste des opportunités de tradingindex = 0opps = []pour la paire, pool_list dans pool_dict.items() :# Stocker les réserves dans les objets du pool pour une utilisation ultérieure pour pair_object dans pool_list : pair_object["reserves"] = reserveList[index] index += 1# Itérer sur tous les pools de la pairepour poolA dans pool_list : pour poolB dans pool_list : # Ignorer s’il s’agit du même pool si poolA["pair"] == poolB["pair"] : continue # Ignorer si l’une des réserves est 0 (division par 0) if 0 in poolA["reserves"] ou 0 in poolB["reserves"] : continue # Réorganiser les réserves pour que WETH soit toujours le premier jeton 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]) # Calculer la valeur de l’entrée optimale via la formule x = optimal_trade_size(res_A, res_B) # Ignorer si l’entrée optimale est négative (l’ordre des pools est inversé) si x < 0 : continue # Calculer la marge brute en Wei (avant le coût du gaz) profit = trade_profit(x, res_A, res_B) # Stocker les détails de l’opportunité. Les valeurs sont exprimées en ETH. (1e18 Wei = 1 ETH) opps.append({ « profit » : profit / 1e18, « input » : x / 1e18, « pair » : paire, « poolA » : poolA, « poolB » : poolB, })print(f"Opportunités {len(opps)} trouvées. »)
Ce qui produit la sortie suivante :
Récupération des réserves de 3081 pools.
Trouvé 1791 opportunités.
Nous avons maintenant une liste de toutes les opportunités. Il nous suffit d'estimer leur profit. Pour l'instant, nous supposerons simplement un coût constant en gaz pour trader sur une opportunité.
Nous devons utiliser une limite inférieure pour le coût en gaz d'un échange sur Uniswap V2. Expérimentalement, nous avons trouvé que cette valeur est proche de 43k gaz.
Exploiter une opportunité nécessite 2 swaps, et exécuter une transaction sur Ethereum coûte un forfait de 21k de gaz, pour un total de 107k de gaz par opportunité.
Voici le code qui calcule le bénéfice net estimé de chaque opportunité :
# [...]# Utilisez le coût du gaz codé en dur de 107k gaz par opportunité gp = w3.eth.gas_pricefor opp in opps:opp["net_profit"] = opp["profit"] - 107000 * gp / 1e18# Trier par bénéfice net estiméopps.sort(key=lambda x: x["net_profit"], reverse=True)# Conserver les opportunités positivespositive_opps = [opp for opp in opps if opp["net_profit"] > 0]
# Compte des opportunités positivesprint(f"Trouvé {len(positive_opps)} opportunités positives.")# Détails sur chaque opportunité ETH_PRIX = 1900 # Vous devriez récupérer dynamiquement le prix de l'ETHpour opp in positive_opps:print(f"Profit : {opp['net_profit']} ETH (${opp['net_profit'] * ETH_PRIX})")print(f"Investissement : {opp['input']} ETH (${opp['input'] * ETH_PRIX})")print(f"Pool A : {opp['poolA']['pair']}")print(f"Pool B : {opp['poolB']['pair']}")print()
Voici la sortie du script :
Trouvé 57 opportunités positives.
Profit: 4.936025725859028 ETH ($9378.448879132153)
Entrée : 1.7958289984719014 ETH ($3412.075097096613)
Pool A: 0x1498bd576454159Bb81B5Ce532692a8752D163e8
Pool B: 0x7D7E813082eF6c143277c71786e5bE626ec77b20
{‘profit’: 4.9374642090282865, ‘input’: 1.7958(...)
Profit: 4.756587769768892 ETH ($9037.516762560894)
Input: 0.32908348765283796 ETH ($625.2586265403921)
Pool A: 0x486c1609f9605fA14C28E311b7D708B0541cd2f5
Pool B: 0x5e81b946b61F3C7F73Bf84dd961dE3A0A78E8c33
{‘profit’: 4.7580262529381505, ‘input’: 0.329(…)
Bénéfice : 0.8147203063054365 ETH ($1547.9685819803292)
Input: 0.6715171730669338 ETH ($1275.8826288271744)
Pool A: 0x1f1B4836Dde1859e2edE1C6155140318EF5931C2
Pool B: 0x1f7efDcD748F43Fc4BeAe6897e5a6DDd865DcceA
{‘profit’: 0.8161587894746954, ‘input’: 0.671(…)
(...)
Des profits anormalement élevés. La première étape à suivre est de vérifier que le code est correct. Après avoir vérifié avec prudence le code, nous avons constaté que le code est correct.
Ces bénéfices sont-ils réels? Il s'avère que non. Nous avons étendu notre filet trop largement en sélectionnant les pools à prendre en compte dans notre stratégie, et nous avons mis la main sur des pools de jetons toxiques.
Le standard de jeton ERC20 décrit uniquement une interface pour l'interopérabilité. N'importe qui peut déployer un jeton qui implémente cette interface et choisir de mettre en œuvre un comportement non orthodoxe, ce qui est exactement ce qui se passe ici.
Certains créateurs de jetons conçoivent leurs ERC20 de sorte que les pools sur lesquels ils sont échangés ne peuvent pas vendre, mais seulement acheter le jeton. Certains contrats de jetons ont même des mécanismes de kill-switch qui permettent au créateur de rug-pull tous ses utilisateurs.
Dans notre bot MEV, ces jetons toxiques doivent être filtrés. Cela sera abordé dans un prochain article.
Si nous filtrons manuellement les jetons toxiques évidents, il nous reste les 42 opportunités suivantes :
Profit : 0.004126583158496902 ETH ($7.840508001144114)
Entrée : 0.008369804833786892 ETH (15.902629184195094 $)
Pool A: 0xdF42388059692150d0A9De836E4171c7B9c09CBf
Pool B: 0xf98fCEB2DC0Fa2B3f32ABccc5e8495E961370B23
{‘profit’: 0.005565066327755902, (…)
Profit: 0.004092580415474992 ETH ($7.775902789402485)
Entrée : 0.014696360216108083 ETH ($27.92308441060536)
Pool A: 0xfDBFb4239935A15C2C348400570E34De3b044c5F
Pool B: 0x0F15d69a7E5998252ccC39Ad239Cef67fa2a9369
{‘profit’: 0.005531063584733992, (…)
Profit: 0.003693235163284344 ETH ($7.017146810240254)
Input: 0.1392339178514088 ETH ($264.5444439176767)
Pool A: 0x2957215d0473d2c811A075725Da3C31D2af075F1
Pool B: 0xF110783EbD020DCFBA91Cd1976b79a6E510846AA
{‘profit’: 0.005131718332543344, (...)
Profit : 0.003674128918827048 ETH ($6.980844945771391)
Entrée : 0.2719041848570484 ETH ($516.617951228392)
Pool A: 0xBa19343ff3E9f496F17C7333cdeeD212D65A8425
Pool B: 0xD30567f1d084f411572f202ebb13261CE9F46325
{‘profit’: 0.005112612088086048, (…)
(…)
Remarquez que en général, les bénéfices sont inférieurs au montant d'entrée nécessaire pour exécuter la transaction.
Ces bénéfices sont beaucoup plus raisonnables. Mais rappelez-vous qu'ils restent des bénéfices dans le meilleur des cas, car nous avons utilisé une estimation très rudimentaire du coût en gaz de chaque opportunité.
Dans un prochain article, nous simulerons l'exécution de notre transaction afin d'obtenir une valeur précise du coût en gaz de chaque opportunité.
Afin de simuler l'exécution, nous devons d'abord développer le contrat intelligent qui exécutera l'échange. C'est le sujet de l'article suivant.
Nous avons maintenant une définition claire du périmètre de notre bot d'arbitrage MEV.
Nous avons exploré la théorie mathématique derrière la stratégie d'arbitrage et l'avons implémentée en Python.
Nous avons maintenant une liste de potentielles opportunités d'arbitrage, et nous devons simuler leur exécution afin d'obtenir une valeur de profit final. Pour ce faire, nous devons avoir notre contrat intelligent de trading prêt.
Dans le prochain article, nous développerons un tel contrat intelligent en Solidité et simulerons notre premier échange d'arbitrage.
Vous pouvez trouver le code complet dans le dépôt GitHub associé à cet article. Le script est mieux exécuté dans un cahier Jupyter.
Si votre installation MEV ne ressemble pas à ceci, vous êtes ngmi
Cet article fait partie d'une série sur la construction d'un bot d'arbitrage. L'objectif de cette série est de fournir un guide étape par étape pour construire un robot de trading MEV automatisé qui peut trouver et exécuter des opportunités d'arbitrage sur les échanges décentralisés populaires.
Dans cet article, nous effectuons une présélection de paires de jetons d'intérêt. Nous dérivons ensuite la formule mathématique pour trouver l'arbitrage optimal entre deux pools de mêmes paires de jetons. Enfin, nous implémentons la formule en code et retournons une liste d'opportunités d'arbitrage potentielles.
Avant de commencer à rechercher des opportunités d'arbitrage, nous devons définir clairement le périmètre de notre bot d'arbitrage. Plus précisément, quel type d'arbitrages voulons-nous effectuer. Le type d'arbitrage le plus sûr est entre les pools impliquant de l'ETH. Puisque l'ETH est l'actif avec lequel le gaz de nos transactions est payé, il est naturel de vouloir toujours finir avec de l'ETH après un arbitrage. Mais tout le monde est tenté de penser de cette façon. Gardez à l'esprit que, dans le trading, les opportunités ponctuelles deviennent de moins en moins rentables à mesure que plus de gens agissent dessus.
Dans un souci de simplicité, nous nous concentrerons sur les opportunités d'arbitrage entre les pools impliquant de l'ETH. Nous ne rechercherons que les opportunités entre deux pools du même couple de jetons. Nous ne traderons pas sur les opportunités impliquant plus de 2 pools dans l'itinéraire de trading (les soi-disant opportunités multi-sauts). Notez que passer à une stratégie plus risquée est la première étape que vous devriez prendre pour améliorer la rentabilité de votre bot.
Pour améliorer cette stratégie, vous pourriez par exemple conserver une partie de l'inventaire en stablecoins et agir sur les opportunités d'arbitrage qui rapportent des stablecoins. La même chose pourrait être faite pour des actifs beaucoup plus risqués comme les shitcoins (avec les précautions nécessaires), et rééquilibrer périodiquement votre portefeuille en ETH pour payer les frais de gaz.
Une autre direction consisterait à abandonner l'hypothèse implicite d'atomicité que nous avons faite et à introduire un raisonnement statistique dans notre stratégie. Par exemple, en achetant un jeton dans un pool lorsque le prix a évolué de manière favorable de plus d'un certain nombre d'écart-types, et en le vendant plus tard (stratégie de réversion à la moyenne). Cela serait idéal pour les shitcoins qui ne sont pas cotés sur des bourses centralisées beaucoup plus efficaces, ou ceux qui le sont mais dont le prix n'est pas correctement suivi on-chain. Cela implique beaucoup plus de pièces mobiles et sort du cadre de cette série.
Maintenant que nous avons défini le périmètre de notre bot d'arbitrage, nous devons sélectionner les paires de jetons sur lesquelles nous voulons trader. Voici les 2 critères de sélection que nous utiliserons :
Réutiliser le code de article 2: Lecture efficace des prix des pools, nous avons le code suivant qui répertorie tous les paires de jetons qui ont été déployées par les contrats d'usine fournis :
# [...]# Chargez les adresses des contrats d'usineavec open("FactoriesV2.json", "r") as f:factories = json.load(f)# [...]# Récupérez la liste des pools pour chaque contrat d'usinepairDataList = []for factoryName, factoryData in factories.items():events = getPairEvents(w3.eth.contract(address=factoryData['factory'], abi=factory_abi), 0, w3.eth.block_number)print(f'Trouvé {len(events)} pools pour {factoryName}')for e in events: pairDataList.append({ "token0": e["args"]["token0"], "token1": e["args"]["token1"], "pair": e["args"]["pair"], "factory": factoryName })
Nous allons simplement inverser pairDataList en un dictionnaire où les clés sont les paires de jetons, et les valeurs sont la liste des pools qui échangent cette paire. Lors du parcours de la liste, nous ignorons les paires qui n'impliquent pas ETH. Lorsque la boucle est terminée, les paires avec au moins 2 pools sélectionnés seront stockées dans des listes avec au moins 2 éléments:
# [...]WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"pair_pool_dict = {}for pair_object in pairDataList:# Vérifiez la présence d'ETH (WETH) dans la paire.pair = (pair_object['token0'], pair_object['token1'])if WETH not in pair: continue# Assurez-vous que la paire est référencée dans le dictionnaire. if pair not in pair_pool_dict: pair_pool_dict[pair] = []# Ajoutez le pool à la liste des pools échangeant cette paire.pair_pool_dict[pair].append(pair_object)# Créez le dictionnaire final des pools qui seront échangés.pool_dict = {}for pair, pool_list in pair_pool_dict.items():if len(pool_list) >= 2: pool_dict[pair] = pool_list
Certains statistiques devraient être imprimées pour mieux appréhender les données avec lesquelles nous travaillons :
# Nombre de paires différentesprint(f'Nous avons {len(pool_dict)} paires différentes.')# Nombre total de poolsprint(f'Nous avons {sum([len(pool_list) for pool_list in pool_dict.values()])} pools au total.')# Paire avec le plus de pools print(f'La paire avec le plus de pools est {max(pool_dict, key=lambda k: len(pool_dict[k]))} avec {len(max(pool_dict.values(), key=len))} pools.')# Répartition du nombre de pools par paire, décilespool_count_list = [len(pool_list) for pool_list in pool_dict.values()]pool_count_list.sort(reverse=True)print(f'Nombre de pools par paire, en déciles : {pool_count_list[::int(len(pool_count_list)/10)]}')# Répartition du nombre de pools par paire, percentiles (déciles du premier décile)pool_count_list.sort(reverse=True)print(f'Nombre de pools par paire, en percentiles : {pool_count_list[::int(len(pool_count_list)/100)][:10]}')
Au moment de l'écriture, cela génère ce qui suit:
Nous avons 1431 paires différentes.
Nous avons 3081 pools au total.
La paire avec le plus grand nombre de pools est (‘0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2’, ‘0xdAC17F958D2ee523a2206206994597C13D831ec7’) avec 16 pools.
Nombre de pools par paire, en déciles : [16, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
Nombre de pools par paire, en percentiles : [16, 5, 4, 3, 3, 3, 3, 3, 3, 3]
La récupération des réserves pour 3000 pools peut être faite en moins d'une seconde avec des nœuds RPC publics. C'est un laps de temps raisonnable.
Maintenant, nous avons toutes les données dont nous avons besoin, nous devons commencer à trouver des opportunités d'arbitrage.
Il existe une opportunité d'arbitrage chaque fois qu'il y a une divergence de prix entre deux pools qui échangent la même paire. Cependant, toutes les différences de prix ne sont pas exploitables : le coût en gaz de la transaction fixe une valeur minimale qui doit être récupérée par le trade, et la liquidité dans chaque pool limite la valeur qui peut être extraite d'une différence de prix donnée.
Afin de trouver l'opportunité d'arbitrage la plus rentable accessible pour nous, nous devrons calculer la valeur potentielle pouvant être extraite de chaque différence de prix, en tenant compte des réserves/liquidités dans chaque pool, et estimer le coût en gaz de la transaction.
Lorsqu'une opportunité d'arbitrage est exploitée, le prix du pool qui achète le jeton d'entrée baissera, et le prix du pool qui vendra augmentera. Le mouvement des prix est décrit par la formule du produit constant.
Nous avons déjà vu dans @emileamajar/construction-dun-bot-darbitrage-les-fabricants-de-marches-automatises-et-uniswap-2d208215d8c2">article 1 comment calculer la sortie d'un échange via un pool, étant donné les réserves de ce pool et le montant d'entrée.
Afin de trouver la taille de transaction optimale, nous commençons par trouver une formule pour le résultat de deux échanges successifs, étant donné un montant d'entrée et les réserves des deux pools impliqués dans les échanges.
Nous supposons que l'entrée du premier échange est en token0, et que l'entrée du deuxième échange est en token1, ce qui donne finalement une sortie en token0.
Soit x le montant d'entrée, (a1, b1) les réserves du premier pool, et (a2, b2) les réserves du deuxième pool. La commission est la commission prélevée par les pools, et on suppose qu'elle est la même pour les deux pools (0,3 % la plupart du temps).
Nous définissons une fonction qui calcule la sortie d'un échange, étant donné l'entrée x et les réserves (a,b):
f(x, a, b) = b (1 - a/(a + x(1-frais)))
Nous savons alors que la sortie du premier échange est:
out1(x) = f(x, a1, b1)
out1(x) = b1 (1 - a1/(a1 + x(1-fee)))
La sortie du deuxième échange est : (remarquez les variables de réserve échangées)
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-frais)))
out2(x) = a2 (1 - b2/(b2 + b1 (1 - a1/(a1 + x (1-fee))) (1-frais)))
Nous pouvons tracer cette fonction en utilisant desmosEn choisissant les valeurs de réserve de telle sorte que nous simulons le premier pool ayant 1 ETH et 1750 USDC, et le deuxième pool ayant 1340 USDC et 1 ETH, nous obtenons le graphique suivant :
Graphique du bénéfice brut du commerce en fonction de la valeur d'entrée
Notez que nous avons réellement tracé out2(x) - x, qui est le profit de l'échange, moins le montant d'entrée.
Graphiquement, nous pouvons voir que la taille de transaction optimale est de 0,0607 ETH en entrée, ce qui génère un bénéfice de 0,0085 ETH. Le contrat doit avoir au moins 0,0607 ETH de liquidité en WETH afin de pouvoir exploiter cette opportunité.
Cette valeur de profit de 0,0085 ETH (~16 $ lors de la rédaction de cet article) n'est PAS le profit final de la transaction, car nous devons encore prendre en compte le coût du gaz de la transaction. Cela sera discuté dans un article ultérieur.
Nous voulons calculer automatiquement la taille de transaction optimale pour notre bot MEV. Cela peut être fait grâce au calcul élémentaire. Nous avons une fonction d'une variable x que nous voulons maximiser. La fonction atteint son maximum pour une valeur de x où la dérivée de la fonction est 0.
Divers outils gratuits et en ligne peuvent être utilisés pour calculer de manière symbolique la dérivée d'une fonction, tels que wolfram alpha.
Trouver la dérivée de notre fonction de bénéfice brut.
Trouver un tel dérivé est très simple avec Wolfram Alpha. Vous pouvez également le faire à la main si vous manquez de confiance en vos compétences en mathématiques.
Wolfram Alpha donne la dérivée suivante :
dout2(x)/dx = (a1b1a2b2(1-frais)^2)/(a1b2 + (1-frais)x(b1(1-frais)+b2))^2
Puisque nous voulons trouver la valeur de x qui maximise le profit (qui est out2(x) - x), nous devons trouver la valeur de x où la dérivée est 1 (et non pas 0).
Wolfram Alpha donne la solution suivante pour x dans l'équation dout2(x)/dx = 1 :
x = (sqrt(a1b1a2b2(1-frais)^4 (b1(1-frais)+b2)^2) - a1b2(1-fee)(b1(1-frais)+b2)) / ((1-frais) (b1(1-frais) + b2))^2
Avec les valeurs des réserves que nous avons utilisées dans le graphique ci-dessus, nous obtenons x_optimal = 0.0607203782551, ce qui valide notre formule (par rapport à la valeur du graphique de 0.0607).
Bien que cette formule ne soit pas très lisible, elle est facile à implémenter en code. Voici une implémentation en python de la formule pour calculer la sortie des 2 swaps et la taille de transaction optimale :
# Fonctions d'aide pour calculer la taille de transaction optimale# Rendement d'un échange uniquedef swap_output(x, a, b, fee=0.003):return b * (1 - a/(a + x*(1-fee)))# Profit brut de deux échanges successifsdef 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# Montant d'entrée optimaldef 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
Maintenant que nous savons comment calculer le bénéfice brut d'une opportunité d'arbitrage entre deux pools donnés du même couple de jetons, il nous suffit simplement de parcourir tous les couples de jetons, et de tester tous les deux à deux tous les pools ayant le même couple de jetons. Cela nous donnera le bénéfice brut de toutes les opportunités d'arbitrage possibles qui sont dans le périmètre de notre stratégie.
Pour estimer le bénéfice net d'un trade, nous devons estimer le coût en gaz de l'exploitation d'une opportunité donnée. Cela peut être fait précisément en simulant la transaction via un eth_call à un nœud RPC, mais cela prend beaucoup de temps et ne peut être effectué que pour quelques dizaines d'opportunités par bloc.
Nous ferons d'abord une estimation brute du coût du gaz en supposant un coût fixe de gaz de transaction (en fait, un minimum), et éliminerons les opportunités qui ne sont pas assez rentables pour couvrir le coût du gaz. Ce n'est qu'alors que nous ferons une estimation précise du coût du gaz pour les opportunités restantes.
Voici le code qui parcourt tous les paires et tous les pools, et trie les opportunités par profit :
# [...] # Récupère les réserves de chaque pool dans pool_dictto_fetch = [] # Liste des adresses de pool pour lesquelles les réserves doivent être récupérées.for pair, pool_list in pool_dict.items() :for pair_object in pool_list : to_fetch.append(pair_object["pair"]) # Ajout de l’adresse poolprint(f"Récupération des réserves de {len(to_fetch)} pools... »)# getReservesParallel() provient de l’article 2 de la série de bots MEVRESERVEList = asyncio.get_event_loop().run_until_complete(getReservesParallel(to_fetch, providersAsync))# Construire la liste des opportunités de tradingindex = 0opps = []pour la paire, pool_list dans pool_dict.items() :# Stocker les réserves dans les objets du pool pour une utilisation ultérieure pour pair_object dans pool_list : pair_object["reserves"] = reserveList[index] index += 1# Itérer sur tous les pools de la pairepour poolA dans pool_list : pour poolB dans pool_list : # Ignorer s’il s’agit du même pool si poolA["pair"] == poolB["pair"] : continue # Ignorer si l’une des réserves est 0 (division par 0) if 0 in poolA["reserves"] ou 0 in poolB["reserves"] : continue # Réorganiser les réserves pour que WETH soit toujours le premier jeton 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]) # Calculer la valeur de l’entrée optimale via la formule x = optimal_trade_size(res_A, res_B) # Ignorer si l’entrée optimale est négative (l’ordre des pools est inversé) si x < 0 : continue # Calculer la marge brute en Wei (avant le coût du gaz) profit = trade_profit(x, res_A, res_B) # Stocker les détails de l’opportunité. Les valeurs sont exprimées en ETH. (1e18 Wei = 1 ETH) opps.append({ « profit » : profit / 1e18, « input » : x / 1e18, « pair » : paire, « poolA » : poolA, « poolB » : poolB, })print(f"Opportunités {len(opps)} trouvées. »)
Ce qui produit la sortie suivante :
Récupération des réserves de 3081 pools.
Trouvé 1791 opportunités.
Nous avons maintenant une liste de toutes les opportunités. Il nous suffit d'estimer leur profit. Pour l'instant, nous supposerons simplement un coût constant en gaz pour trader sur une opportunité.
Nous devons utiliser une limite inférieure pour le coût en gaz d'un échange sur Uniswap V2. Expérimentalement, nous avons trouvé que cette valeur est proche de 43k gaz.
Exploiter une opportunité nécessite 2 swaps, et exécuter une transaction sur Ethereum coûte un forfait de 21k de gaz, pour un total de 107k de gaz par opportunité.
Voici le code qui calcule le bénéfice net estimé de chaque opportunité :
# [...]# Utilisez le coût du gaz codé en dur de 107k gaz par opportunité gp = w3.eth.gas_pricefor opp in opps:opp["net_profit"] = opp["profit"] - 107000 * gp / 1e18# Trier par bénéfice net estiméopps.sort(key=lambda x: x["net_profit"], reverse=True)# Conserver les opportunités positivespositive_opps = [opp for opp in opps if opp["net_profit"] > 0]
# Compte des opportunités positivesprint(f"Trouvé {len(positive_opps)} opportunités positives.")# Détails sur chaque opportunité ETH_PRIX = 1900 # Vous devriez récupérer dynamiquement le prix de l'ETHpour opp in positive_opps:print(f"Profit : {opp['net_profit']} ETH (${opp['net_profit'] * ETH_PRIX})")print(f"Investissement : {opp['input']} ETH (${opp['input'] * ETH_PRIX})")print(f"Pool A : {opp['poolA']['pair']}")print(f"Pool B : {opp['poolB']['pair']}")print()
Voici la sortie du script :
Trouvé 57 opportunités positives.
Profit: 4.936025725859028 ETH ($9378.448879132153)
Entrée : 1.7958289984719014 ETH ($3412.075097096613)
Pool A: 0x1498bd576454159Bb81B5Ce532692a8752D163e8
Pool B: 0x7D7E813082eF6c143277c71786e5bE626ec77b20
{‘profit’: 4.9374642090282865, ‘input’: 1.7958(...)
Profit: 4.756587769768892 ETH ($9037.516762560894)
Input: 0.32908348765283796 ETH ($625.2586265403921)
Pool A: 0x486c1609f9605fA14C28E311b7D708B0541cd2f5
Pool B: 0x5e81b946b61F3C7F73Bf84dd961dE3A0A78E8c33
{‘profit’: 4.7580262529381505, ‘input’: 0.329(…)
Bénéfice : 0.8147203063054365 ETH ($1547.9685819803292)
Input: 0.6715171730669338 ETH ($1275.8826288271744)
Pool A: 0x1f1B4836Dde1859e2edE1C6155140318EF5931C2
Pool B: 0x1f7efDcD748F43Fc4BeAe6897e5a6DDd865DcceA
{‘profit’: 0.8161587894746954, ‘input’: 0.671(…)
(...)
Des profits anormalement élevés. La première étape à suivre est de vérifier que le code est correct. Après avoir vérifié avec prudence le code, nous avons constaté que le code est correct.
Ces bénéfices sont-ils réels? Il s'avère que non. Nous avons étendu notre filet trop largement en sélectionnant les pools à prendre en compte dans notre stratégie, et nous avons mis la main sur des pools de jetons toxiques.
Le standard de jeton ERC20 décrit uniquement une interface pour l'interopérabilité. N'importe qui peut déployer un jeton qui implémente cette interface et choisir de mettre en œuvre un comportement non orthodoxe, ce qui est exactement ce qui se passe ici.
Certains créateurs de jetons conçoivent leurs ERC20 de sorte que les pools sur lesquels ils sont échangés ne peuvent pas vendre, mais seulement acheter le jeton. Certains contrats de jetons ont même des mécanismes de kill-switch qui permettent au créateur de rug-pull tous ses utilisateurs.
Dans notre bot MEV, ces jetons toxiques doivent être filtrés. Cela sera abordé dans un prochain article.
Si nous filtrons manuellement les jetons toxiques évidents, il nous reste les 42 opportunités suivantes :
Profit : 0.004126583158496902 ETH ($7.840508001144114)
Entrée : 0.008369804833786892 ETH (15.902629184195094 $)
Pool A: 0xdF42388059692150d0A9De836E4171c7B9c09CBf
Pool B: 0xf98fCEB2DC0Fa2B3f32ABccc5e8495E961370B23
{‘profit’: 0.005565066327755902, (…)
Profit: 0.004092580415474992 ETH ($7.775902789402485)
Entrée : 0.014696360216108083 ETH ($27.92308441060536)
Pool A: 0xfDBFb4239935A15C2C348400570E34De3b044c5F
Pool B: 0x0F15d69a7E5998252ccC39Ad239Cef67fa2a9369
{‘profit’: 0.005531063584733992, (…)
Profit: 0.003693235163284344 ETH ($7.017146810240254)
Input: 0.1392339178514088 ETH ($264.5444439176767)
Pool A: 0x2957215d0473d2c811A075725Da3C31D2af075F1
Pool B: 0xF110783EbD020DCFBA91Cd1976b79a6E510846AA
{‘profit’: 0.005131718332543344, (...)
Profit : 0.003674128918827048 ETH ($6.980844945771391)
Entrée : 0.2719041848570484 ETH ($516.617951228392)
Pool A: 0xBa19343ff3E9f496F17C7333cdeeD212D65A8425
Pool B: 0xD30567f1d084f411572f202ebb13261CE9F46325
{‘profit’: 0.005112612088086048, (…)
(…)
Remarquez que en général, les bénéfices sont inférieurs au montant d'entrée nécessaire pour exécuter la transaction.
Ces bénéfices sont beaucoup plus raisonnables. Mais rappelez-vous qu'ils restent des bénéfices dans le meilleur des cas, car nous avons utilisé une estimation très rudimentaire du coût en gaz de chaque opportunité.
Dans un prochain article, nous simulerons l'exécution de notre transaction afin d'obtenir une valeur précise du coût en gaz de chaque opportunité.
Afin de simuler l'exécution, nous devons d'abord développer le contrat intelligent qui exécutera l'échange. C'est le sujet de l'article suivant.
Nous avons maintenant une définition claire du périmètre de notre bot d'arbitrage MEV.
Nous avons exploré la théorie mathématique derrière la stratégie d'arbitrage et l'avons implémentée en Python.
Nous avons maintenant une liste de potentielles opportunités d'arbitrage, et nous devons simuler leur exécution afin d'obtenir une valeur de profit final. Pour ce faire, nous devons avoir notre contrat intelligent de trading prêt.
Dans le prochain article, nous développerons un tel contrat intelligent en Solidité et simulerons notre premier échange d'arbitrage.
Vous pouvez trouver le code complet dans le dépôt GitHub associé à cet article. Le script est mieux exécuté dans un cahier Jupyter.