Crea tu propio agente de IA de trading en Ethereum
En este tutorial aprenderás a construir un agente de IA de trading sencillo. Este agente funciona siguiendo estos pasos:
- Leer los precios actuales y pasados de un token, así como otra información potencialmente relevante
- Construir una consulta con esta información, junto con información de contexto para explicar cómo podría ser relevante
- Enviar la consulta y recibir a cambio un precio proyectado
- Operar basándose en la recomendación
- Esperar y repetir
Este agente demuestra cómo leer información, traducirla en una consulta que produzca una respuesta utilizable y usar esa respuesta. Todos estos son pasos necesarios para un agente de IA. Este agente está implementado en Python porque es el lenguaje más común utilizado en IA.
¿Por qué hacer esto?
Los agentes de trading automatizados permiten a los desarrolladores seleccionar y ejecutar una estrategia de trading. Los agentes de IA permiten estrategias de trading más complejas y dinámicas, utilizando potencialmente información y algoritmos que el desarrollador ni siquiera ha considerado usar.
Las herramientas
Este tutorial utiliza Python (opens in a new tab), la biblioteca Web3 (opens in a new tab) y Uniswap v3 (opens in a new tab) para cotizaciones y operaciones.
¿Por qué Python?
El lenguaje más utilizado para la IA es Python (opens in a new tab), así que lo usaremos aquí. No te preocupes si no sabes Python. El lenguaje es muy claro y explicaré exactamente qué hace.
La biblioteca Web3 (opens in a new tab) es la API de Ethereum para Python más común. Es bastante fácil de usar.
Operar en la cadena de bloques
Hay muchos intercambios descentralizados (DEX) que te permiten intercambiar tokens en Ethereum. Sin embargo, tienden a tener tasas de cambio similares debido al arbitraje.
Uniswap (opens in a new tab) es un DEX muy utilizado que podemos usar tanto para cotizaciones (para ver los valores relativos de los tokens) como para operaciones.
OpenAI
Para un modelo de lenguaje grande, elegí empezar con OpenAI (opens in a new tab). Para ejecutar la aplicación en este tutorial tendrás que pagar por el acceso a la API. El pago mínimo de 5 $ es más que suficiente.
Desarrollo, paso a paso
Para simplificar el desarrollo, procederemos por etapas. Cada paso es una rama en GitHub.
Primeros pasos
Hay pasos para empezar en UNIX o Linux (incluyendo WSL (opens in a new tab))
-
Si aún no lo tienes, descarga e instala Python (opens in a new tab).
-
Clona el repositorio de GitHub.
git clone https://github.com/qbzzt/260215-ai-agent.git -b 01-getting-started cd 260215-ai-agent -
Instala
uv(opens in a new tab). El comando en tu sistema podría ser diferente.pipx install uv -
Descarga las bibliotecas.
uv sync -
Activa el entorno virtual.
source .venv/bin/activate -
Para verificar que Python y Web3 funcionan correctamente, ejecuta
python3y proporciónale este programa. Puedes introducirlo en el prompt>>>; no hay necesidad de crear un archivo.from web3 import Web3 MAINNET_URL = "https://eth.drpc.org" w3 = Web3(Web3.HTTPProvider(MAINNET_URL)) w3.eth.block_number quit()
Leer de la cadena de bloques
El siguiente paso es leer de la cadena de bloques. Para hacerlo, necesitas cambiar a la rama 02-read-quote y luego usar uv para ejecutar el programa.
git checkout 02-read-quote
uv run agent.py
Deberías recibir una lista de objetos Quote, cada uno con una marca de tiempo, un precio y el activo (actualmente siempre WETH/USDC).
Aquí tienes una explicación línea por línea.
from web3 import Web3
from web3.contract import Contract
from decimal import Decimal, ROUND_HALF_UP
from dataclasses import dataclass
from datetime import datetime, timezone
from pprint import pprint
import time
import functools
import sys
Importa las bibliotecas que necesitamos. Se explican a continuación cuando se utilizan.
print = functools.partial(print, flush=True)
Reemplaza el print de Python con una versión que siempre vacía la salida inmediatamente. Esto es útil en un script de larga duración porque no queremos esperar a las actualizaciones de estado o a la salida de depuración.
MAINNET_URL = "https://eth.drpc.org"
Una URL para llegar a la Red principal. Puedes obtener una de un Nodo como servicio o usar una de las anunciadas en Chainlist (opens in a new tab).
BLOCK_TIME_SECONDS = 12
MINUTE_BLOCKS = int(60 / BLOCK_TIME_SECONDS)
HOUR_BLOCKS = MINUTE_BLOCKS * 60
DAY_BLOCKS = HOUR_BLOCKS * 24
Un bloque de la red principal de Ethereum suele ocurrir cada doce segundos, así que este es el número de bloques que esperaríamos que ocurrieran en un período de tiempo. Ten en cuenta que esta no es una cifra exacta. Cuando el proponente de bloque está inactivo, ese bloque se omite y el tiempo para el siguiente bloque es de 24 segundos. Si quisiéramos obtener el bloque exacto para una marca de tiempo, usaríamos una búsqueda binaria (opens in a new tab). Sin embargo, esto es lo suficientemente cercano para nuestros propósitos. Predecir el futuro no es una ciencia exacta.
CYCLE_BLOCKS = DAY_BLOCKS
El tamaño del ciclo. Revisamos las cotizaciones una vez por ciclo e intentamos estimar el valor al final del siguiente ciclo.
# La dirección del pool que estamos leyendo
WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")
Los valores de cotización se toman del pool USDC/WETH de Uniswap 3 en la dirección 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640 (opens in a new tab). Esta dirección ya está en formato de suma de comprobación (checksum), pero es mejor usar Web3.to_checksum_address (opens in a new tab) para hacer que el código sea reutilizable.
POOL_ABI = [
{ "name": "slot0", ... },
{ "name": "token0", ... },
{ "name": "token1", ... },
]
ERC20_ABI = [
{ "name": "symbol", ... },
{ "name": "decimals", ... }
]
Estas son las ABI (opens in a new tab) para los dos contratos con los que necesitamos contactar. Para mantener el código conciso, incluimos solo las funciones que necesitamos llamar.
w3 = Web3(Web3.HTTPProvider(MAINNET_URL))
Inicia la biblioteca Web3 (opens in a new tab) y conéctate a un nodo de Ethereum.
@dataclass(frozen=True)
class ERC20Token:
address: str
symbol: str
decimals: int
contract: Contract
Esta es una forma de crear una clase de datos en Python. El tipo de datos Contract (opens in a new tab) se utiliza para conectarse al contrato. Fíjate en el (frozen=True). En Python, los booleanos (opens in a new tab) se definen como True o False, en mayúsculas. Esta clase de datos es frozen, lo que significa que los campos no se pueden modificar.
Fíjate en la sangría. A diferencia de los lenguajes derivados de C (opens in a new tab), Python utiliza la sangría para denotar bloques. El intérprete de Python sabe que la siguiente definición no es parte de esta clase de datos porque no comienza con la misma sangría que los campos de la clase de datos.
@dataclass(frozen=True)
class PoolInfo:
address: str
token0: ERC20Token
token1: ERC20Token
contract: Contract
asset: str
decimal_factor: Decimal = 1
El tipo Decimal (opens in a new tab) se utiliza para manejar con precisión las fracciones decimales.
def get_price(self, block: int) -> Decimal:
Esta es la forma de definir una función en Python. La definición está sangrada para mostrar que todavía es parte de PoolInfo.
En una función que es parte de una clase de datos, el primer parámetro siempre es self, la instancia de la clase de datos que se llamó aquí. Aquí hay otro parámetro, el número de bloque.
assert block <= w3.eth.block_number, "Block is in the future"
Si pudiéramos leer el futuro, no necesitaríamos IA para el trading.
sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])
La sintaxis para llamar a una función en la EVM desde Web3 es esta: <contract object>.functions.<function name>().call(<parameters>). Los parámetros pueden ser los parámetros de la función de la EVM (si los hay; aquí no los hay) o parámetros con nombre (opens in a new tab) para modificar el comportamiento de la cadena de bloques. Aquí usamos uno, block_identifier, para especificar el número de bloque en el que deseamos ejecutar.
El resultado es esta estructura, en forma de matriz (opens in a new tab). El primer valor es una función de la tasa de cambio entre los dos tokens.
raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2
Para reducir los cálculos en cadena, Uniswap v3 no almacena el factor de intercambio real, sino su raíz cuadrada. Debido a que la EVM no admite matemáticas de punto flotante ni fracciones, en lugar del valor real, la respuesta es
# (token1 por token0)
return 1/(raw_price * self.decimal_factor)
El precio bruto que obtenemos es el número de token0 que obtenemos por cada token1. En nuestro pool, token0 es USDC (moneda estable con el mismo valor que un dólar estadounidense) y token1 es WETH (opens in a new tab). El valor que realmente queremos es el número de dólares por WETH, no a la inversa.
El factor decimal es la proporción entre los factores decimales (opens in a new tab) de los dos tokens.
@dataclass(frozen=True)
class Quote:
timestamp: str
price: Decimal
asset: str
Esta clase de datos representa una cotización: el precio de un activo específico en un momento dado. En este punto, el campo asset es irrelevante porque usamos un solo pool y, por lo tanto, tenemos un solo activo. Sin embargo, añadiremos más activos más adelante.
def read_token(address: str) -> ERC20Token:
token = w3.eth.contract(address=address, abi=ERC20_ABI)
symbol = token.functions.symbol().call()
decimals = token.functions.decimals().call()
return ERC20Token(
address=address,
symbol=symbol,
decimals=decimals,
contract=token
)
Esta función toma una dirección y devuelve información sobre el contrato del token en esa dirección. Para crear un nuevo Contract de Web3 (opens in a new tab), proporcionamos la dirección y la ABI a w3.eth.contract.
def read_pool(address: str) -> PoolInfo:
pool_contract = w3.eth.contract(address=address, abi=POOL_ABI)
token0Address = pool_contract.functions.token0().call()
token1Address = pool_contract.functions.token1().call()
token0 = read_token(token0Address)
token1 = read_token(token1Address)
return PoolInfo(
address=address,
asset=f"{token1.symbol}/{token0.symbol}",
token0=token0,
token1=token1,
contract=pool_contract,
decimal_factor=Decimal(10) ** Decimal(token0.decimals - token1.decimals)
)
Esta función devuelve todo lo que necesitamos sobre un pool específico (opens in a new tab). La sintaxis f"<string>" es una cadena formateada (opens in a new tab).
def get_quote(pool: PoolInfo, block_number: int = None) -> Quote:
Obtén un objeto Quote. El valor predeterminado para block_number es None (sin valor).
if block_number is None:
block_number = w3.eth.block_number
Si no se especificó un número de bloque, usa w3.eth.block_number, que es el último número de bloque. Esta es la sintaxis para una declaración if (opens in a new tab).
Podría parecer que habría sido mejor simplemente establecer el valor predeterminado en w3.eth.block_number, pero eso no funciona bien porque sería el número de bloque en el momento en que se define la función. En un agente de larga duración, esto sería un problema.
block = w3.eth.get_block(block_number)
price = pool.get_price(block_number)
return Quote(
timestamp=datetime.fromtimestamp(block.timestamp, timezone.utc).isoformat(),
price=price.quantize(Decimal("0.01")),
asset=pool.asset
)
Usa la biblioteca datetime (opens in a new tab) para formatearlo a un formato legible para humanos y modelos de lenguaje grandes (LLM). Usa Decimal.quantize (opens in a new tab) para redondear el valor a dos decimales.
def get_quotes(pool: PoolInfo, start_block: int, end_block: int, step: int) -> list[Quote]:
En Python defines una lista (opens in a new tab) que solo puede contener un tipo específico usando list[<type>].
quotes = []
for block in range(start_block, end_block + 1, step):
En Python, un bucle for (opens in a new tab) normalmente itera sobre una lista. La lista de números de bloque en los que encontrar cotizaciones proviene de range (opens in a new tab).
quote = get_quote(pool, block)
quotes.append(quote)
return quotes
Para cada número de bloque, obtén un objeto Quote y añádelo a la lista quotes. Luego devuelve esa lista.
pool = read_pool(WETHUSDC_ADDRESS)
quotes = get_quotes(
pool,
w3.eth.block_number - 12*CYCLE_BLOCKS,
w3.eth.block_number,
CYCLE_BLOCKS
)
pprint(quotes)
Este es el código principal del script. Lee la información del pool, obtén doce cotizaciones y pprint (opens in a new tab) (imprímelas).
Crear un prompt
A continuación, necesitamos convertir esta lista de cotizaciones en un prompt para un LLM y obtener un valor futuro esperado.
git checkout 03-create-prompt
uv run agent.py
La salida ahora va a ser un prompt para un LLM, similar a:
Dadas estas cotizaciones:
Activo: WETH/USDC
2026-01-20T16:34 3016.21
.
.
.
2026-02-01T17:49 2299.10
Activo: WBTC/WETH
2026-01-20T16:34 29.84
.
.
.
2026-02-01T17:50 33.46
¿Cuál esperarías que fuera el valor de WETH/USDC en el momento 2026-02-02T17:56?
Proporciona tu respuesta como un solo número redondeado a dos decimales,
sin ningún otro texto.
Ten en cuenta que aquí hay cotizaciones para dos activos, WETH/USDC y WBTC/WETH. Añadir cotizaciones de otro activo podría mejorar la precisión de la predicción.
Cómo se ve un prompt
Este prompt contiene tres secciones, que son bastante comunes en los prompts de LLM.
-
Información. Los LLM tienen mucha información de su entrenamiento, pero normalmente no tienen la más reciente. Esta es la razón por la que necesitamos recuperar las últimas cotizaciones aquí. Añadir información a un prompt se llama generación aumentada por recuperación (RAG) (opens in a new tab).
-
La pregunta real. Esto es lo que queremos saber.
-
Instrucciones de formato de salida. Normalmente, un LLM nos dará una estimación con una explicación de cómo llegó a ella. Esto es mejor para los humanos, pero un programa informático solo necesita el resultado final.
Explicación del código
Aquí está el nuevo código.
from datetime import datetime, timezone, timedelta
Necesitamos proporcionar al LLM el tiempo para el cual queremos una estimación. Para obtener un tiempo de "n minutos/horas/días" en el futuro, usamos la clase timedelta (opens in a new tab).
# Las direcciones de los pools que estamos leyendo
WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")
WETHWBTC_ADDRESS = Web3.to_checksum_address("0xCBCdF9626bC03E24f779434178A73a0B4bad62eD")
Tenemos dos pools que necesitamos leer.
@dataclass(frozen=True)
class PoolInfo:
.
.
.
reverse: bool = False
def get_price(self, block: int) -> Decimal:
assert block <= w3.eth.block_number, "Block is in the future"
sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])
raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2 # (token1 por token0)
if self.reverse:
return 1/(raw_price * self.decimal_factor)
else:
return raw_price * self.decimal_factor
En el pool WETH/USDC, queremos saber cuántos token0 (USDC) necesitamos para comprar un token1 (WETH). En el pool WETH/WBTC, queremos saber cuántos token1 (WETH) necesitamos para comprar un token0 (WBTC, que es Bitcoin envuelto). Necesitamos hacer un seguimiento de si la proporción del pool debe invertirse.
def read_pool(address: str, reverse: bool = False) -> PoolInfo:
.
.
.
return PoolInfo(
.
.
.
asset= f"{token1.symbol}/{token0.symbol}" if reverse else f"{token0.symbol}/{token1.symbol}",
reverse=reverse
)
Para saber si un pool necesita ser invertido, tenemos que obtener eso como entrada para read_pool. Además, el símbolo del activo debe configurarse correctamente.
La sintaxis <a> if <b> else <c> es el equivalente en Python del operador condicional ternario (opens in a new tab), que en un lenguaje derivado de C sería <b> ? <a> : <c>.
def format_quotes(quotes: list[Quote]) -> str:
result = f"Asset: {quotes[0].asset}\n"
for quote in quotes:
result += f"\t{quote.timestamp[0:16]} {quote.price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}\n"
return result
Esta función construye una cadena que formatea una lista de objetos Quote, asumiendo que todos se aplican al mismo activo.
def make_prompt(quotes: list[list[Quote]], expected_time: str, asset: str) -> str:
return f"""
En Python, los literales de cadena de varias líneas (opens in a new tab) se escriben como """ .... """.
Given these quotes:
{
functools.reduce(lambda acc, q: acc + '\n' + q,
map(lambda q: format_quotes(q), quotes))
}
Aquí, usamos el patrón MapReduce (opens in a new tab) para generar una cadena para cada lista de cotizaciones con format_quotes, y luego las reducimos a una sola cadena para usarla en el prompt.
What would you expect the value for {asset} to be at time {expected_time}?
Provide your answer as a single number rounded to two decimal places,
without any other text.
"""
El resto del prompt es como se esperaba.
wethusdc_pool = read_pool(WETHUSDC_ADDRESS, True)
wethusdc_quotes = get_quotes(
wethusdc_pool,
w3.eth.block_number - 12*CYCLE_BLOCKS,
w3.eth.block_number,
CYCLE_BLOCKS,
)
wethwbtc_pool = read_pool(WETHWBTC_ADDRESS)
wethwbtc_quotes = get_quotes(
wethwbtc_pool,
w3.eth.block_number - 12*CYCLE_BLOCKS,
w3.eth.block_number,
CYCLE_BLOCKS
)
Revisa los dos pools y obtén cotizaciones de ambos.
future_time = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()[0:16]
print(make_prompt(wethusdc_quotes + wethwbtc_quotes, future_time, wethusdc_pool.asset))
Determina el punto de tiempo futuro para el cual queremos la estimación y crea el prompt.
Interactuar con un LLM
A continuación, enviamos un prompt a un LLM real y recibimos un valor futuro esperado. Escribí este programa usando OpenAI, así que si quieres usar un proveedor diferente, tendrás que ajustarlo.
-
Obtén una cuenta de OpenAI (opens in a new tab)
-
Añade fondos a la cuenta (opens in a new tab): la cantidad mínima en el momento de escribir esto es de 5 $
-
En la línea de comandos, exporta la clave de API para que tu programa pueda usarla
export OPENAI_API_KEY=sk-<the rest of the key goes here> -
Cambia de rama (checkout) y ejecuta el agente
git checkout 04-interface-llm uv run agent.py
Aquí está el nuevo código.
from openai import OpenAI
open_ai = OpenAI() # El cliente lee la variable de entorno OPENAI_API_KEY
Importa e instancia la API de OpenAI.
response = open_ai.chat.completions.create(
model="gpt-4-turbo",
messages=[
{"role": "user", "content": prompt}
],
temperature=0.0,
max_tokens=16,
)
Llama a la API de OpenAI (open_ai.chat.completions.create) para crear la respuesta.
expected_price = Decimal(response.choices[0].message.content.strip())
current_price = wethusdc_quotes[-1].price
print ("Current price:", wethusdc_quotes[-1].price)
print(f"In {future_time}, expected price: {expected_price} USD")
if (expected_price > current_price):
print(f"Buy, I expect the price to go up by {expected_price - current_price} USD")
else:
print(f"Sell, I expect the price to go down by {current_price - expected_price} USD")
Muestra el precio y proporciona una recomendación de compra o venta.
Probar las predicciones
Ahora que podemos generar predicciones, también podemos usar datos históricos para evaluar si producimos predicciones útiles.
uv run test-predictor.py
El resultado esperado es similar a:
Predicción para 2026-01-05T19:50: predicho 3138.93 USD, real 3218.92 USD, error 79.99 USD
Predicción para 2026-01-06T19:56: predicho 3243.39 USD, real 3221.08 USD, error 22.31 USD
Predicción para 2026-01-07T20:02: predicho 3223.24 USD, real 3146.89 USD, error 76.35 USD
Predicción para 2026-01-08T20:11: predicho 3150.47 USD, real 3092.04 USD, error 58.43 USD
.
.
.
Predicción para 2026-01-31T22:33: predicho 2637.73 USD, real 2417.77 USD, error 219.96 USD
Predicción para 2026-02-01T22:41: predicho 2381.70 USD, real 2318.84 USD, error 62.86 USD
Predicción para 2026-02-02T22:49: predicho 2234.91 USD, real 2349.28 USD, error 114.37 USD
Error medio de predicción en 29 predicciones: 83.87103448275862068965517241 USD
Cambio medio por recomendación: 4.787931034482758620689655172 USD
Varianza estándar de los cambios: 104.42 USD
Días rentables: 51.72%
Días con pérdidas: 48.28%
La mayor parte del probador es idéntica al agente, pero aquí están las partes que son nuevas o modificadas.
CYCLES_FOR_TEST = 40 # Para el backtest, sobre cuántos ciclos probamos
# Obtener muchas cotizaciones
wethusdc_pool = read_pool(WETHUSDC_ADDRESS, True)
wethusdc_quotes = get_quotes(
wethusdc_pool,
w3.eth.block_number - CYCLE_BLOCKS*CYCLES_FOR_TEST,
w3.eth.block_number,
CYCLE_BLOCKS,
)
wethwbtc_pool = read_pool(WETHWBTC_ADDRESS)
wethwbtc_quotes = get_quotes(
wethwbtc_pool,
w3.eth.block_number - CYCLE_BLOCKS*CYCLES_FOR_TEST,
w3.eth.block_number,
CYCLE_BLOCKS
)
Miramos CYCLES_FOR_TEST (especificado como 40 aquí) días atrás.
# Crear predicciones y verificarlas contra el historial real
total_error = Decimal(0)
changes = []
Hay dos tipos de errores que nos interesan. El primero, total_error, es simplemente la suma de los errores que cometió el predictor.
Para entender el segundo, changes, necesitamos recordar el propósito del agente. No es predecir la proporción WETH/USDC (precio de ETH). Es emitir recomendaciones de venta y compra. Si el precio actual es de 2000 $ y predice 2010 $ para mañana, no nos importa si el resultado real es 2020 $ y ganamos dinero extra. Pero sí nos importa si predijo 2010 $, y compró ETH basándose en esa recomendación, y el precio cae a 1990 $.
for index in range(0,len(wethusdc_quotes)-CYCLES_BACK):
Solo podemos observar los casos en los que el historial completo (los valores utilizados para la predicción y el valor del mundo real con el que compararlo) está disponible. Esto significa que el caso más reciente debe ser el que comenzó hace CYCLES_BACK.
wethusdc_slice = wethusdc_quotes[index:index+CYCLES_BACK]
wethwbtc_slice = wethwbtc_quotes[index:index+CYCLES_BACK]
Usa segmentos (slices) (opens in a new tab) para obtener el mismo número de muestras que el número que usa el agente. El código entre aquí y el siguiente segmento es el mismo código de obtener una predicción que tenemos en el agente.
predicted_price = Decimal(response.choices[0].message.content.strip())
real_price = wethusdc_quotes[index+CYCLES_BACK].price
prediction_time_price = wethusdc_quotes[index+CYCLES_BACK-1].price
Obtén el precio predicho, el precio real y el precio en el momento de la predicción. Necesitamos el precio en el momento de la predicción para determinar si la recomendación fue comprar o vender.
error = abs(predicted_price - real_price)
total_error += error
print (f"Prediction for {prediction_time}: predicted {predicted_price} USD, real {real_price} USD, error {error} USD")
Calcula el error y añádelo al total.
recomended_action = 'buy' if predicted_price > prediction_time_price else 'sell'
price_increase = real_price - prediction_time_price
changes.append(price_increase if recomended_action == 'buy' else -price_increase)
Para changes, queremos el impacto monetario de comprar o vender un ETH. Así que primero, necesitamos determinar la recomendación, luego evaluar cómo cambió el precio real, y si la recomendación ganó dinero (cambio positivo) o costó dinero (cambio negativo).
print (f"Mean prediction error over {len(wethusdc_quotes)-CYCLES_BACK} predictions: {total_error / Decimal(len(wethusdc_quotes)-CYCLES_BACK)} USD")
length_changes = Decimal(len(changes))
mean_change = sum(changes, Decimal(0)) / length_changes
print (f"Mean change per recommendation: {mean_change} USD")
var = sum((x - mean_change) ** 2 for x in changes) / length_changes
print (f"Standard variance of changes: {var.sqrt().quantize(Decimal("0.01"))} USD")
Informa de los resultados.
print (f"Profitable days: {len(list(filter(lambda x: x > 0, changes)))/length_changes:.2%}")
print (f"Losing days: {len(list(filter(lambda x: x < 0, changes)))/length_changes:.2%}")
Usa filter (opens in a new tab) para contar el número de días rentables y el número de días con pérdidas. El resultado es un objeto de filtro, que necesitamos convertir en una lista para obtener la longitud.
Enviar transacciones
Ahora necesitamos enviar transacciones realmente. Sin embargo, no quiero gastar dinero real en este punto, antes de que el sistema esté probado. En su lugar, crearemos una bifurcación local de la Red principal y "operaremos" en esa red.
Aquí están los pasos para crear una bifurcación local y habilitar el trading.
-
Instala Foundry (opens in a new tab)
-
Inicia
anvil(opens in a new tab)anvil --fork-url https://eth.drpc.org --block-time 12anvilestá escuchando en la URL predeterminada para Foundry, http://localhost:8545 (opens in a new tab), por lo que no necesitamos especificar la URL para el comandocast(opens in a new tab) que usamos para manipular la cadena de bloques. -
Al ejecutar en
anvil, hay diez cuentas de prueba que tienen ETH: establece las variables de entorno para la primeraPRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 ADDRESS=`cast wallet address $PRIVATE_KEY` -
Estos son los contratos que necesitamos usar.
SwapRouter(opens in a new tab) es el contrato de Uniswap v3 que usamos para operar realmente. Podríamos operar directamente a través del pool, pero esto es mucho más fácil.Las dos variables inferiores son las rutas de Uniswap v3 necesarias para intercambiar entre WETH y USDC.
WETH_ADDRESS=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 USDC_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 POOL_ADDRESS=0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640 SWAP_ROUTER=0xE592427A0AEce92De3Edee1F18E0157C05861564 WETH_TO_USDC=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 USDC_TO_WETH=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 -
Cada una de las cuentas de prueba tiene 10.000 ETH. Usa el contrato WETH para envolver 1000 ETH y obtener 1000 WETH para operar.
cast send $WETH_ADDRESS "deposit()" --value 1000ether --private-key $PRIVATE_KEY -
Usa
SwapRouterpara intercambiar 500 WETH por USDC.cast send $WETH_ADDRESS "approve(address,uint256)" $SWAP_ROUTER 500ether --private-key $PRIVATE_KEY MAXINT=`cast max-int uint256` cast send $SWAP_ROUTER \ "exactInput((bytes,address,uint256,uint256,uint256))" \ "($WETH_TO_USDC,$ADDRESS,$MAXINT,500ether,1000000)" \ --private-key $PRIVATE_KEYLa llamada
approvecrea una asignación que permite aSwapRoutergastar algunos de nuestros tokens. Los contratos no pueden monitorizar eventos, por lo que si transferimos tokens directamente al contratoSwapRouter, no sabría que se le pagó. En su lugar, permitimos que el contratoSwapRoutergaste una cierta cantidad, y luegoSwapRouterlo hace. Esto se hace a través de una función llamada porSwapRouter, por lo que sabe si tuvo éxito. -
Verifica que tienes suficientes de ambos tokens.
cast call $WETH_ADDRESS "balanceOf(address)" $ADDRESS | cast from-wei echo `cast call $USDC_ADDRESS "balanceOf(address)" $ADDRESS | cast to-dec`/10^6 | bc
Ahora que tenemos WETH y USDC, podemos ejecutar realmente el agente.
git checkout 05-trade
uv run agent.py
La salida se verá similar a:
(ai-trading-agent) qbzzt@Ori-Cloudnomics:~/260215-ai-agent$ uv run agent.py
Precio actual: 1843.16
En 2026-02-06T23:07, precio esperado: 1724.41 USD
Saldos de la cuenta antes de la operación:
Saldo de USDC: 927301.578272
Saldo de WETH: 500
Vender, espero que el precio baje 118.75 USD
Transacción de aprobación enviada: 74e367ddbb407c1aaf567d87aa5863049991b1d2aa092b6b85195d925e2bd41f
Transacción de aprobación minada.
Transacción de venta enviada: fad1bcf938585c9e90364b26ac7a80eea9efd34c37e5db81e58d7655bcae28bf
Transacción de venta minada.
Saldos de la cuenta después de la operación:
Saldo de USDC: 929143.797116
Saldo de WETH: 499
Para usarlo realmente, necesitas algunos cambios menores.
- En la línea 14, cambia
MAINNET_URLa un punto de acceso real, comohttps://eth.drpc.org - En la línea 28, cambia
PRIVATE_KEYa tu propia clave privada - A menos que seas muy rico y puedas comprar o vender 1 ETH cada día para un agente no probado, es posible que quieras cambiar la línea 29 para disminuir
WETH_TRADE_AMOUNT
Explicación del código
Aquí está el nuevo código.
SWAP_ROUTER_ADDRESS=Web3.to_checksum_address("0xE592427A0AEce92De3Edee1F18E0157C05861564")
WETH_TO_USDC=bytes.fromhex("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
USDC_TO_WETH=bytes.fromhex("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
PRIVATE_KEY="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
Las mismas variables que usamos en el paso 4.
WETH_TRADE_AMOUNT=1
La cantidad a operar.
ERC20_ABI = [
{ "name": "symbol", ... },
{ "name": "decimals", ... },
{ "name": "balanceOf", ...},
{ "name": "approve", ...}
]
Para operar realmente, necesitamos la función approve. También queremos mostrar los saldos antes y después, así que también necesitamos balanceOf.
SWAP_ROUTER_ABI = [
{ "name": "exactInput", ...},
]
En la ABI de SwapRouter solo necesitamos exactInput. Hay una función relacionada, exactOutput, que podríamos usar para comprar exactamente un WETH, pero por simplicidad solo usamos exactInput en ambos casos.
account = w3.eth.account.from_key(PRIVATE_KEY)
swap_router = w3.eth.contract(
address=SWAP_ROUTER_ADDRESS,
abi=SWAP_ROUTER_ABI
)
Las definiciones de Web3 para el account (opens in a new tab) y el contrato SwapRouter.
def txn_params() -> dict:
return {
"from": account.address,
"value": 0,
"gas": 300000,
"nonce": w3.eth.get_transaction_count(account.address),
}
Los parámetros de la transacción. Necesitamos una función aquí porque el nonce (opens in a new tab) debe cambiar cada vez.
def approve_token(contract: Contract, amount: int):
Aprueba una asignación de tokens para SwapRouter.
txn = contract.functions.approve(SWAP_ROUTER_ADDRESS, amount).build_transaction(txn_params())
signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
Así es como enviamos una transacción en Web3. Primero usamos el objeto Contract (opens in a new tab) para construir la transacción. Luego usamos web3.eth.account.sign_transaction (opens in a new tab) para firmar la transacción, usando PRIVATE_KEY. Finalmente, usamos w3.eth.send_raw_transaction (opens in a new tab) para enviar la transacción.
print(f"Approve transaction sent: {tx_hash.hex()}")
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Approve transaction mined.")
w3.eth.wait_for_transaction_receipt (opens in a new tab) espera hasta que la transacción sea minada. Devuelve el recibo si es necesario.
SELL_PARAMS = {
"path": WETH_TO_USDC,
"recipient": account.address,
"deadline": 2**256 - 1,
"amountIn": WETH_TRADE_AMOUNT * 10 ** wethusdc_pool.token1.decimals,
"amountOutMinimum": 0,
}
Estos son los parámetros al vender WETH.
def make_buy_params(quote: Quote) -> dict:
return {
"path": USDC_TO_WETH,
"recipient": account.address,
"deadline": 2**256 - 1,
"amountIn": int(quote.price*WETH_TRADE_AMOUNT) * 10**wethusdc_pool.token0.decimals,
"amountOutMinimum": 0,
}
A diferencia de SELL_PARAMS, los parámetros de compra pueden cambiar. La cantidad de entrada es el coste de 1 WETH, tal como está disponible en quote.
def buy(quote: Quote):
buy_params = make_buy_params(quote)
approve_token(wethusdc_pool.token0.contract, buy_params["amountIn"])
txn = swap_router.functions.exactInput(buy_params).build_transaction(txn_params())
signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
print(f"Buy transaction sent: {tx_hash.hex()}")
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Buy transaction mined.")
def sell():
approve_token(wethusdc_pool.token1.contract,
WETH_TRADE_AMOUNT * 10**wethusdc_pool.token1.decimals)
txn = swap_router.functions.exactInput(SELL_PARAMS).build_transaction(txn_params())
signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
print(f"Sell transaction sent: {tx_hash.hex()}")
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Sell transaction mined.")
Las funciones buy() y sell() son casi idénticas. Primero aprobamos una asignación suficiente para SwapRouter, y luego lo llamamos con la ruta y la cantidad correctas.
def balances():
token0_balance = wethusdc_pool.token0.contract.functions.balanceOf(account.address).call()
token1_balance = wethusdc_pool.token1.contract.functions.balanceOf(account.address).call()
print(f"{wethusdc_pool.token0.symbol} Balance: {Decimal(token0_balance) / Decimal(10 ** wethusdc_pool.token0.decimals)}")
print(f"{wethusdc_pool.token1.symbol} Balance: {Decimal(token1_balance) / Decimal(10 ** wethusdc_pool.token1.decimals)}")
Informa de los saldos del usuario en ambas monedas.
print("Account balances before trade:")
balances()
if (expected_price > current_price):
print(f"Buy, I expect the price to go up by {expected_price - current_price} USD")
buy(wethusdc_quotes[-1])
else:
print(f"Sell, I expect the price to go down by {current_price - expected_price} USD")
sell()
print("Account balances after trade:")
balances()
Este agente actualmente solo funciona una vez. Sin embargo, puedes cambiarlo para que funcione continuamente, ya sea ejecutándolo desde crontab (opens in a new tab) o envolviendo las líneas 368-400 en un bucle y usando time.sleep (opens in a new tab) para esperar hasta que sea el momento del siguiente ciclo.
Posibles mejoras
Esta no es una versión de producción completa; es simplemente un ejemplo para enseñar los conceptos básicos. Aquí hay algunas ideas para mejoras.
Trading más inteligente
Hay dos hechos importantes que el agente ignora al decidir qué hacer.
- La magnitud del cambio anticipado. El agente vende una cantidad fija de
WETHsi se espera que el precio baje, independientemente de la magnitud de la caída. Podría decirse que sería mejor ignorar los cambios menores y vender en función de cuánto esperamos que baje el precio. - El portafolio actual. Si el 10 % de tu portafolio está en WETH y crees que el precio subirá, probablemente tenga sentido comprar más. Pero si el 90 % de tu portafolio está en WETH, es posible que estés suficientemente expuesto y no haya necesidad de comprar más. Lo contrario es cierto si esperas que el precio baje.
¿Qué pasa si quieres mantener tu estrategia de trading en secreto?
Los proveedores de IA pueden ver las consultas que envías a sus LLM, lo que podría exponer el genial sistema de trading que desarrollaste con tu agente. Un sistema de trading que demasiada gente usa no vale nada porque demasiada gente intenta comprar cuando tú quieres comprar (y el precio sube) e intenta vender cuando tú quieres vender (y el precio baja).
Puedes ejecutar un LLM localmente, por ejemplo, usando LM-Studio (opens in a new tab), para evitar este problema.
De bot de IA a agente de IA
Puedes argumentar con razón que este es un bot de IA, no un agente de IA. Implementa una estrategia relativamente simple que se basa en información predefinida. Podemos habilitar la automejora, por ejemplo, proporcionando una lista de pools de Uniswap v3 y sus últimos valores y preguntando qué combinación tiene el mejor valor predictivo.
Protección contra el deslizamiento
Actualmente no hay protección contra el deslizamiento (opens in a new tab). Si la cotización actual es de 2000 $ y el precio esperado es de 2100 $, el agente comprará. Sin embargo, si antes de que el agente compre el coste sube a 2200 $, ya no tiene sentido comprar.
Para implementar la protección contra el deslizamiento, especifica un valor amountOutMinimum en las líneas 325 y 334 de agent.py (opens in a new tab).
Conclusión
Con suerte, ahora sabes lo suficiente para empezar con los agentes de IA. Esta no es una descripción general exhaustiva del tema; hay libros enteros dedicados a eso, pero esto es suficiente para empezar. ¡Buena suerte!