Создайте своего торгового агента с ИИ на Ethereum
В этом руководстве вы узнаете, как создать простого торгового агента с ИИ. Этот агент работает следующим образом:
- Считывание текущих и прошлых цен токена, а также другой потенциально релевантной информации
- Создание запроса с этой информацией, а также с фоновой информацией для объяснения ее возможной релевантности
- Отправка запроса и получение прогнозируемой цены
- Торговля на основе рекомендации
- Ожидание и повторение
Этот агент демонстрирует, как считывать информацию, преобразовывать ее в запрос, который дает применимый ответ, и использовать этот ответ. Все это необходимые шаги для агента с ИИ. Этот агент реализован на языке Python, поскольку это самый распространенный язык, используемый в сфере ИИ.
Зачем это делать?
Автоматизированные торговые агенты позволяют разработчикам выбирать и выполнять торговую стратегию. Агенты с ИИ позволяют использовать более сложные и динамичные торговые стратегии, потенциально используя информацию и алгоритмы, которые разработчик даже не рассматривал для использования.
Инструменты
В этом руководстве используется Python (opens in a new tab), библиотека Web3 (opens in a new tab), и Uniswap v3 (opens in a new tab) для получения котировок и торговли.
Почему Python?
Самый широко используемый язык для ИИ — это Python (opens in a new tab), поэтому мы используем его здесь. Не беспокойтесь, если вы не знаете Python. Язык очень понятен, и я объясню, что именно он делает.
Библиотека Web3 (opens in a new tab) является самым распространенным API для Ethereum на языке Python. Она довольно проста в использовании.
Торговля в блокчейне
Существует много распределенных бирж (DEX), которые позволяют торговать токенами на Ethereum. Однако у них, как правило, схожие обменные курсы благодаря арбитражу.
Uniswap (opens in a new tab) — это широко используемая DEX, которую мы можем использовать как для получения котировок (чтобы увидеть относительную стоимость токенов), так и для совершения сделок.
OpenAI
Для работы с большой языковой моделью я решил начать с OpenAI (opens in a new tab). Для запуска приложения из этого руководства вам понадобится оплатить доступ к API. Минимального платежа в 5 $ более чем достаточно.
Пошаговая разработка
Чтобы упростить разработку, мы будем действовать поэтапно. Каждый шаг — это отдельная ветка в GitHub.
Начало работы
Вот шаги для начала работы в UNIX или Linux (включая WSL (opens in a new tab))
-
Если у вас еще нет, скачайте и установите Python (opens in a new tab).
-
Клонируйте репозиторий GitHub.
1git clone https://github.com/qbzzt/260215-ai-agent.git -b 01-getting-started2cd 260215-ai-agent -
Установите
uv(opens in a new tab). Команда в вашей системе может отличаться.1pipx install uv -
Скачайте библиотеки.
1uv sync -
Активируйте виртуальное окружение.
1source .venv/bin/activate -
Чтобы убедиться, что Python и Web3 работают корректно, запустите
python3и передайте ему эту программу. Вы можете ввести ее в строке>>>; нет необходимости создавать файл.1from web3 import Web32MAINNET_URL = "https://eth.drpc.org"3w3 = Web3(Web3.HTTPProvider(MAINNET_URL))4w3.eth.block_number5quit()
Чтение из блокчейна
Следующий шаг — чтение из блокчейна. Для этого вам нужно переключиться на ветку 02-read-quote, а затем использовать uv для запуска программы.
1git checkout 02-read-quote2uv run agent.pyВы должны получить список объектов Quote, каждый из которых содержит временную метку, цену и актив (в настоящее время это всегда WETH/USDC).
Вот пошаговое объяснение.
1from web3 import Web32from web3.contract import Contract3from decimal import Decimal, ROUND_HALF_UP4from dataclasses import dataclass5from datetime import datetime, timezone6from pprint import pprint7import time8import functools9import sysПоказать всеИмпортируйте необходимые нам библиотеки. Они объясняются ниже, по мере их использования.
1print = functools.partial(print, flush=True)Заменяет print из Python на версию, которая всегда немедленно сбрасывает вывод. Это полезно в долго работающем скрипте, потому что мы не хотим ждать обновлений статуса или вывода для отладки.
1MAINNET_URL = "https://eth.drpc.org"URL для доступа к основной сети. Вы можете получить его у сервиса «Узел как услуга» или использовать один из тех, что рекламируются в Chainlist (opens in a new tab).
1BLOCK_TIME_SECONDS = 122MINUTE_BLOCKS = int(60 / BLOCK_TIME_SECONDS)3HOUR_BLOCKS = MINUTE_BLOCKS * 604DAY_BLOCKS = HOUR_BLOCKS * 24Блок в основной сети Ethereum обычно создается каждые двенадцать секунд, поэтому это количество блоков, которое мы ожидаем за определенный период времени. Обратите внимание, что это не точное значение. Когда предлагающий блок недоступен, этот блок пропускается, и время до следующего блока составляет 24 секунды. Если бы мы хотели получить точный блок по временной метке, мы бы использовали двоичный поиск (opens in a new tab). Однако для наших целей этого достаточно. Предсказание будущего — не точная наука.
1CYCLE_BLOCKS = DAY_BLOCKSРазмер цикла. Мы просматриваем котировки один раз за цикл и пытаемся оценить стоимость в конце следующего цикла.
1# Адрес пула, который мы считываем2WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")Значения котировок берутся из пула Uniswap 3 USDC/WETH по адресу 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640 (opens in a new tab). Этот адрес уже в формате с контрольной суммой, но лучше использовать Web3.to_checksum_address (opens in a new tab), чтобы сделать код повторно используемым.
1POOL_ABI = [2 { "name": "slot0", ... },3 { "name": "token0", ... },4 { "name": "token1", ... },5]67ERC20_ABI = [8 { "name": "symbol", ... },9 { "name": "decimals", ... }10]Показать всеЭто ABI (opens in a new tab) для двух контрактов, с которыми нам нужно взаимодействовать. Для краткости кода мы включаем только те функции, которые нам нужно вызывать.
1w3 = Web3(Web3.HTTPProvider(MAINNET_URL))Инициализируйте библиотеку Web3 (opens in a new tab) и подключитесь к узлу Ethereum.
1@dataclass(frozen=True)2class ERC20Token:3 address: str4 symbol: str5 decimals: int6 contract: ContractЭто один из способов создания класса данных в Python. Тип данных Contract (opens in a new tab) используется для подключения к контракту. Обратите внимание на (frozen=True). В Python логические значения (opens in a new tab) определяются как True или False, с заглавной буквы. Этот класс данных является frozen, что означает, что поля не могут быть изменены.
Обратите внимание на отступы. В отличие от C-подобных языков (opens in a new tab), Python использует отступы для обозначения блоков. Интерпретатор Python знает, что следующее определение не является частью этого класса данных, потому что оно не начинается с тем же отступом, что и поля класса данных.
1@dataclass(frozen=True)2class PoolInfo:3 address: str4 token0: ERC20Token5 token1: ERC20Token6 contract: Contract7 asset: str8 decimal_factor: Decimal = 1Тип Decimal (opens in a new tab) используется для точной обработки десятичных дробей.
1 def get_price(self, block: int) -> Decimal:Так определяется функция в Python. Определение имеет отступ, чтобы показать, что оно по-прежнему является частью PoolInfo.
В функции, которая является частью класса данных, первым параметром всегда является self — экземпляр класса данных, который ее вызвал. Здесь есть еще один параметр — номер блока.
1 assert block <= w3.eth.block_number, "Block is in the future"Если бы мы могли читать будущее, нам бы не нужен был ИИ для торговли.
1 sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])Синтаксис для вызова функции в EVM из Web3 следующий: <contract object>.functions.<function name>().call(<parameters>). Параметрами могут быть параметры функции EVM (если они есть; здесь их нет) или именованные параметры (opens in a new tab) для изменения поведения блокчейна. Здесь мы используем один, block_identifier, чтобы указать номер блока, в котором мы хотим выполнить операцию.
Результатом является эта структура в виде массива (opens in a new tab). Первое значение — это функция обменного курса между двумя токенами.
1 raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2Для сокращения вычислений в сети Uniswap v3 хранит не фактический коэффициент обмена, а его квадратный корень. Поскольку EVM не поддерживает математику с плавающей запятой или дроби, вместо фактического значения ответ будет
1 # (token1 per token0)2 return 1/(raw_price * self.decimal_factor)Необработанная цена, которую мы получаем, — это количество token0, которое мы получаем за каждый token1. В нашем пуле token0 — это USDC (стабильная монета с такой же стоимостью, как у доллара США), а token1 — это WETH (opens in a new tab). Значение, которое нам действительно нужно, — это количество долларов за WETH, а не обратное значение.
Десятичный множитель — это соотношение между десятичными множителями (opens in a new tab) для двух токенов.
1@dataclass(frozen=True)2class Quote:3 timestamp: str4 price: Decimal5 asset: strЭтот класс данных представляет собой котировку: цену определенного актива в данный момент времени. На данный момент поле asset не имеет значения, поскольку мы используем один пул и, следовательно, имеем один актив. Однако позже мы добавим больше активов.
1def read_token(address: str) -> ERC20Token:2 token = w3.eth.contract(address=address, abi=ERC20_ABI)3 symbol = token.functions.symbol().call()4 decimals = token.functions.decimals().call()56 return ERC20Token(7 address=address,8 symbol=symbol,9 decimals=decimals,10 contract=token11 )Показать всеЭта функция принимает адрес и возвращает информацию о контракте токена по этому адресу. Чтобы создать новый Contract Web3 (opens in a new tab), мы передаем адрес и ABI в w3.eth.contract.
1def read_pool(address: str) -> PoolInfo:2 pool_contract = w3.eth.contract(address=address, abi=POOL_ABI)3 token0Address = pool_contract.functions.token0().call()4 token1Address = pool_contract.functions.token1().call()5 token0 = read_token(token0Address)6 token1 = read_token(token1Address)78 return PoolInfo(9 address=address,10 asset=f"{token1.symbol}/{token0.symbol}",11 token0=token0,12 token1=token1,13 contract=pool_contract,14 decimal_factor=Decimal(10) ** Decimal(token0.decimals - token1.decimals)15 )Показать всеЭта функция возвращает все, что нам нужно о конкретном пуле (opens in a new tab). Синтаксис f"<string>" — это форматированная строка (opens in a new tab).
1def get_quote(pool: PoolInfo, block_number: int = None) -> Quote:Получить объект Quote. Значение по умолчанию для block_number — None (нет значения).
1 if block_number is None:2 block_number = w3.eth.block_numberЕсли номер блока не был указан, используется w3.eth.block_number, который является последним номером блока. Это синтаксис для оператора if (opens in a new tab).
Может показаться, что было бы лучше просто установить значение по умолчанию w3.eth.block_number, но это не очень хорошо работает, потому что это был бы номер блока на момент определения функции. В долго работающем агенте это стало бы проблемой.
1 block = w3.eth.get_block(block_number)2 price = pool.get_price(block_number)3 return Quote(4 timestamp=datetime.fromtimestamp(block.timestamp, timezone.utc).isoformat(),5 price=price.quantize(Decimal("0.01")),6 asset=pool.asset7 )Используйте библиотеку datetime (opens in a new tab), чтобы отформатировать ее в формат, читаемый для людей и больших языковых моделей (LLM). Используйте Decimal.quantize (opens in a new tab), чтобы округлить значение до двух десятичных знаков.
1def get_quotes(pool: PoolInfo, start_block: int, end_block: int, step: int) -> list[Quote]:В Python вы определяете список (opens in a new tab), который может содержать только определенный тип, используя list[<type>].
1 quotes = []2 for block in range(start_block, end_block + 1, step):В Python цикл for (opens in a new tab) обычно итерируется по списку. Список номеров блоков для поиска котировок получается из range (opens in a new tab).
1 quote = get_quote(pool, block)2 quotes.append(quote)3 return quotesДля каждого номера блока получите объект Quote и добавьте его в список quotes. Затем верните этот список.
1pool = read_pool(WETHUSDC_ADDRESS)2quotes = get_quotes(3 pool,4 w3.eth.block_number - 12*CYCLE_BLOCKS,5 w3.eth.block_number,6 CYCLE_BLOCKS7)89pprint(quotes)Показать всеЭто основной код скрипта. Прочитайте информацию о пуле, получите двенадцать котировок и pprint (opens in a new tab) их.
Создание подсказки
Далее нам нужно преобразовать этот список котировок в подсказку для LLM и получить ожидаемое будущее значение.
1git checkout 03-create-prompt2uv run agent.pyРезультатом будет подсказка для LLM, подобная этой:
1Given these quotes:2Asset: WETH/USDC3 2026-01-20T16:34 3016.214 .5 .6 .7 2026-02-01T17:49 2299.1089Asset: WBTC/WETH10 2026-01-20T16:34 29.8411 .12 .13 .14 2026-02-01T17:50 33.46151617What would you expect the value for WETH/USDC to be at time 2026-02-02T17:56?1819Provide your answer as a single number rounded to two decimal places,20without any other text.Показать всеОбратите внимание, что здесь есть котировки для двух активов, WETH/USDC и WBTC/WETH. Добавление котировок из другого актива может повысить точность прогноза.
Как выглядит подсказка
Эта подсказка содержит три раздела, которые довольно часто встречаются в подсказках для LLM.
-
Информация. LLM обладают большим объемом информации, полученной в ходе обучения, но обычно у них нет самой последней. Именно по этой причине нам нужно получить здесь последние котировки. Добавление информации в подсказку называется поисково-дополненной генерацией (RAG) (opens in a new tab).
-
Собственно вопрос. Это то, что мы хотим знать.
-
Инструкции по форматированию вывода. Обычно LLM дает нам оценку с объяснением, как она была получена. Это лучше для людей, но компьютерной программе нужна только суть.
Объяснение кода
Вот новый код.
1from datetime import datetime, timezone, timedeltaНам нужно предоставить LLM время, для которого мы хотим получить оценку. Чтобы получить время «n минут/часов/дней» в будущем, мы используем класс timedelta (opens in a new tab).
1# Адреса пулов, которые мы считываем2WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")3WETHWBTC_ADDRESS = Web3.to_checksum_address("0xCBCdF9626bC03E24f779434178A73a0B4bad62eD")У нас есть два пула, которые нам нужно прочитать.
1@dataclass(frozen=True)2class PoolInfo:3 .4 .5 .6 reverse: bool = False78 def get_price(self, block: int) -> Decimal:9 assert block <= w3.eth.block_number, "Block is in the future"10 sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])11 raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2 # (token1 per token0)12 if self.reverse:13 return 1/(raw_price * self.decimal_factor)14 else:15 return raw_price * self.decimal_factorПоказать всеВ пуле WETH/USDC мы хотим знать, сколько token0 (USDC) нам нужно, чтобы купить один token1 (WETH). В пуле WETH/WBTC мы хотим знать, сколько token1 (WETH) нам нужно, чтобы купить один token0 (WBTC, что является обернутым Bitcoin). Нам нужно отслеживать, нужно ли инвертировать соотношение пула.
1def read_pool(address: str, reverse: bool = False) -> PoolInfo:2 .3 .4 .56 return PoolInfo(7 .8 .9 .1011 asset= f"{token1.symbol}/{token0.symbol}" if reverse else f"{token0.symbol}/{token1.symbol}",12 reverse=reverse13 )Показать всеЧтобы знать, нужно ли инвертировать пул, мы должны получить это как входные данные для read_pool. Кроме того, символ актива должен быть настроен правильно.
Синтаксис <a> if <b> else <c> является эквивалентом Python тернарного условного оператора (opens in a new tab), который в C-подобном языке был бы <b> ? <a> : <c>.
1def format_quotes(quotes: list[Quote]) -> str:2 result = f"Asset: {quotes[0].asset}\n"3 for quote in quotes:4 result += f"\t{quote.timestamp[0:16]} {quote.price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}\n"5 return resultЭта функция строит строку, которая форматирует список объектов Quote, предполагая, что все они относятся к одному и тому же активу.
1def make_prompt(quotes: list[list[Quote]], expected_time: str, asset: str) -> str:2 return f"""В Python многострочные строковые литералы (opens in a new tab) пишутся как """ .... """.
1Given these quotes:2{3 functools.reduce(lambda acc, q: acc + '\n' + q,4 map(lambda q: format_quotes(q), quotes))5}Здесь мы используем шаблон MapReduce (opens in a new tab) для генерации строки для каждого списка котировок с помощью format_quotes, а затем объединяем их в одну строку для использования в подсказке.
1What would you expect the value for {asset} to be at time {expected_time}?23Provide your answer as a single number rounded to two decimal places,4without any other text.5 """Остальная часть подсказки соответствует ожиданиям.
1wethusdc_pool = read_pool(WETHUSDC_ADDRESS, True)2wethusdc_quotes = get_quotes(3 wethusdc_pool,4 w3.eth.block_number - 12*CYCLE_BLOCKS,5 w3.eth.block_number,6 CYCLE_BLOCKS,7)89wethwbtc_pool = read_pool(WETHWBTC_ADDRESS)10wethwbtc_quotes = get_quotes(11 wethwbtc_pool,12 w3.eth.block_number - 12*CYCLE_BLOCKS,13 w3.eth.block_number,14 CYCLE_BLOCKS15)Показать всеПросмотрите два пула и получите котировки из обоих.
1future_time = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()[0:16]23print(make_prompt(wethusdc_quotes + wethwbtc_quotes, future_time, wethusdc_pool.asset))Определите будущий момент времени, для которого мы хотим получить оценку, и создайте подсказку.
Взаимодействие с LLM
Далее мы запрашиваем реальную LLM и получаем ожидаемое будущее значение. Я написал эту программу с использованием OpenAI, поэтому, если вы хотите использовать другого провайдера, вам нужно будет ее скорректировать.
-
Получите аккаунт OpenAI (opens in a new tab)
-
Пополните счет (opens in a new tab) — минимальная сумма на момент написания составляет 5 долларов
-
В командной строке экспортируйте ключ API, чтобы ваша программа могла его использовать
1export OPENAI_API_KEY=sk-<the rest of the key goes here> -
Получите и запустите агент
1git checkout 04-interface-llm2uv run agent.py
Вот новый код.
1from openai import OpenAI23open_ai = OpenAI() # Клиент считывает переменную окружения OPENAI_API_KEYИмпортируйте и создайте экземпляр API OpenAI.
1response = open_ai.chat.completions.create(2 model="gpt-4-turbo",3 messages=[4 {"role": "user", "content": prompt}5 ],6 temperature=0.0,7 max_tokens=16,8)Вызовите API OpenAI (open_ai.chat.completions.create) для создания ответа.
1expected_price = Decimal(response.choices[0].message.content.strip())2current_price = wethusdc_quotes[-1].price34print ("Current price:", wethusdc_quotes[-1].price)5print(f"In {future_time}, expected price: {expected_price} USD")67if (expected_price > current_price):8 print(f"Buy, I expect the price to go up by {expected_price - current_price} USD")9else:10 print(f"Sell, I expect the price to go down by {current_price - expected_price} USD")Показать всеВыведите цену и дайте рекомендацию на покупку или продажу.
Тестирование прогнозов
Теперь, когда мы можем генерировать прогнозы, мы также можем использовать исторические данные для оценки, создаем ли мы полезные прогнозы.
1uv run test-predictor.pyОжидаемый результат подобен этому:
1Prediction for 2026-01-05T19:50: predicted 3138.93 USD, real 3218.92 USD, error 79.99 USD2Prediction for 2026-01-06T19:56: predicted 3243.39 USD, real 3221.08 USD, error 22.31 USD3Prediction for 2026-01-07T20:02: predicted 3223.24 USD, real 3146.89 USD, error 76.35 USD4Prediction for 2026-01-08T20:11: predicted 3150.47 USD, real 3092.04 USD, error 58.43 USD5.6.7.8Prediction for 2026-01-31T22:33: predicted 2637.73 USD, real 2417.77 USD, error 219.96 USD9Prediction for 2026-02-01T22:41: predicted 2381.70 USD, real 2318.84 USD, error 62.86 USD10Prediction for 2026-02-02T22:49: predicted 2234.91 USD, real 2349.28 USD, error 114.37 USD11Mean prediction error over 29 predictions: 83.87103448275862068965517241 USD12Mean change per recommendation: 4.787931034482758620689655172 USD13Standard variance of changes: 104.42 USD14Profitable days: 51.72%15Losing days: 48.28%Показать всеБольшая часть тестера идентична агенту, но вот новые или измененные части.
1CYCLES_FOR_TEST = 40 # Для бэктеста, сколько циклов мы тестируем23# Получить много котировок4wethusdc_pool = read_pool(WETHUSDC_ADDRESS, True)5wethusdc_quotes = get_quotes(6 wethusdc_pool,7 w3.eth.block_number - CYCLE_BLOCKS*CYCLES_FOR_TEST,8 w3.eth.block_number,9 CYCLE_BLOCKS,10)1112wethwbtc_pool = read_pool(WETHWBTC_ADDRESS)13wethwbtc_quotes = get_quotes(14 wethwbtc_pool,15 w3.eth.block_number - CYCLE_BLOCKS*CYCLES_FOR_TEST,16 w3.eth.block_number,17 CYCLE_BLOCKS18)Показать всеМы смотрим на CYCLES_FOR_TEST (здесь указано 40) дней назад.
1# Создать прогнозы и проверить их на реальной истории23total_error = Decimal(0)4changes = []Нас интересуют два типа ошибок. Первая, total_error, — это просто сумма ошибок, допущенных предсказателем.
Чтобы понять вторую, changes, нам нужно вспомнить о цели агента. Его цель не предсказать соотношение WETH/USDC (цену ETH). Его цель — выдавать рекомендации о продаже и покупке. Если текущая цена составляет 2000 $, и он предсказывает 2010 $ на завтра, мы не возражаем, если фактический результат будет 2020 $, и мы заработаем дополнительные деньги. Но мы возражаем, если он предсказал 2010 $, и купил ETH на основе этой рекомендации, а цена упала до 1990 $.
1for index in range(0,len(wethusdc_quotes)-CYCLES_BACK):Мы можем рассматривать только те случаи, когда доступна полная история (значения, используемые для прогноза, и реальное значение для сравнения). Это означает, что самый новый случай должен был начаться CYCLES_BACK назад.
1 wethusdc_slice = wethusdc_quotes[index:index+CYCLES_BACK]2 wethwbtc_slice = wethwbtc_quotes[index:index+CYCLES_BACK]Используйте срезы (opens in a new tab), чтобы получить то же количество образцов, которое использует агент. Код между этим и следующим сегментом — это тот же код для получения прогноза, который есть в агенте.
1 predicted_price = Decimal(response.choices[0].message.content.strip())2 real_price = wethusdc_quotes[index+CYCLES_BACK].price3 prediction_time_price = wethusdc_quotes[index+CYCLES_BACK-1].priceПолучите предсказанную цену, реальную цену и цену на момент прогноза. Нам нужна цена на момент прогноза, чтобы определить, была ли рекомендация купить или продать.
1 error = abs(predicted_price - real_price)2 total_error += error3 print (f"Prediction for {prediction_time}: predicted {predicted_price} USD, real {real_price} USD, error {error} USD")Вычислите ошибку и добавьте ее к общей сумме.
1 recomended_action = 'buy' if predicted_price > prediction_time_price else 'sell'2 price_increase = real_price - prediction_time_price3 changes.append(price_increase if recomended_action == 'buy' else -price_increase)Для changes мы хотим получить денежное влияние от покупки или продажи одного ETH. Поэтому сначала нам нужно определить рекомендацию, затем оценить, как изменилась фактическая цена, и принесла ли рекомендация деньги (положительное изменение) или стоила денег (отрицательное изменение).
1print (f"Mean prediction error over {len(wethusdc_quotes)-CYCLES_BACK} predictions: {total_error / Decimal(len(wethusdc_quotes)-CYCLES_BACK)} USD")23length_changes = Decimal(len(changes))4mean_change = sum(changes, Decimal(0)) / length_changes5print (f"Mean change per recommendation: {mean_change} USD")6var = sum((x - mean_change) ** 2 for x in changes) / length_changes7print (f"Standard variance of changes: {var.sqrt().quantize(Decimal("0.01"))} USD")Сообщите о результатах.
1print (f"Profitable days: {len(list(filter(lambda x: x > 0, changes)))/length_changes:.2%}")2print (f"Losing days: {len(list(filter(lambda x: x < 0, changes)))/length_changes:.2%}")Используйте filter (opens in a new tab), чтобы подсчитать количество прибыльных дней и количество убыточных дней. Результатом является объект-фильтр, который нам нужно преобразовать в список, чтобы получить его длину.
Отправка транзакций
Теперь нам нужно фактически отправить транзакции. Однако я не хочу тратить реальные деньги на этом этапе, пока система не доказала свою эффективность. Вместо этого мы создадим локальный форк основной сети и будем «торговать» в этой сети.
Вот шаги по созданию локального форка и включению торговли.
-
Установите Foundry (opens in a new tab)
-
Запустите
anvil(opens in a new tab)1anvil --fork-url https://eth.drpc.org --block-time 12anvilслушает на URL по умолчанию для Foundry, http://localhost:8545 (opens in a new tab), поэтому нам не нужно указывать URL для командыcast(opens in a new tab), которую мы используем для манипулирования блокчейном. -
При работе в
anvilесть десять тестовых аккаунтов с ETH — установите переменные окружения для первого1PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff802ADDRESS=`cast wallet address $PRIVATE_KEY` -
Это контракты, которые нам нужно использовать.
SwapRouter(opens in a new tab) — это контракт Uniswap v3, который мы используем для фактической торговли. Мы могли бы торговать напрямую через пул, но так гораздо проще.Две нижние переменные — это пути Uniswap v3, необходимые для обмена между WETH и USDC.
1WETH_ADDRESS=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc22USDC_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB483POOL_ADDRESS=0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f56404SWAP_ROUTER=0xE592427A0AEce92De3Edee1F18E0157C058615645WETH_TO_USDC=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB486USDC_TO_WETH=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 -
На каждом из тестовых аккаунтов есть 10 000 ETH. Используйте контракт WETH, чтобы обернуть 1000 ETH и получить 1000 WETH для торговли.
1cast send $WETH_ADDRESS "deposit()" --value 1000ether --private-key $PRIVATE_KEY -
Используйте
SwapRouter, чтобы обменять 500 WETH на USDC.1cast send $WETH_ADDRESS "approve(address,uint256)" $SWAP_ROUTER 500ether --private-key $PRIVATE_KEY2MAXINT=`cast max-int uint256`3cast send $SWAP_ROUTER \4 "exactInput((bytes,address,uint256,uint256,uint256))" \5 "($WETH_TO_USDC,$ADDRESS,$MAXINT,500ether,1000000)" \6 --private-key $PRIVATE_KEYВызов
approveсоздает разрешение, которое позволяетSwapRouterтратить некоторые из наших токенов. Контракты не могут отслеживать события, поэтому, если мы переведем токены непосредственно на контрактSwapRouter, он не узнает, что ему заплатили. Вместо этого мы разрешаем контрактуSwapRouterпотратить определенную сумму, а затемSwapRouterделает это. Это делается через функцию, вызываемуюSwapRouter, поэтому он знает, была ли она успешной. -
Убедитесь, что у вас достаточно обоих токенов.
1cast call $WETH_ADDRESS "balanceOf(address)" $ADDRESS | cast from-wei2echo `cast call $USDC_ADDRESS "balanceOf(address)" $ADDRESS | cast to-dec`/10^6 | bc
Теперь, когда у нас есть WETH и USDC, мы можем запустить агент.
1git checkout 05-trade2uv run agent.pyВывод будет выглядеть примерно так:
1(ai-trading-agent) qbzzt@Ori-Cloudnomics:~/260215-ai-agent$ uv run agent.py2Current price: 1843.163In 2026-02-06T23:07, expected price: 1724.41 USD4Account balances before trade:5USDC Balance: 927301.5782726WETH Balance: 5007Sell, I expect the price to go down by 118.75 USD8Approve transaction sent: 74e367ddbb407c1aaf567d87aa5863049991b1d2aa092b6b85195d925e2bd41f9Approve transaction mined.10Sell transaction sent: fad1bcf938585c9e90364b26ac7a80eea9efd34c37e5db81e58d7655bcae28bf11Sell transaction mined.12Account balances after trade:13USDC Balance: 929143.79711614WETH Balance: 499Показать всеЧтобы использовать его на самом деле, вам понадобятся несколько незначительных изменений.
- В строке 14 измените
MAINNET_URLна реальную точку доступа, напримерhttps://eth.drpc.org - В строке 28 измените
PRIVATE_KEYна ваш собственный приватный ключ - Если вы не очень богаты и не можете покупать или продавать 1 ETH каждый день для непроверенного агента, вы можете изменить строку 29, чтобы уменьшить
WETH_TRADE_AMOUNT
Объяснение кода
Вот новый код.
1SWAP_ROUTER_ADDRESS=Web3.to_checksum_address("0xE592427A0AEce92De3Edee1F18E0157C05861564")2WETH_TO_USDC=bytes.fromhex("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")3USDC_TO_WETH=bytes.fromhex("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")4PRIVATE_KEY="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"Те же переменные, что мы использовали на шаге 4.
1WETH_TRADE_AMOUNT=1Сумма для торговли.
1ERC20_ABI = [2 { "name": "symbol", ... },3 { "name": "decimals", ... },4 { "name": "balanceOf", ...},5 { "name": "approve", ...}6]Для фактической торговли нам нужна функция approve. Мы также хотим показать балансы до и после, поэтому нам также нужна balanceOf.
1SWAP_ROUTER_ABI = [2 { "name": "exactInput", ...},3]В ABI SwapRouter нам нужен только exactInput. Есть связанная функция, exactOutput, которую мы могли бы использовать для покупки ровно одного WETH, но для простоты мы просто используем exactInput в обоих случаях.
1account = w3.eth.account.from_key(PRIVATE_KEY)2swap_router = w3.eth.contract(3 address=SWAP_ROUTER_ADDRESS,4 abi=SWAP_ROUTER_ABI5)Определения Web3 для account (opens in a new tab) и контракта SwapRouter.
1def txn_params() -> dict:2 return {3 "from": account.address,4 "value": 0,5 "gas": 300000,6 "nonce": w3.eth.get_transaction_count(account.address),7 }Параметры транзакции. Нам нужна здесь функция, потому что nonce (opens in a new tab) должен меняться каждый раз.
1def approve_token(contract: Contract, amount: int):Утвердите разрешение на токены для SwapRouter.
1 txn = contract.functions.approve(SWAP_ROUTER_ADDRESS, amount).build_transaction(txn_params())2 signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)3 tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)Вот как мы отправляем транзакцию в Web3. Сначала мы используем объект Contract (opens in a new tab) для создания транзакции. Затем мы используем web3.eth.account.sign_transaction (opens in a new tab) для подписи транзакции, используя PRIVATE_KEY. Наконец, мы используем w3.eth.send_raw_transaction (opens in a new tab) для отправки транзакции.
1 print(f"Approve transaction sent: {tx_hash.hex()}")2 w3.eth.wait_for_transaction_receipt(tx_hash)3 print("Approve transaction mined.")w3.eth.wait_for_transaction_receipt (opens in a new tab) ожидает, пока транзакция будет включена в блок. При необходимости возвращается квитанция.
1SELL_PARAMS = {2 "path": WETH_TO_USDC,3 "recipient": account.address,4 "deadline": 2**256 - 1,5 "amountIn": WETH_TRADE_AMOUNT * 10 ** wethusdc_pool.token1.decimals,6 "amountOutMinimum": 0,7}Это параметры при продаже WETH.
1def make_buy_params(quote: Quote) -> dict:2 return {3 "path": USDC_TO_WETH,4 "recipient": account.address,5 "deadline": 2**256 - 1,6 "amountIn": int(quote.price*WETH_TRADE_AMOUNT) * 10**wethusdc_pool.token0.decimals,7 "amountOutMinimum": 0,8 }В отличие от SELL_PARAMS, параметры покупки могут меняться. Входная сумма — это стоимость 1 WETH, доступная в quote.
1def buy(quote: Quote):2 buy_params = make_buy_params(quote)3 approve_token(wethusdc_pool.token0.contract, buy_params["amountIn"])4 txn = swap_router.functions.exactInput(buy_params).build_transaction(txn_params())5 signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)6 tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)7 print(f"Buy transaction sent: {tx_hash.hex()}")8 w3.eth.wait_for_transaction_receipt(tx_hash)9 print("Buy transaction mined.")101112def sell():13 approve_token(wethusdc_pool.token1.contract,14 WETH_TRADE_AMOUNT * 10**wethusdc_pool.token1.decimals)15 txn = swap_router.functions.exactInput(SELL_PARAMS).build_transaction(txn_params())16 signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)17 tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)18 print(f"Sell transaction sent: {tx_hash.hex()}")19 w3.eth.wait_for_transaction_receipt(tx_hash)20 print("Sell transaction mined.")Показать всеФункции buy() и sell() почти идентичны. Сначала мы утверждаем достаточное разрешение для SwapRouter, а затем вызываем его с правильным путем и суммой.
1def balances():2 token0_balance = wethusdc_pool.token0.contract.functions.balanceOf(account.address).call()3 token1_balance = wethusdc_pool.token1.contract.functions.balanceOf(account.address).call()45 print(f"{wethusdc_pool.token0.symbol} Balance: {Decimal(token0_balance) / Decimal(10 ** wethusdc_pool.token0.decimals)}")6 print(f"{wethusdc_pool.token1.symbol} Balance: {Decimal(token1_balance) / Decimal(10 ** wethusdc_pool.token1.decimals)}")Сообщите о балансах пользователя в обеих валютах.
1print("Account balances before trade:")2balances()34if (expected_price > current_price):5 print(f"Buy, I expect the price to go up by {expected_price - current_price} USD")6 buy(wethusdc_quotes[-1])7else:8 print(f"Sell, I expect the price to go down by {current_price - expected_price} USD")9 sell()1011print("Account balances after trade:")12balances()Показать всеЭтот агент в настоящее время работает только один раз. Однако вы можете изменить его для непрерывной работы либо запустив его из crontab (opens in a new tab), либо обернув строки 368-400 в цикл и используя time.sleep (opens in a new tab), чтобы дождаться времени для следующего цикла.
Возможные улучшения
Это не полная производственная версия; это лишь пример для обучения основам. Вот несколько идей для улучшений.
Более умная торговля
Есть два важных факта, которые агент игнорирует при принятии решения о действиях.
- Величина ожидаемого изменения. Агент продает фиксированное количество
WETH, если ожидается снижение цены, независимо от величины этого снижения. Вероятно, было бы лучше игнорировать незначительные изменения и продавать в зависимости от того, на сколько мы ожидаем снижения цены. - Текущий портфель. Если 10% вашего портфеля находятся в WETH, и вы думаете, что цена вырастет, вероятно, имеет смысл купить больше. Но если 90% вашего портфеля находятся в WETH, вы можете быть достаточно подвержены риску, и нет необходимости покупать больше. Обратное верно, если вы ожидаете снижения цены.
Что, если вы хотите сохранить свою торговую стратегию в секрете?
Поставщики ИИ могут видеть запросы, которые вы отправляете их LLM, что может раскрыть гениальную торговую систему, разработанную вами с помощью вашего агента. Торговая система, которую использует слишком много людей, бесполезна, потому что слишком много людей пытаются купить, когда вы хотите купить (и цена растет), и пытаются продать, когда вы хотите продать (и цена падает).
Вы можете запустить LLM локально, например, используя LM-Studio (opens in a new tab), чтобы избежать этой проблемы.
От бота с ИИ к агенту с ИИ
Можно с полным основанием утверждать, что это бот с ИИ, а не агент с ИИ. Он реализует относительно простую стратегию, которая опирается на предопределенную информацию. Мы можем включить самосовершенствование, например, предоставив список пулов Uniswap v3 и их последние значения и спросив, какая комбинация имеет наилучшую прогностическую ценность.
Защита от проскальзывания
В настоящее время нет защиты от проскальзывания (opens in a new tab). Если текущая котировка составляет 2000 $, а ожидаемая цена — 2100 $, агент купит. Однако, если до того, как агент купит, стоимость поднимется до 2200 $, покупать больше не имеет смысла.
Чтобы реализовать защиту от проскальзывания, укажите значение amountOutMinimum в строках 325 и 334 файла agent.py (opens in a new tab).
Заключение
Надеюсь, теперь вы знаете достаточно, чтобы начать работать с агентами ИИ. Это не исчерпывающий обзор предмета; этому посвящены целые книги, но этого достаточно, чтобы вы могли начать. Удачи!
Больше моих работ смотрите здесь (opens in a new tab).
Последнее обновление страницы: 10 февраля 2026 г.