Створіть власного торгового AI-агента на Ethereum
У цьому посібнику ви дізнаєтеся, як створити простого торгового AI-агента. Цей агент працює за такими кроками:
- Зчитування поточних і минулих цін токена, а також іншої потенційно релевантної інформації
- Створення запиту з цією інформацією, а також довідкової інформації для пояснення її можливої релевантності
- Надсилання запиту й отримання прогнозованої ціни у відповідь
- Здійснення угоди на основі рекомендації
- Очікування та повторення
Цей агент демонструє, як зчитувати інформацію, перетворювати її на запит, що дає корисну відповідь, і використовувати цю відповідь. Усе це — кроки, необхідні для AI-агента. Цей агент реалізовано на Python, оскільки це найпоширеніша мова, що використовується в ШІ.
Навіщо це робити?
Автоматизовані торгові агенти дозволяють розробникам вибирати й виконувати торгову стратегію. AI-агенти уможливлюють складніші та динамічніші торгові стратегії, потенційно використовуючи інформацію й алгоритми, які розробник навіть не розглядав для використання.
Інструменти
У цьому посібнику для отримання котирувань і торгівлі використовуються 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 Python для Ethereum. Вона досить проста у використанні.
Торгівля на блокчейні
Існує багато розподілених бірж (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-started\ncd 260215-ai-agent -
Установіть
uv(opens in a new tab). Команда у вашій системі може відрізнятися.1pipx install uv -
Завантажте бібліотеки.
1uv sync -
Активуйте віртуальне середовище.
1source .venv/bin/activate -
Щоб перевірити, чи Python і Web3 працюють правильно, запустіть
python3і надайте йому цю програму. Ви можете ввести її в командному рядку>>>; створювати файл не потрібно.1from web3 import Web3\nMAINNET_URL = \"https://eth.drpc.org\"\nw3 = Web3(Web3.HTTPProvider(MAINNET_URL))\nw3.eth.block_number\nquit()
Читання з блокчейну
Наступний крок — читання з блокчейну. Для цього вам потрібно перейти до гілки 02-read-quote, а потім використати uv для запуску програми.
1git checkout 02-read-quote\nuv run agent.pyВи повинні отримати список об’єктів Quote, кожен з яких містить часову мітку, ціну та актив (наразі це завжди WETH/USDC).
Ось пояснення рядок за рядком.
1from web3 import Web3\nfrom web3.contract import Contract\nfrom decimal import Decimal, ROUND_HALF_UP\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\nfrom pprint import pprint\nimport time\nimport functools\nimport 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 = 12\nMINUTE_BLOCKS = int(60 / BLOCK_TIME_SECONDS)\nHOUR_BLOCKS = MINUTE_BLOCKS * 60\nDAY_BLOCKS = HOUR_BLOCKS * 24Блок в основній мережі Ethereum зазвичай з’являється кожні дванадцять секунд, тож це кількість блоків, які, як ми очікуємо, з’являться за певний період часу. Зауважте, що це не точне число. Коли пропонувач блоку не працює, цей блок пропускається, і час до наступного блоку становить 24 секунди. Якби ми хотіли отримати точний блок для часової мітки, ми б використали двійковий пошук (opens in a new tab). Однак для наших цілей цього достатньо. Прогнозування майбутнього — не точна наука.
1CYCLE_BLOCKS = DAY_BLOCKSРозмір циклу. Ми переглядаємо котирування раз за цикл і намагаємося оцінити вартість наприкінці наступного циклу.
1# Адреса пулу, з якого ми читаємо\nWETHUSDC_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 = [\n { \"name\": \"slot0\", ... },\n { \"name\": \"token0\", ... },\n { \"name\": \"token1\", ... },\n]\n\nERC20_ABI = [\n { \"name\": \"symbol\", ... },\n { \"name\": \"decimals\", ... }\n]Це ABI (opens in a new tab) для двох контрактів, з якими нам потрібно взаємодіяти. Щоб код був стислим, ми включаємо лише ті функції, які нам потрібно викликати.
1w3 = Web3(Web3.HTTPProvider(MAINNET_URL))Ініціюйте бібліотеку Web3 (opens in a new tab) і підключіться до вузла Ethereum.
1@dataclass(frozen=True)\nclass ERC20Token:\n address: str\n symbol: str\n decimals: int\n 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)\nclass PoolInfo:\n address: str\n token0: ERC20Token\n token1: ERC20Token\n contract: Contract\n asset: str\n 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, \"Блок із майбутнього\"Якби ми могли читати майбутнє, нам не знадобився б ШІ для торгівлі.
1 sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])Синтаксис виклику функції на EVM з Web3 такий: <об'єкт контракту>.functions.<назва функції>().call(<параметри>). Параметрами можуть бути параметри функції EVM (якщо вони є; тут їх немає) або [іменовані параметри](https://en.wikipedia.org/wiki/Named_parameter) для зміни поведінки блокчейну. Тут ми використовуємо один з них, block_identifier`, щоб указати номер блоку, у якому ми хочемо виконати операцію.
Результатом є ця структура у формі масиву (opens in a new tab). Перше значення — це функція обмінного курсу між двома токенами.
1 raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2Щоб зменшити кількість обчислень в мережі, Uniswap v3 зберігає не фактичний коефіцієнт обміну, а його квадратний корінь. Оскільки EVM не підтримує математику з рухомою комою або дроби, замість фактичного значення відповідь —
1 # (токен1 за токен0)\n 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)\nclass Quote:\n timestamp: str\n price: Decimal\n asset: strЦей клас даних представляє котирування: ціну певного активу в певний момент часу. На цьому етапі поле asset не має значення, оскільки ми використовуємо один пул і, отже, маємо один актив. Однак пізніше ми додамо більше активів.
1def read_token(address: str) -> ERC20Token:\n token = w3.eth.contract(address=address, abi=ERC20_ABI)\n symbol = token.functions.symbol().call()\n decimals = token.functions.decimals().call()\n\n return ERC20Token(\n address=address,\n symbol=symbol,\n decimals=decimals,\n contract=token\n )Ця функція приймає адресу й повертає інформацію про контракт токена за цією адресою. Щоб створити новий Contract Web3 (opens in a new tab), ми надаємо адресу й ABI для w3.eth.contract.
1def read_pool(address: str) -> PoolInfo:\n pool_contract = w3.eth.contract(address=address, abi=POOL_ABI)\n token0Address = pool_contract.functions.token0().call()\n token1Address = pool_contract.functions.token1().call()\n token0 = read_token(token0Address)\n token1 = read_token(token1Address)\n\n return PoolInfo(\n address=address,\n asset=f\"{token1.symbol}/{token0.symbol}\",\n token0=token0,\n token1=token1,\n contract=pool_contract,\n decimal_factor=Decimal(10) ** Decimal(token0.decimals - token1.decimals)\n )Ця функція повертає все, що нам потрібно про конкретний пул (opens in a new tab). Синтаксис f\"<рядок>\" — це форматований рядок (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:\n 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)\n price = pool.get_price(block_number)\n return Quote(\n timestamp=datetime.fromtimestamp(block.timestamp, timezone.utc).isoformat(),\n price=price.quantize(Decimal(\"0.01\")),\n asset=pool.asset\n )Використовуйте бібліотеку 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[<тип>].
1 quotes = []\n 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)\n quotes.append(quote)\n return quotesДля кожного номера блоку отримайте об’єкт Quote і додайте його до списку quotes. Потім поверніть цей список.
1pool = read_pool(WETHUSDC_ADDRESS)\nquotes = get_quotes(\n pool,\n w3.eth.block_number - 12*CYCLE_BLOCKS,\n w3.eth.block_number,\n CYCLE_BLOCKS\n)\n\npprint(quotes)Це основний код скрипту. Прочитайте інформацію про пул, отримайте дванадцять котирувань і виведіть їх за допомогою pprint (opens in a new tab).
Створення запиту
Далі нам потрібно перетворити цей список котирувань на запит для LLM та отримати очікуване майбутнє значення.
1git checkout 03-create-prompt\nuv run agent.pyТепер вивід буде запитом до LLM, схожим на:
1З огляду на ці котирування:\nАктив: WETH/USDC\n 2026-01-20T16:34 3016.21\n .\n .\n .\n 2026-02-01T17:49 2299.10\n\nАктив: WBTC/WETH\n 2026-01-20T16:34 29.84\n .\n .\n .\n 2026-02-01T17:50 33.46\n\n\nЯке значення для WETH/USDC ви очікуєте на момент 2026-02-02T17:56?\n\nНадайте відповідь у вигляді одного числа, заокругленого до двох знаків після коми,\nбез будь-якого іншого тексту.Зверніть увагу, що тут є котирування для двох активів, 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# Адреси пулів, які ми читаємо\nWETHUSDC_ADDRESS = Web3.to_checksum_address(\"0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640\")\nWETHWBTC_ADDRESS = Web3.to_checksum_address(\"0xCBCdF9626bC03E24f779434178A73a0B4bad62eD\")У нас є два пули, які нам потрібно прочитати.
1@dataclass(frozen=True)\nclass PoolInfo:\n .\n .\n .\n reverse: bool = False\n\n def get_price(self, block: int) -> Decimal:\n assert block <= w3.eth.block_number, \"Блок із майбутнього\"\n sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])\n raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2 # (токен1 за токен0)\n if self.reverse:\n return 1/(raw_price * self.decimal_factor)\n else:\n 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:\n .\n .\n .\n\n return PoolInfo(\n .\n .\n .\n\n asset= f\"{token1.symbol}/{token0.symbol}\" if reverse else f\"{token0.symbol}/{token1.symbol}\",\n reverse=reverse\n )Щоб знати, чи потрібно змінювати співвідношення пулу на обернене, ми отримуємо це як вхідні дані для read_pool. Крім того, символ активу має бути налаштований правильно.
Синтаксис <a> if <b> else <c> є еквівалентом Python тернарного умовного оператора (opens in a new tab), який у мові, що походить від C, виглядав би як <b> ? <a> : <c>.
1def format_quotes(quotes: list[Quote]) -> str:\n result = f\"Актив: {quotes[0].asset}\n\"\n for quote in quotes:\n result += f\"\t{quote.timestamp[0:16]} {quote.price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}\n\"\n return resultЦя функція створює рядок, який форматує список об’єктів Quote, припускаючи, що всі вони застосовуються до одного активу.
1def make_prompt(quotes: list[list[Quote]], expected_time: str, asset: str) -> str:\n return f\"\"\"У Python багаторядкові рядкові літерали (opens in a new tab) записуються як \"\"\".... \"\"\".
1З огляду на ці котирування:\n{\n functools.reduce(lambda acc, q: acc + '\n' + q,\n map(lambda q: format_quotes(q), quotes))\n}Тут ми використовуємо патерн MapReduce (opens in a new tab), щоб згенерувати рядок для кожного списку котирувань за допомогою format_quotes, а потім об’єднати їх в один рядок для використання в запиті.
1Яке значення для {asset} ви очікуєте на момент {expected_time}?\n\nНадайте відповідь у вигляді одного числа, заокругленого до двох знаків після коми,\nбез будь-якого іншого тексту.\n \"\"\"Решта запиту така, як і очікувалося.
1wethusdc_pool = read_pool(WETHUSDC_ADDRESS, True)\nwethusdc_quotes = get_quotes(\n wethusdc_pool,\n w3.eth.block_number - 12*CYCLE_BLOCKS,\n w3.eth.block_number,\n CYCLE_BLOCKS,\n)\n\nwethwbtc_pool = read_pool(WETHWBTC_ADDRESS)\nwethwbtc_quotes = get_quotes(\n wethwbtc_pool,\n w3.eth.block_number - 12*CYCLE_BLOCKS,\n w3.eth.block_number,\n CYCLE_BLOCKS\n)Перегляньте два пули й отримайте котирування з обох.
1future_time = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()[0:16]\n\nprint(make_prompt(wethusdc_quotes + wethwbtc_quotes, future_time, wethusdc_pool.asset))Визначте майбутній момент часу, для якого ми хочемо отримати оцінку, і створіть запит.
Взаємодія з LLM
Далі ми надсилаємо запит до фактичної LLM і отримуємо очікуване майбутнє значення. Я написав цю програму, використовуючи OpenAI, тому, якщо ви хочете використовувати іншого провайдера, вам потрібно буде її скоригувати.
-
Поповніть рахунок (opens in a new tab) — на момент написання статті мінімальна сума становить 5 доларів США
-
У командному рядку експортуйте ключ API, щоб ваша програма могла його використовувати
1export OPENAI_API_KEY=sk-<решта ключа вводиться сюди> -
Перейдіть до гілки та запустіть агент
1git checkout 04-interface-llm\nuv run agent.py
Ось новий код.
1from openai import OpenAI\n\nopen_ai = OpenAI() # Клієнт зчитує змінну середовища OPENAI_API_KEYІмпортуйте та створіть екземпляр OpenAI API.
1response = open_ai.chat.completions.create(\n model=\"gpt-4-turbo\",\n messages=[\n {\"role\": \"user\", \"content\": prompt}\n ],\n temperature=0.0,\n max_tokens=16,\n)Викличте OpenAI API (open_ai.chat.completions.create), щоб створити відповідь.
1expected_price = Decimal(response.choices[0].message.content.strip())\ncurrent_price = wethusdc_quotes[-1].price\n\nprint (\"Поточна ціна:\", wethusdc_quotes[-1].price)\nprint(f\"На {future_time} очікувана ціна: {expected_price} USD\")\n\nif (expected_price > current_price):\n print(f\"Купувати, я очікую, що ціна зросте на {expected_price - current_price} USD\")\nelse:\n print(f\"Продавати, я очікую, що ціна впаде на {current_price - expected_price} USD\")Виведіть ціну та надайте рекомендацію щодо купівлі чи продажу.
Тестування прогнозів
Тепер, коли ми можемо генерувати прогнози, ми також можемо використовувати історичні дані для оцінки, чи створюємо ми корисні прогнози.
1uv run test-predictor.pyОчікуваний результат схожий на:
1Прогноз на 2026-01-05T19:50: прогнозовано 3138.93 USD, реальна 3218.92 USD, помилка 79.99 USD\nПрогноз на 2026-01-06T19:56: прогнозовано 3243.39 USD, реальна 3221.08 USD, помилка 22.31 USD\nПрогноз на 2026-01-07T20:02: прогнозовано 3223.24 USD, реальна 3146.89 USD, помилка 76.35 USD\nПрогноз на 2026-01-08T20:11: прогнозовано 3150.47 USD, реальна 3092.04 USD, помилка 58.43 USD\n.\n.\n.\nПрогноз на 2026-01-31T22:33: прогнозовано 2637.73 USD, реальна 2417.77 USD, помилка 219.96 USD\nПрогноз на 2026-02-01T22:41: прогнозовано 2381.70 USD, реальна 2318.84 USD, помилка 62.86 USD\nПрогноз на 2026-02-02T22:49: прогнозовано 2234.91 USD, реальна 2349.28 USD, помилка 114.37 USD\nСередня помилка прогнозу за 29 прогнозів: 83.87103448275862068965517241 USD\nСередня зміна на рекомендацію: 4.787931034482758620689655172 USD\nСтандартне відхилення змін: 104.42 USD\nПрибуткові дні: 51.72%\nЗбиткові дні: 48.28%Більша частина тестера ідентична агенту, але ось частини, які є новими або зміненими.
1CYCLES_FOR_TEST = 40 # Для бектесту, скільки циклів ми тестуємо\n\n# Отримати багато котирувань\nwethusdc_pool = read_pool(WETHUSDC_ADDRESS, True)\nwethusdc_quotes = get_quotes(\n wethusdc_pool,\n w3.eth.block_number - CYCLE_BLOCKS*CYCLES_FOR_TEST,\n w3.eth.block_number,\n CYCLE_BLOCKS,\n)\n\nwethwbtc_pool = read_pool(WETHWBTC_ADDRESS)\nwethwbtc_quotes = get_quotes(\n wethwbtc_pool,\n w3.eth.block_number - CYCLE_BLOCKS*CYCLES_FOR_TEST,\n w3.eth.block_number,\n CYCLE_BLOCKS\n)Ми розглядаємо CYCLES_FOR_TEST (тут вказано 40) днів назад.
1# Створення прогнозів та їх перевірка на основі реальної історії\n\ntotal_error = Decimal(0)\nchanges = []Нас цікавлять два типи помилок. Перша, 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]\n wethwbtc_slice = wethwbtc_quotes[index:index+CYCLES_BACK]Використовуйте зрізи (opens in a new tab), щоб отримати таку ж кількість зразків, яку використовує агент. Код між цим і наступним сегментом — це той самий код для отримання прогнозу, який ми маємо в агенті.
1 predicted_price = Decimal(response.choices[0].message.content.strip())\n real_price = wethusdc_quotes[index+CYCLES_BACK].price\n prediction_time_price = wethusdc_quotes[index+CYCLES_BACK-1].priceОтримайте прогнозовану ціну, реальну ціну та ціну на момент прогнозування. Нам потрібна ціна на момент прогнозування, щоб визначити, чи була рекомендація купувати чи продавати.
1 error = abs(predicted_price - real_price)\n total_error += error\n print (f\"Прогноз на {prediction_time}: прогнозована ціна {predicted_price} USD, реальна ціна {real_price} USD, помилка {error} USD\")Визначте помилку та додайте її до загальної суми.
1 recomended_action = 'buy' if predicted_price > prediction_time_price else 'sell'\n price_increase = real_price - prediction_time_price\n changes.append(price_increase if recomended_action == 'buy' else -price_increase)Для changes нам потрібен грошовий ефект від купівлі або продажу одного ETH. Отже, спочатку нам потрібно визначити рекомендацію, потім оцінити, як змінилася фактична ціна, і чи принесла рекомендація гроші (позитивна зміна), чи призвела до збитків (негативна зміна).
1print (f\"Середня помилка прогнозування за {len(wethusdc_quotes)-CYCLES_BACK} прогнозів: {total_error / Decimal(len(wethusdc_quotes)-CYCLES_BACK)} USD\")\n\nlength_changes = Decimal(len(changes))\nmean_change = sum(changes, Decimal(0)) / length_changes\nprint (f\"Середня зміна на рекомендацію: {mean_change} USD\")\nvar = sum((x - mean_change) ** 2 for x in changes) / length_changes\nprint (f\"Стандартне відхилення змін: {var.sqrt().quantize(Decimal(\"0.01\"))} USD\")Повідомте результати.
1print (f\"Прибуткові дні: {len(list(filter(lambda x: x > 0, changes)))/length_changes:.2%}\")\nprint (f\"Збиткові дні: {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=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80\nADDRESS=`cast wallet address $PRIVATE_KEY` -
Це контракти, які нам потрібно використовувати.
SwapRouter(opens in a new tab) — це контракт Uniswap v3, який ми використовуємо для фактичної торгівлі. Ми могли б торгувати безпосередньо через пул, але так набагато простіше.Дві нижні змінні — це шляхи Uniswap v3, необхідні для обміну між WETH і USDC.
1WETH_ADDRESS=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\nUSDC_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\nPOOL_ADDRESS=0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640\nSWAP_ROUTER=0xE592427A0AEce92De3Edee1F18E0157C05861564\nWETH_TO_USDC=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\nUSDC_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_KEY\nMAXINT=`cast max-int uint256`\ncast send $SWAP_ROUTER \\\n \"exactInput((bytes,address,uint256,uint256,uint256))\" \\\n \"($WETH_TO_USDC,$ADDRESS,$MAXINT,500ether,1000000)\" \\\n --private-key $PRIVATE_KEYВиклик
approveстворює дозвіл, який дозволяєSwapRouterвитрачати деякі з наших токенів. Контракти не можуть відстежувати події, тому якщо ми перекажемо токени безпосередньо на контрактSwapRouter, він не дізнається, що йому заплатили. Натомість ми дозволяємо контрактуSwapRouterвитратити певну суму, а потімSwapRouterробить це. Це робиться за допомогою функції, що викликаєтьсяSwapRouter, тому він знає, чи був виклик успішним. -
Переконайтеся, що у вас достатньо обох токенів.
1cast call $WETH_ADDRESS \"balanceOf(address)\" $ADDRESS | cast from-wei\necho `cast call $USDC_ADDRESS \"balanceOf(address)\" $ADDRESS | cast to-dec`/10^6 | bc
Тепер, коли у нас є WETH та USDC, ми можемо фактично запустити агент.
1git checkout 05-trade\nuv run agent.pyВивід буде схожий на:
1(ai-trading-agent) qbzzt@Ori-Cloudnomics:~/260215-ai-agent$ uv run agent.py\nПоточна ціна: 1843.16\nНа 2026-02-06T23:07, очікувана ціна: 1724.41 USD\nБаланси облікового запису перед угодою:\nБаланс USDC: 927301.578272\nБаланс WETH: 500\nПродавати, я очікую, що ціна впаде на 118.75 USD\nНадіслана транзакція схвалення: 74e367ddbb407c1aaf567d87aa5863049991b1d2aa092b6b85195d925e2bd41f\nТранзакцію схвалення видобуто.\nНадіслано транзакцію продажу: fad1bcf938585c9e90364b26ac7a80eea9efd34c37e5db81e58d7655bcae28bf\nТранзакцію продажу видобуто.\nБаланси облікового запису після угоди:\nБаланс USDC: 929143.797116\nБаланс WETH: 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\")\nWETH_TO_USDC=bytes.fromhex(\"C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\")\nUSDC_TO_WETH=bytes.fromhex(\"A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\")\nPRIVATE_KEY=\"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80\"Ті самі змінні, які ми використовували на кроці 4.
1WETH_TRADE_AMOUNT=1Сума для торгівлі.
1ERC20_ABI = [\n { \"name\": \"symbol\", ... },\n { \"name\": \"decimals\", ... },\n { \"name\": \"balanceOf\", ...},\n { \"name\": \"approve\", ...}\n]Щоб фактично торгувати, нам потрібна функція approve. Ми також хочемо показувати баланси до та після, тому нам також потрібна balanceOf.
1SWAP_ROUTER_ABI = [\n { \"name\": \"exactInput\", ...},\n]В ABI SwapRouter нам потрібна лише exactInput. Існує пов'язана функція exactOutput, яку ми могли б використовувати, щоб купити рівно один WETH, але для простоти ми використовуємо exactInput в обох випадках.
1account = w3.eth.account.from_key(PRIVATE_KEY)\nswap_router = w3.eth.contract(\n address=SWAP_ROUTER_ADDRESS,\n abi=SWAP_ROUTER_ABI\n)Визначення Web3 для account (opens in a new tab) і контракту SwapRouter.
1def txn_params() -> dict:\n return {\n \"from\": account.address,\n \"value\": 0,\n \"gas\": 300000,\n \"nonce\": w3.eth.get_transaction_count(account.address),\n }Параметри транзакції. Тут нам потрібна функція, оскільки 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())\n signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)\n 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\"Надіслано транзакцію схвалення: {tx_hash.hex()}\")\n w3.eth.wait_for_transaction_receipt(tx_hash)\n print(\"Транзакцію схвалення видобуто.\")w3.eth.wait_for_transaction_receipt (opens in a new tab) очікує, доки транзакцію не буде видобуто. За потреби він повертає квитанцію.
1SELL_PARAMS = {\n \"path\": WETH_TO_USDC,\n \"recipient\": account.address,\n \"deadline\": 2**256 - 1,\n \"amountIn\": WETH_TRADE_AMOUNT * 10 ** wethusdc_pool.token1.decimals,\n \"amountOutMinimum\": 0,\n}Це параметри для продажу WETH.
1def make_buy_params(quote: Quote) -> dict:\n return {\n \"path\": USDC_TO_WETH,\n \"recipient\": account.address,\n \"deadline\": 2**256 - 1,\n \"amountIn\": int(quote.price*WETH_TRADE_AMOUNT) * 10**wethusdc_pool.token0.decimals,\n \"amountOutMinimum\": 0,\n }На відміну від SELL_PARAMS, параметри купівлі можуть змінюватися. Вхідна сума — це вартість 1 WETH, доступна в quote.
1def buy(quote: Quote):\n buy_params = make_buy_params(quote)\n approve_token(wethusdc_pool.token0.contract, buy_params[\"amountIn\"])\n txn = swap_router.functions.exactInput(buy_params).build_transaction(txn_params())\n signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)\n tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)\n print(f\"Надіслано транзакцію купівлі: {tx_hash.hex()}\")\n w3.eth.wait_for_transaction_receipt(tx_hash)\n print(\"Транзакцію купівлі видобуто.\")\n\n\ndef sell():\n approve_token(wethusdc_pool.token1.contract,\n WETH_TRADE_AMOUNT * 10**wethusdc_pool.token1.decimals)\n txn = swap_router.functions.exactInput(SELL_PARAMS).build_transaction(txn_params())\n signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)\n tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)\n print(f\"Надіслано транзакцію продажу: {tx_hash.hex()}\")\n w3.eth.wait_for_transaction_receipt(tx_hash)\n print(\"Транзакцію продажу видобуто.\")Функції buy() та sell() майже ідентичні. Спочатку ми схвалюємо достатній дозвіл для SwapRouter, а потім викликаємо його з правильним шляхом і сумою.
1def balances():\n token0_balance = wethusdc_pool.token0.contract.functions.balanceOf(account.address).call()\n token1_balance = wethusdc_pool.token1.contract.functions.balanceOf(account.address).call()\n\n print(f\"{wethusdc_pool.token0.symbol} Баланс: {Decimal(token0_balance) / Decimal(10 ** wethusdc_pool.token0.decimals)}\")\n print(f\"{wethusdc_pool.token1.symbol} Баланс: {Decimal(token1_balance) / Decimal(10 ** wethusdc_pool.token1.decimals)}\")Повідомте про баланси користувача в обох валютах.
1print(\"Баланси облікового запису перед угодою:\")\nbalances()\n\nif (expected_price > current_price):\n print(f\"Купувати, я очікую, що ціна зросте на {expected_price - current_price} USD\")\n buy(wethusdc_quotes[-1])\nelse:\n print(f\"Продавати, я очікую, що ціна впаде на {current_price - expected_price} USD\")\n sell()\n\nprint(\"Баланси облікового запису після угоди:\")\nbalances()Наразі цей агент працює лише один раз. Однак ви можете змінити його для безперервної роботи, запустивши його з 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), щоб уникнути цієї проблеми.
Від AI-бота до AI-агента
Можна цілком обґрунтовано стверджувати, що це AI-бот, а не AI-агент. Він реалізує відносно просту стратегію, яка покладається на заздалегідь визначену інформацію. Ми можемо увімкнути самовдосконалення, наприклад, надавши список пулів Uniswap v3 та їхні останні значення й запитавши, яка комбінація має найкращу прогностичну цінність.
Захист від прослизання
Наразі немає захисту від прослизання (opens in a new tab). Якщо поточне котирування становить 2000 доларів США, а очікувана ціна — 2100 доларів США, агент купить. Однак, якщо до того, як агент купить, вартість зросте до 2200 доларів, купувати більше немає сенсу.
Щоб реалізувати захист від прослизання, вкажіть значення amountOutMinimum у рядках 325 і 334 файлу agent.py (opens in a new tab).
Висновок
Сподіваюся, тепер ви знаєте достатньо, щоб почати роботу з AI-агентами. Це не всеосяжний огляд теми; цьому присвячені цілі книги, але цього достатньо, щоб почати. Успіхів!
Більше моїх робіт дивіться тут (opens in a new tab).
Останні оновлення сторінки: 10 лютого 2026 р.