Si su configuración de MEV no se ve así, usted está ngmi
Este artículo es parte de una serie sobre la construcción de un bot de arbitraje. El objetivo de esta serie es proporcionar una guía paso a paso para construir un robot de trading automatizado de MEV que pueda encontrar y ejecutar oportunidades de arbitraje en intercambios descentralizados populares.
En este artículo, realizamos una preselección de pares de tokens de interés. Luego derivamos la fórmula matemática para encontrar la arbitraje óptimo entre dos pools de los mismos pares de tokens. Finalmente, implementamos la fórmula en código y devolvemos una lista de oportunidades de arbitraje potenciales.
Antes de comenzar a buscar oportunidades de arbitraje, debemos definir claramente el alcance de nuestro bot de arbitraje. Específicamente, en qué tipo de arbitrajes queremos actuar. El tipo de arbitraje más seguro es entre pools que involucran ETH. Dado que ETH es el activo con el que se paga el gas de nuestras transacciones, es natural querer terminar siempre con ETH después de un arbitraje. Pero todos están tentados a pensar de esta manera. Ten en cuenta que en el trading, las oportunidades puntuales son menos y menos rentables a medida que más personas actúan sobre ellas.
Por simplicidad, nos centraremos en oportunidades de arbitraje entre pools que involucren ETH. Solo buscaremos oportunidades entre dos pools del mismo par de tokens. No intercambiaremos oportunidades que involucren más de 2 pools en la ruta de trading (llamadas oportunidades de multi-salto). Tenga en cuenta que mejorar esta estrategia a una más arriesgada es el primer paso que debe tomar para mejorar la rentabilidad de su bot.
Para mejorar esta estrategia, podrías, por ejemplo, mantener parte del inventario en stablecoins y aprovechar oportunidades de arbitraje que generen stablecoins. Lo mismo se podría hacer para activos mucho más arriesgados como shitcoins (con las precauciones necesarias) y periódicamente reequilibrar tu cartera hacia ETH para pagar el gas.
Otra dirección sería abandonar el supuesto implícito de atomicidad que hicimos e introducir razonamiento estadístico en nuestra estrategia. Por ejemplo, comprando un token en un pool cuando el precio se ha movido favorablemente más que cierta cantidad de desviaciones estándar, y vendiéndolo más tarde (estrategia de reversión a la media). Esto sería ideal para shitcoins que no están listadas en intercambios centralizados mucho más eficientes, o aquellas que lo están pero cuyo precio no se rastrea correctamente en cadena. Esto implica muchas más partes móviles y está fuera del alcance de esta serie.
Ahora que hemos definido el perímetro de nuestro bot de arbitraje, necesitamos seleccionar los pares de tokens en los que queremos operar. Aquí están los 2 criterios de selección que utilizaremos:
Reutilizando el código deartículo 2: Lectura eficiente de los precios de pool, tenemos el siguiente código que lista todos los pares de tokens que fueron desplegados por los contratos de fábrica proporcionados:
# [...]# Cargue las direcciones de los contratos de fábricawith open("FactoriesV2.json", "r") as f:factories = json.load(f)# [...]# Obtenga la lista de pools para cada contrato de fábricapairDataList = []for factoryName, factoryData in factories.items():events = getPairEvents(w3.eth.contract(address=factoryData['factory'], abi=factory_abi), 0, w3.eth.block_number)print(f'Encontrados {len(events)} pools para {factoryName}')for e in events: pairDataList.append({ "token0": e["args"]["token0"], "token1": e["args"]["token1"], "pair": e["args"]["pair"], "factory": factoryName })
Simplemente invertiremos pairDataList en un diccionario donde las claves son los pares de tokens y los valores son la lista de pools que negocian este par. Al recorrer la lista, ignoramos los pares que no involucran ETH. Cuando finaliza el bucle, los pares con al menos 2 pools seleccionados se almacenarán en listas con al menos 2 elementos:
# [...]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
Algunas estadísticas deberían imprimirse para tener un mejor control con los datos con los que estamos trabajando:
# Número de pares diferentesprint(f'Tenemos {len(pool_dict)} pares diferentes.')# Número total de poolspint(f'Tenemos {sum([len(pool_list) for pool_list in pool_dict.values()])} pools en total.')# Par con más poolsprint(f'El par con más pools es {max(pool_dict, key=lambda k: len(pool_dict[k]))} con {len(max(pool_dict.values(), key=len))} pools.')# Distribución del número de pools por par, decilespool_count_list = [len(pool_list) for pool_list in pool_dict.values()]pool_count_list.sort(reverse=True)print(f'Número de pools por par, en deciles: {pool_count_list[::int(len(pool_count_list)/10)]}')# Distribución del número de pools por par, percentiles (deciles del primer decil)pool_count_list.sort(reverse=True)print(f'Número de pools por par, en percentiles: {pool_count_list[::int(len(pool_count_list)/100)][:10]}')
En el momento de escribir, esto produce lo siguiente:
Tenemos 1431 pares diferentes.
Tenemos un total de 3081 piscinas.
El par con más pools es ('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', '0xdAC17F958D2ee523a2206206994597C13D831ec7') con 16 pools.
Número de piscinas por par, en deciles: [16, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
Número de pools por par, en percentiles: [16, 5, 4, 3, 3, 3, 3, 3, 3, 3]
Obtener reservas para 3000 piscinas se puede hacer en menos de 1 segundo con nodos RPC públicos. Esta es una cantidad razonable de tiempo.
Ahora, que tenemos todos los datos que necesitamos, necesitamos empezar a encontrar oportunidades de arbitraje.
Existe una oportunidad de arbitraje siempre que haya una discrepancia de precios entre dos pools que operan el mismo par. Sin embargo, no todas las diferencias de precios son explotables: el costo de gas de la transacción establece un valor mínimo que debe ser recuperado por el intercambio, y la liquidez en cada pool limita el valor que se puede extraer de una diferencia dada en el precio.
Para encontrar la oportunidad de arbitraje más rentable accesible para nosotros, necesitaremos calcular el valor potencial extraíble de cada diferencia de precio, considerando las reservas/liquidez en cada pool, y estimar el costo de gas de la transacción.
Cuando se aprovecha una oportunidad de arbitraje, el precio del grupo que compra el token de entrada bajará, y el precio del grupo que vende subirá. El movimiento de los precios está descrito por la fórmula del producto constante.
Ya vimos en @emileamajar/construcción-de-un-bot-de-arbitraje-market-makers-automatizados-y-uniswap-2d208215d8c2">artículo 1 cómo calcular la salida de un intercambio a través de una piscina, dadas las reservas de esa piscina y la cantidad de entrada.
Para encontrar el tamaño óptimo de la operación, primero encontramos una fórmula para el resultado de dos intercambios sucesivos, dada una cantidad de entrada y las reservas de los dos pools involucrados en los intercambios.
Suponemos que la entrada del primer intercambio está en token0, y la entrada del segundo intercambio está en token1, lo que finalmente produce una salida en token0.
Sea x la cantidad de entrada, (a1, b1) las reservas del primer pool, y (a2, b2) las reservas del segundo pool. La comisión es la tarifa cobrada por los pools, y se supone que es la misma para ambos pools (0.3% la mayor parte del tiempo).
Definimos una función que calcula la salida de un intercambio, dada la entrada x y las reservas (a, b):
f(x, a, b) = b (1 - a/(a + x(1-fee)))
Entonces sabemos que la salida del primer intercambio es:
out1(x) = f(x, a1, b1)
out1(x) = b1 (1 - a1/(a1 + x(1-fee)))
La salida del segundo intercambio es: (nota las variables de reserva intercambiadas)
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-tasa)))
Podemos trazar esta función utilizando desmos. Al elegir los valores de reserva para simular que el primer grupo tiene 1 ETH y 1750 USDC, y el segundo grupo tiene 1340 USDC y 1 ETH, obtenemos el siguiente gráfico:
Gráfico de la ganancia bruta del comercio en función del valor de entrada
Tenga en cuenta que en realidad hemos trazado out2(x) - x, que es la ganancia del intercambio, menos la cantidad de entrada.
Gráficamente, podemos ver que el tamaño óptimo de la operación es de 0.0607 ETH de entrada, lo que produce una ganancia de 0.0085 ETH. El contrato debe tener al menos 0.0607 ETH de liquidez en WETH para poder aprovechar esta oportunidad.
Este valor de beneficio de 0.0085 ETH (~$16 al escribir este artículo) NO es el beneficio final del intercambio, ya que todavía necesitamos tener en cuenta el costo de gas de la transacción. Esto se discutirá en un artículo siguiente.
Queremos calcular automáticamente este tamaño de operación óptimo para nuestro bot de MEV. Esto se puede hacer a través del cálculo elemental. Tenemos una función de una variable x que queremos maximizar. La función alcanza su máximo valor para un valor de x donde la derivada de la función es 0.
Varias herramientas gratuitas y en línea se pueden utilizar para calcular simbólicamente la derivada de una función, como wolfram alpha.
Encontrar la derivada de nuestra función de beneficio bruto.
Encontrar tal derivada es muy simple con Wolfram Alpha. También puedes hacerlo a mano si no estás seguro de tus habilidades matemáticas.
Wolfram Alpha arroja la siguiente derivada:
dout2(x)/dx = (a1b1a2b2(1-fee)^2)/(a1b2 + (1-fee)x(b1(1-fee)+b2))^2
Dado que queremos encontrar el valor de x que maximiza el beneficio (que es out2(x) - x), necesitamos encontrar el valor de x donde la derivada es 1 (y no 0).
Wolfram Alpha arroja la siguiente solución para x en la ecuación dout2(x)/dx = 1:
x = (sqrt(a1b1a2b2(1-tarifa)^4 (b1(1-fee)+b2)^2) - a1b2(1-fee)(b1(1-fee)+b2)) / ((1-fee) (b1(1-fee) + b2))^2
Con los valores de las reservas que utilizamos en el gráfico anterior, obtenemos x_optimal = 0.0607203782551, lo que valida nuestra fórmula (en comparación con el valor del gráfico de 0.0607).
Aunque esta fórmula no es muy legible, es fácil de implementar en código. Aquí hay una implementación en python de la fórmula para calcular la salida de los 2 swaps y el tamaño óptimo de la operación:
# Funciones auxiliares para calcular el tamaño óptimo de la operación# Salida de un solo intercambio.def salida_intercambio(x, a, b, tarifa=0.003):return b * (1 - a/(a + x*(1-tarifa)))# Ganancia bruta de dos intercambios sucesivos.def ganancia_intercambio(x, reservas1, reservas2, tarifa=0.003): a1, b1 = reservas1a2, b2 = reservas2return salida_intercambio(salida_intercambio(x, a1, b1, tarifa), b2, a2, tarifa) - x# Cantidad óptima de entrada.def tamaño_operacion_optimo(reservas1, reservas2, tarifa=0.003):a1, b1 = reservas1a2, b2 = reservas2return (math.sqrt(a1*b1*a2*b2*(1-tarifa)**4 * (b1*(1-tarifa)+b2)**2) - a1*b2*(1-tarifa)*(b1*(1-tarifa)+b2)) / ((1-tarifa) * (b1*(1-tarifa) + b2))**2
Ahora que sabemos cómo calcular el beneficio bruto de una oportunidad de arbitraje entre cualquier par de tokens dado, simplemente tenemos que iterar sobre todos los pares de tokens, y probar de dos en dos todas las pools que tengan el mismo par de tokens. Esto nos dará el beneficio bruto de todas las posibles oportunidades de arbitraje que estén dentro del perímetro de nuestra estrategia.
Para estimar la ganancia neta de una operación, necesitamos estimar el costo de gas de explotar una oportunidad dada. Esto se puede hacer con precisión simulando la transacción a través de un eth_call a un nodo RPC, pero lleva mucho tiempo y solo se puede realizar para unas pocas docenas de oportunidades por bloque.
Primero haremos una estimación bruta del costo del gas asumiendo un costo de gas de transacción fijo (en realidad, un límite inferior) y descartaremos las oportunidades que no sean lo suficientemente rentables como para cubrir el costo del gas. Solo entonces realizaremos una estimación precisa del costo del gas para las oportunidades restantes.
Aquí está el código que recorre todos los pares y todas las pools, y ordena las oportunidades por ganancia:
# [...] # Obtener las reservas de cada pool en pool_dictto_fetch = [] # Lista de direcciones de pool para las que es necesario obtener reservas.for pair, pool_list in pool_dict.items():for pair_object in pool_list: to_fetch.append(pair_object["pair"]) # Añade la dirección del poolprint(f"Buscando reservas de {len(to_fetch)} pools...")# getReservesParallel() es del artículo 2 de la serie de bots MEV reserveList = asyncio.get_event_loop().run_until_complete(getReservesParallel(to_fetch, providersAsync))# Construir una lista de oportunidades de tradingindex = 0opps = []for pair, pool_list in pool_dict.items():# Almacenar las reservas en los objetos del pool para su uso posteriorfor pair_object en pool_list: pair_object["reserves"] = reserveList[index] index += 1# Iterar sobre todos los pools del pairfor poolA in pool_list: for poolB in pool_list: # Omitir si es el mismo pool if poolA["pair"] == poolB["pair"]: continue # Omitir si una de las reservas es 0 (dividir por 0) if 0 en poolA["reserves"] o 0 en poolB["reserves"]: continue # Reordenar las reservas para que WETH sea siempre el primer token 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["reservas"][0]) res_B = (poolB["reservas"][1], poolB["reservas"][0]) # Calcular el valor de la entrada óptima a través de la fórmula x = optimal_trade_size(res_A, res_B) # Omitir si la entrada óptima es negativa (el orden de los grupos se invierte) si x < 0: continuar # Calcular el beneficio bruto en Wei (antes del coste del gas) beneficio = trade_profit(x, res_A, res_B) # Almacenar detalles de la oportunidad. Los valores están en ETH. (1e18 Wei = 1 ETH) opps.append({ "profit": profit / 1e18, "input": x / 1e18, "pair": pair, "poolA": poolA, "poolB": poolB, })print(f"Found {len(opps)} oportunidades.")
Lo cual produce la siguiente salida:
Obteniendo reservas de 3081 piscinas.
Encontradas 1791 oportunidades.
Ahora tenemos una lista de todas las oportunidades. Solo necesitamos estimar su beneficio. En este momento, simplemente asumiremos un costo de gas constante para operar en una oportunidad.
Debemos usar un límite inferior para el costo de gas de un intercambio en Uniswap V2. Experimentalmente, encontramos que este valor está cerca de 43k de gas.
Explotar una oportunidad requiere 2 intercambios, y ejecutar una transacción en Ethereum cuesta un gas plano de 21k, para un total de 107k gas por oportunidad.
Aquí está el código que calcula la ganancia neta estimada de cada oportunidad:
# [...]# Utilice el costo de gas codificado duro de 107k gas por oportunidad gp = w3.eth.gas_pricefor opp in opps:opp["net_profit"] = opp["profit"] - 107000 * gp / 1e18# Ordenar por beneficio neto estimadoopps.sort(key=lambda x: x["net_profit"], reverse=True)# Mantener oportunidades positivaspositive_opps = [opp for opp in opps if opp["net_profit"] > 0]
# Oportunidades positivas contadas
print(f"Se encontraron {len(positive_opps)} oportunidades positivas.")
# Detalles de cada oportunidad
ETH_PRICE = 1900 # Deberías obtener dinámicamente el precio de ETH
for opp in positive_opps:
print(f"Beneficio: {opp['net_profit']} ETH (${opp['net_profit'] * ETH_PRICE})")
print(f"Entrada: {opp['input']} ETH (${opp['input'] * ETH_PRICE})")
print(f"Pool A: {opp['poolA']['pair']}")
print(f"Pool B: {opp['poolB']['pair']}")
print()
Aquí está la salida del script:
Encontradas 57 oportunidades positivas.
Beneficio: 4.936025725859028 ETH ($9378.448879132153)
Entrada: 1.7958289984719014 ETH ($3412.075097096613)
Pool A: 0x1498bd576454159Bb81B5Ce532692a8752D163e8
Pool B: 0x7D7E813082eF6c143277c71786e5bE626ec77b20
{‘profit’: 4.9374642090282865, ‘input’: 1.7958(…)
Beneficio: 4.756587769768892 ETH ($9037.516762560894)
Entrada: 0.32908348765283796 ETH ($625.2586265403921)
Grupo A: 0x486c1609f9605fA14C28E311b7D708B0541cd2f5
Pool B: 0x5e81b946b61F3C7F73Bf84dd961dE3A0A78E8c33
{'profit': 4.7580262529381505, 'input': 0.329(…)
Beneficio: 0.8147203063054365 ETH ($1547.9685819803292)
Entrada: 0.6715171730669338 ETH ($1275.8826288271744)
Pool A: 0x1f1B4836Dde1859e2edE1C6155140318EF5931C2
Grupo B: 0x1f7efDcD748F43Fc4BeAe6897e5a6DDd865DcceA
{‘profit’: 0.8161587894746954, ‘input’: 0.671(…)
(...)
Que son ganancias sospechosamente altas. El primer paso que se debe tomar es verificar que el código sea correcto. Después de verificar con cautela el código, encontramos que el código es correcto.
¿Son reales estas ganancias? Resulta que no. Hemos extendido nuestra red demasiado ampliamente al seleccionar qué pools considerar en nuestra estrategia, y hemos obtenido en nuestras manos pools de tokens tóxicos.
El estándar de token ERC20 solo describe una interfaz para la interoperabilidad. Cualquiera puede implementar un token que cumpla con esta interfaz y elegir implementar un comportamiento no ortodoxo, que es exactamente lo que está en juego aquí.
Algunos creadores de tokens diseñan sus tokens ERC20 de manera que los pools en los que se negocian no puedan vender, sino solo comprar el token. Incluso algunos contratos de tokens tienen mecanismos de interruptor de apagado que permiten al creador deshacerse de todos sus usuarios.
En nuestro bot de MEV, estos tokens tóxicos deben ser filtrados. Esto se abordará en un artículo futuro.
Si filtramos manualmente los tokens obviamente tóxicos, nos quedan las siguientes 42 oportunidades:
Beneficio: 0.004126583158496902 ETH ($7.840508001144114)
Entrada: 0.008369804833786892 ETH ($15.902629184195094)
Grupo A: 0xdF42388059692150d0A9De836E4171c7B9c09CBf
Pool B: 0xf98fCEB2DC0Fa2B3f32ABccc5e8495E961370B23
{‘profit’: 0.005565066327755902, (...)}
Beneficio: 0.004092580415474992 ETH ($7.775902789402485)
Entrada: 0.014696360216108083 ETH ($27.92308441060536)
Pool A: 0xfDBFb4239935A15C2C348400570E34De3b044c5F
Grupo B: 0x0F15d69a7E5998252ccC39Ad239Cef67fa2a9369
{‘profit’: 0.005531063584733992, (…)
Beneficio: 0.003693235163284344 ETH ($7.017146810240254)
Entrada: 0.1392339178514088 ETH ($264.5444439176767)
Grupo A: 0x2957215d0473d2c811A075725Da3C31D2af075F1
Pool B: 0xF110783EbD020DCFBA91Cd1976b79a6E510846AA
{‘profit’: 0.005131718332543344, (...)
Beneficio: 0.003674128918827048 ETH ($6.980844945771391)
Entrada: 0.2719041848570484 ETH ($516.617951228392)
Piscina A: 0xBa19343ff3E9f496F17C7333cdeeD212D65A8425
Pool B: 0xD30567f1d084f411572f202ebb13261CE9F46325
{‘profit’: 0.005112612088086048, (...)}
(…)
Tenga en cuenta que en general, las ganancias son inferiores al monto de entrada necesario para ejecutar la transacción.
Estas ganancias son mucho más razonables. Pero recuerda que siguen siendo ganancias en el mejor de los casos, ya que hemos utilizado una estimación muy rudimentaria del costo de gas de cada oportunidad.
En un artículo futuro, simularemos la ejecución de nuestro intercambio para obtener un valor preciso del costo de gas de cada oportunidad.
Para simular la ejecución, primero necesitamos desarrollar el contrato inteligente que ejecutará el intercambio. Este es el tema del próximo artículo.
Ahora tenemos una definición clara del perímetro de nuestro bot de arbitraje de MEV.
Hemos explorado la teoría matemática detrás de la estrategia de arbitraje, y la hemos implementado en Python.
Ahora tenemos una lista de posibles oportunidades de arbitraje, y necesitamos simular su ejecución para obtener un valor final de beneficio. Para hacerlo, necesitamos tener listo nuestro contrato inteligente de trading.
En el próximo artículo, desarrollaremos un contrato inteligente en Solidity y simularemos nuestro primer intercambio de arbitraje.
Puedes encontrar el código completo en el repositorio de GitHub asociado con este artículo. El script se ejecuta mejor en un cuaderno Jupyter.
Si su configuración de MEV no se ve así, usted está ngmi
Este artículo es parte de una serie sobre la construcción de un bot de arbitraje. El objetivo de esta serie es proporcionar una guía paso a paso para construir un robot de trading automatizado de MEV que pueda encontrar y ejecutar oportunidades de arbitraje en intercambios descentralizados populares.
En este artículo, realizamos una preselección de pares de tokens de interés. Luego derivamos la fórmula matemática para encontrar la arbitraje óptimo entre dos pools de los mismos pares de tokens. Finalmente, implementamos la fórmula en código y devolvemos una lista de oportunidades de arbitraje potenciales.
Antes de comenzar a buscar oportunidades de arbitraje, debemos definir claramente el alcance de nuestro bot de arbitraje. Específicamente, en qué tipo de arbitrajes queremos actuar. El tipo de arbitraje más seguro es entre pools que involucran ETH. Dado que ETH es el activo con el que se paga el gas de nuestras transacciones, es natural querer terminar siempre con ETH después de un arbitraje. Pero todos están tentados a pensar de esta manera. Ten en cuenta que en el trading, las oportunidades puntuales son menos y menos rentables a medida que más personas actúan sobre ellas.
Por simplicidad, nos centraremos en oportunidades de arbitraje entre pools que involucren ETH. Solo buscaremos oportunidades entre dos pools del mismo par de tokens. No intercambiaremos oportunidades que involucren más de 2 pools en la ruta de trading (llamadas oportunidades de multi-salto). Tenga en cuenta que mejorar esta estrategia a una más arriesgada es el primer paso que debe tomar para mejorar la rentabilidad de su bot.
Para mejorar esta estrategia, podrías, por ejemplo, mantener parte del inventario en stablecoins y aprovechar oportunidades de arbitraje que generen stablecoins. Lo mismo se podría hacer para activos mucho más arriesgados como shitcoins (con las precauciones necesarias) y periódicamente reequilibrar tu cartera hacia ETH para pagar el gas.
Otra dirección sería abandonar el supuesto implícito de atomicidad que hicimos e introducir razonamiento estadístico en nuestra estrategia. Por ejemplo, comprando un token en un pool cuando el precio se ha movido favorablemente más que cierta cantidad de desviaciones estándar, y vendiéndolo más tarde (estrategia de reversión a la media). Esto sería ideal para shitcoins que no están listadas en intercambios centralizados mucho más eficientes, o aquellas que lo están pero cuyo precio no se rastrea correctamente en cadena. Esto implica muchas más partes móviles y está fuera del alcance de esta serie.
Ahora que hemos definido el perímetro de nuestro bot de arbitraje, necesitamos seleccionar los pares de tokens en los que queremos operar. Aquí están los 2 criterios de selección que utilizaremos:
Reutilizando el código deartículo 2: Lectura eficiente de los precios de pool, tenemos el siguiente código que lista todos los pares de tokens que fueron desplegados por los contratos de fábrica proporcionados:
# [...]# Cargue las direcciones de los contratos de fábricawith open("FactoriesV2.json", "r") as f:factories = json.load(f)# [...]# Obtenga la lista de pools para cada contrato de fábricapairDataList = []for factoryName, factoryData in factories.items():events = getPairEvents(w3.eth.contract(address=factoryData['factory'], abi=factory_abi), 0, w3.eth.block_number)print(f'Encontrados {len(events)} pools para {factoryName}')for e in events: pairDataList.append({ "token0": e["args"]["token0"], "token1": e["args"]["token1"], "pair": e["args"]["pair"], "factory": factoryName })
Simplemente invertiremos pairDataList en un diccionario donde las claves son los pares de tokens y los valores son la lista de pools que negocian este par. Al recorrer la lista, ignoramos los pares que no involucran ETH. Cuando finaliza el bucle, los pares con al menos 2 pools seleccionados se almacenarán en listas con al menos 2 elementos:
# [...]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
Algunas estadísticas deberían imprimirse para tener un mejor control con los datos con los que estamos trabajando:
# Número de pares diferentesprint(f'Tenemos {len(pool_dict)} pares diferentes.')# Número total de poolspint(f'Tenemos {sum([len(pool_list) for pool_list in pool_dict.values()])} pools en total.')# Par con más poolsprint(f'El par con más pools es {max(pool_dict, key=lambda k: len(pool_dict[k]))} con {len(max(pool_dict.values(), key=len))} pools.')# Distribución del número de pools por par, decilespool_count_list = [len(pool_list) for pool_list in pool_dict.values()]pool_count_list.sort(reverse=True)print(f'Número de pools por par, en deciles: {pool_count_list[::int(len(pool_count_list)/10)]}')# Distribución del número de pools por par, percentiles (deciles del primer decil)pool_count_list.sort(reverse=True)print(f'Número de pools por par, en percentiles: {pool_count_list[::int(len(pool_count_list)/100)][:10]}')
En el momento de escribir, esto produce lo siguiente:
Tenemos 1431 pares diferentes.
Tenemos un total de 3081 piscinas.
El par con más pools es ('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', '0xdAC17F958D2ee523a2206206994597C13D831ec7') con 16 pools.
Número de piscinas por par, en deciles: [16, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
Número de pools por par, en percentiles: [16, 5, 4, 3, 3, 3, 3, 3, 3, 3]
Obtener reservas para 3000 piscinas se puede hacer en menos de 1 segundo con nodos RPC públicos. Esta es una cantidad razonable de tiempo.
Ahora, que tenemos todos los datos que necesitamos, necesitamos empezar a encontrar oportunidades de arbitraje.
Existe una oportunidad de arbitraje siempre que haya una discrepancia de precios entre dos pools que operan el mismo par. Sin embargo, no todas las diferencias de precios son explotables: el costo de gas de la transacción establece un valor mínimo que debe ser recuperado por el intercambio, y la liquidez en cada pool limita el valor que se puede extraer de una diferencia dada en el precio.
Para encontrar la oportunidad de arbitraje más rentable accesible para nosotros, necesitaremos calcular el valor potencial extraíble de cada diferencia de precio, considerando las reservas/liquidez en cada pool, y estimar el costo de gas de la transacción.
Cuando se aprovecha una oportunidad de arbitraje, el precio del grupo que compra el token de entrada bajará, y el precio del grupo que vende subirá. El movimiento de los precios está descrito por la fórmula del producto constante.
Ya vimos en @emileamajar/construcción-de-un-bot-de-arbitraje-market-makers-automatizados-y-uniswap-2d208215d8c2">artículo 1 cómo calcular la salida de un intercambio a través de una piscina, dadas las reservas de esa piscina y la cantidad de entrada.
Para encontrar el tamaño óptimo de la operación, primero encontramos una fórmula para el resultado de dos intercambios sucesivos, dada una cantidad de entrada y las reservas de los dos pools involucrados en los intercambios.
Suponemos que la entrada del primer intercambio está en token0, y la entrada del segundo intercambio está en token1, lo que finalmente produce una salida en token0.
Sea x la cantidad de entrada, (a1, b1) las reservas del primer pool, y (a2, b2) las reservas del segundo pool. La comisión es la tarifa cobrada por los pools, y se supone que es la misma para ambos pools (0.3% la mayor parte del tiempo).
Definimos una función que calcula la salida de un intercambio, dada la entrada x y las reservas (a, b):
f(x, a, b) = b (1 - a/(a + x(1-fee)))
Entonces sabemos que la salida del primer intercambio es:
out1(x) = f(x, a1, b1)
out1(x) = b1 (1 - a1/(a1 + x(1-fee)))
La salida del segundo intercambio es: (nota las variables de reserva intercambiadas)
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-tasa)))
Podemos trazar esta función utilizando desmos. Al elegir los valores de reserva para simular que el primer grupo tiene 1 ETH y 1750 USDC, y el segundo grupo tiene 1340 USDC y 1 ETH, obtenemos el siguiente gráfico:
Gráfico de la ganancia bruta del comercio en función del valor de entrada
Tenga en cuenta que en realidad hemos trazado out2(x) - x, que es la ganancia del intercambio, menos la cantidad de entrada.
Gráficamente, podemos ver que el tamaño óptimo de la operación es de 0.0607 ETH de entrada, lo que produce una ganancia de 0.0085 ETH. El contrato debe tener al menos 0.0607 ETH de liquidez en WETH para poder aprovechar esta oportunidad.
Este valor de beneficio de 0.0085 ETH (~$16 al escribir este artículo) NO es el beneficio final del intercambio, ya que todavía necesitamos tener en cuenta el costo de gas de la transacción. Esto se discutirá en un artículo siguiente.
Queremos calcular automáticamente este tamaño de operación óptimo para nuestro bot de MEV. Esto se puede hacer a través del cálculo elemental. Tenemos una función de una variable x que queremos maximizar. La función alcanza su máximo valor para un valor de x donde la derivada de la función es 0.
Varias herramientas gratuitas y en línea se pueden utilizar para calcular simbólicamente la derivada de una función, como wolfram alpha.
Encontrar la derivada de nuestra función de beneficio bruto.
Encontrar tal derivada es muy simple con Wolfram Alpha. También puedes hacerlo a mano si no estás seguro de tus habilidades matemáticas.
Wolfram Alpha arroja la siguiente derivada:
dout2(x)/dx = (a1b1a2b2(1-fee)^2)/(a1b2 + (1-fee)x(b1(1-fee)+b2))^2
Dado que queremos encontrar el valor de x que maximiza el beneficio (que es out2(x) - x), necesitamos encontrar el valor de x donde la derivada es 1 (y no 0).
Wolfram Alpha arroja la siguiente solución para x en la ecuación dout2(x)/dx = 1:
x = (sqrt(a1b1a2b2(1-tarifa)^4 (b1(1-fee)+b2)^2) - a1b2(1-fee)(b1(1-fee)+b2)) / ((1-fee) (b1(1-fee) + b2))^2
Con los valores de las reservas que utilizamos en el gráfico anterior, obtenemos x_optimal = 0.0607203782551, lo que valida nuestra fórmula (en comparación con el valor del gráfico de 0.0607).
Aunque esta fórmula no es muy legible, es fácil de implementar en código. Aquí hay una implementación en python de la fórmula para calcular la salida de los 2 swaps y el tamaño óptimo de la operación:
# Funciones auxiliares para calcular el tamaño óptimo de la operación# Salida de un solo intercambio.def salida_intercambio(x, a, b, tarifa=0.003):return b * (1 - a/(a + x*(1-tarifa)))# Ganancia bruta de dos intercambios sucesivos.def ganancia_intercambio(x, reservas1, reservas2, tarifa=0.003): a1, b1 = reservas1a2, b2 = reservas2return salida_intercambio(salida_intercambio(x, a1, b1, tarifa), b2, a2, tarifa) - x# Cantidad óptima de entrada.def tamaño_operacion_optimo(reservas1, reservas2, tarifa=0.003):a1, b1 = reservas1a2, b2 = reservas2return (math.sqrt(a1*b1*a2*b2*(1-tarifa)**4 * (b1*(1-tarifa)+b2)**2) - a1*b2*(1-tarifa)*(b1*(1-tarifa)+b2)) / ((1-tarifa) * (b1*(1-tarifa) + b2))**2
Ahora que sabemos cómo calcular el beneficio bruto de una oportunidad de arbitraje entre cualquier par de tokens dado, simplemente tenemos que iterar sobre todos los pares de tokens, y probar de dos en dos todas las pools que tengan el mismo par de tokens. Esto nos dará el beneficio bruto de todas las posibles oportunidades de arbitraje que estén dentro del perímetro de nuestra estrategia.
Para estimar la ganancia neta de una operación, necesitamos estimar el costo de gas de explotar una oportunidad dada. Esto se puede hacer con precisión simulando la transacción a través de un eth_call a un nodo RPC, pero lleva mucho tiempo y solo se puede realizar para unas pocas docenas de oportunidades por bloque.
Primero haremos una estimación bruta del costo del gas asumiendo un costo de gas de transacción fijo (en realidad, un límite inferior) y descartaremos las oportunidades que no sean lo suficientemente rentables como para cubrir el costo del gas. Solo entonces realizaremos una estimación precisa del costo del gas para las oportunidades restantes.
Aquí está el código que recorre todos los pares y todas las pools, y ordena las oportunidades por ganancia:
# [...] # Obtener las reservas de cada pool en pool_dictto_fetch = [] # Lista de direcciones de pool para las que es necesario obtener reservas.for pair, pool_list in pool_dict.items():for pair_object in pool_list: to_fetch.append(pair_object["pair"]) # Añade la dirección del poolprint(f"Buscando reservas de {len(to_fetch)} pools...")# getReservesParallel() es del artículo 2 de la serie de bots MEV reserveList = asyncio.get_event_loop().run_until_complete(getReservesParallel(to_fetch, providersAsync))# Construir una lista de oportunidades de tradingindex = 0opps = []for pair, pool_list in pool_dict.items():# Almacenar las reservas en los objetos del pool para su uso posteriorfor pair_object en pool_list: pair_object["reserves"] = reserveList[index] index += 1# Iterar sobre todos los pools del pairfor poolA in pool_list: for poolB in pool_list: # Omitir si es el mismo pool if poolA["pair"] == poolB["pair"]: continue # Omitir si una de las reservas es 0 (dividir por 0) if 0 en poolA["reserves"] o 0 en poolB["reserves"]: continue # Reordenar las reservas para que WETH sea siempre el primer token 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["reservas"][0]) res_B = (poolB["reservas"][1], poolB["reservas"][0]) # Calcular el valor de la entrada óptima a través de la fórmula x = optimal_trade_size(res_A, res_B) # Omitir si la entrada óptima es negativa (el orden de los grupos se invierte) si x < 0: continuar # Calcular el beneficio bruto en Wei (antes del coste del gas) beneficio = trade_profit(x, res_A, res_B) # Almacenar detalles de la oportunidad. Los valores están en ETH. (1e18 Wei = 1 ETH) opps.append({ "profit": profit / 1e18, "input": x / 1e18, "pair": pair, "poolA": poolA, "poolB": poolB, })print(f"Found {len(opps)} oportunidades.")
Lo cual produce la siguiente salida:
Obteniendo reservas de 3081 piscinas.
Encontradas 1791 oportunidades.
Ahora tenemos una lista de todas las oportunidades. Solo necesitamos estimar su beneficio. En este momento, simplemente asumiremos un costo de gas constante para operar en una oportunidad.
Debemos usar un límite inferior para el costo de gas de un intercambio en Uniswap V2. Experimentalmente, encontramos que este valor está cerca de 43k de gas.
Explotar una oportunidad requiere 2 intercambios, y ejecutar una transacción en Ethereum cuesta un gas plano de 21k, para un total de 107k gas por oportunidad.
Aquí está el código que calcula la ganancia neta estimada de cada oportunidad:
# [...]# Utilice el costo de gas codificado duro de 107k gas por oportunidad gp = w3.eth.gas_pricefor opp in opps:opp["net_profit"] = opp["profit"] - 107000 * gp / 1e18# Ordenar por beneficio neto estimadoopps.sort(key=lambda x: x["net_profit"], reverse=True)# Mantener oportunidades positivaspositive_opps = [opp for opp in opps if opp["net_profit"] > 0]
# Oportunidades positivas contadas
print(f"Se encontraron {len(positive_opps)} oportunidades positivas.")
# Detalles de cada oportunidad
ETH_PRICE = 1900 # Deberías obtener dinámicamente el precio de ETH
for opp in positive_opps:
print(f"Beneficio: {opp['net_profit']} ETH (${opp['net_profit'] * ETH_PRICE})")
print(f"Entrada: {opp['input']} ETH (${opp['input'] * ETH_PRICE})")
print(f"Pool A: {opp['poolA']['pair']}")
print(f"Pool B: {opp['poolB']['pair']}")
print()
Aquí está la salida del script:
Encontradas 57 oportunidades positivas.
Beneficio: 4.936025725859028 ETH ($9378.448879132153)
Entrada: 1.7958289984719014 ETH ($3412.075097096613)
Pool A: 0x1498bd576454159Bb81B5Ce532692a8752D163e8
Pool B: 0x7D7E813082eF6c143277c71786e5bE626ec77b20
{‘profit’: 4.9374642090282865, ‘input’: 1.7958(…)
Beneficio: 4.756587769768892 ETH ($9037.516762560894)
Entrada: 0.32908348765283796 ETH ($625.2586265403921)
Grupo A: 0x486c1609f9605fA14C28E311b7D708B0541cd2f5
Pool B: 0x5e81b946b61F3C7F73Bf84dd961dE3A0A78E8c33
{'profit': 4.7580262529381505, 'input': 0.329(…)
Beneficio: 0.8147203063054365 ETH ($1547.9685819803292)
Entrada: 0.6715171730669338 ETH ($1275.8826288271744)
Pool A: 0x1f1B4836Dde1859e2edE1C6155140318EF5931C2
Grupo B: 0x1f7efDcD748F43Fc4BeAe6897e5a6DDd865DcceA
{‘profit’: 0.8161587894746954, ‘input’: 0.671(…)
(...)
Que son ganancias sospechosamente altas. El primer paso que se debe tomar es verificar que el código sea correcto. Después de verificar con cautela el código, encontramos que el código es correcto.
¿Son reales estas ganancias? Resulta que no. Hemos extendido nuestra red demasiado ampliamente al seleccionar qué pools considerar en nuestra estrategia, y hemos obtenido en nuestras manos pools de tokens tóxicos.
El estándar de token ERC20 solo describe una interfaz para la interoperabilidad. Cualquiera puede implementar un token que cumpla con esta interfaz y elegir implementar un comportamiento no ortodoxo, que es exactamente lo que está en juego aquí.
Algunos creadores de tokens diseñan sus tokens ERC20 de manera que los pools en los que se negocian no puedan vender, sino solo comprar el token. Incluso algunos contratos de tokens tienen mecanismos de interruptor de apagado que permiten al creador deshacerse de todos sus usuarios.
En nuestro bot de MEV, estos tokens tóxicos deben ser filtrados. Esto se abordará en un artículo futuro.
Si filtramos manualmente los tokens obviamente tóxicos, nos quedan las siguientes 42 oportunidades:
Beneficio: 0.004126583158496902 ETH ($7.840508001144114)
Entrada: 0.008369804833786892 ETH ($15.902629184195094)
Grupo A: 0xdF42388059692150d0A9De836E4171c7B9c09CBf
Pool B: 0xf98fCEB2DC0Fa2B3f32ABccc5e8495E961370B23
{‘profit’: 0.005565066327755902, (...)}
Beneficio: 0.004092580415474992 ETH ($7.775902789402485)
Entrada: 0.014696360216108083 ETH ($27.92308441060536)
Pool A: 0xfDBFb4239935A15C2C348400570E34De3b044c5F
Grupo B: 0x0F15d69a7E5998252ccC39Ad239Cef67fa2a9369
{‘profit’: 0.005531063584733992, (…)
Beneficio: 0.003693235163284344 ETH ($7.017146810240254)
Entrada: 0.1392339178514088 ETH ($264.5444439176767)
Grupo A: 0x2957215d0473d2c811A075725Da3C31D2af075F1
Pool B: 0xF110783EbD020DCFBA91Cd1976b79a6E510846AA
{‘profit’: 0.005131718332543344, (...)
Beneficio: 0.003674128918827048 ETH ($6.980844945771391)
Entrada: 0.2719041848570484 ETH ($516.617951228392)
Piscina A: 0xBa19343ff3E9f496F17C7333cdeeD212D65A8425
Pool B: 0xD30567f1d084f411572f202ebb13261CE9F46325
{‘profit’: 0.005112612088086048, (...)}
(…)
Tenga en cuenta que en general, las ganancias son inferiores al monto de entrada necesario para ejecutar la transacción.
Estas ganancias son mucho más razonables. Pero recuerda que siguen siendo ganancias en el mejor de los casos, ya que hemos utilizado una estimación muy rudimentaria del costo de gas de cada oportunidad.
En un artículo futuro, simularemos la ejecución de nuestro intercambio para obtener un valor preciso del costo de gas de cada oportunidad.
Para simular la ejecución, primero necesitamos desarrollar el contrato inteligente que ejecutará el intercambio. Este es el tema del próximo artículo.
Ahora tenemos una definición clara del perímetro de nuestro bot de arbitraje de MEV.
Hemos explorado la teoría matemática detrás de la estrategia de arbitraje, y la hemos implementado en Python.
Ahora tenemos una lista de posibles oportunidades de arbitraje, y necesitamos simular su ejecución para obtener un valor final de beneficio. Para hacerlo, necesitamos tener listo nuestro contrato inteligente de trading.
En el próximo artículo, desarrollaremos un contrato inteligente en Solidity y simularemos nuestro primer intercambio de arbitraje.
Puedes encontrar el código completo en el repositorio de GitHub asociado con este artículo. El script se ejecuta mejor en un cuaderno Jupyter.