Stwórz własnego agenta handlowego AI na Ethereum
W tym samouczku dowiesz się, jak zbudować prostego agenta handlowego AI. Agent ten działa w oparciu o następujące kroki:
- Odczytanie bieżących i przeszłych cen tokena, a także innych potencjalnie istotnych informacji
- Zbudowanie zapytania z tymi informacjami, wraz z informacjami ogólnymi, aby wyjaśnić, w jaki sposób mogą być one istotne
- Przesłanie zapytania i otrzymanie prognozowanej ceny
- Handel w oparciu o rekomendację
- Oczekiwanie i powtórzenie
Ten agent pokazuje, jak odczytywać informacje, przekształcać je w zapytanie, które daje użyteczną odpowiedź i jak z tej odpowiedzi korzystać. Wszystkie te kroki są wymagane w przypadku agenta AI. Agent ten jest zaimplementowany w Pythonie, ponieważ jest to najpopularniejszy język używany w AI.
Po co to robić?
Zautomatyzowani agenci handlowi pozwalają deweloperom wybierać i realizować strategię handlową. Agenci AI pozwalają na bardziej złożone i dynamiczne strategie handlowe, potencjalnie wykorzystując informacje i algorytmy, których użycia deweloper nawet nie brał pod uwagę.
Narzędzia
W tym samouczku wykorzystano Python (opens in a new tab), bibliotekę Web3 (opens in a new tab) oraz Uniswap v3 (opens in a new tab) do notowań i handlu.
Dlaczego Python?
Najczęściej używanym językiem w dziedzinie AI jest Python (opens in a new tab), więc używamy go tutaj. Nie martw się, jeśli nie znasz Pythona. Język jest bardzo przejrzysty, a ja dokładnie wyjaśnię, co robi.
Biblioteka Web3 (opens in a new tab) jest najpopularniejszym API Pythona dla Ethereum. Jest dość łatwa w użyciu.
Handel na blockchainie
Istnieje wiele zdecentralizowanych giełd (DEX), które pozwalają na handel tokenami na Ethereum. Jednakże, ze względu na arbitraż, mają one zazwyczaj podobne kursy wymiany.
Uniswap (opens in a new tab) to powszechnie używana giełda DEX, której możemy używać zarówno do notowań (w celu sprawdzenia względnych wartości tokenów), jak i transakcji.
OpenAI
Jeśli chodzi o duży model językowy, zdecydowałem się zacząć od OpenAI (opens in a new tab). Aby uruchomić aplikację z tego samouczka, trzeba będzie zapłacić za dostęp do API. Minimalna opłata w wysokości 5 USD jest więcej niż wystarczająca.
Programowanie, krok po kroku
Aby uprościć programowanie, postępujemy etapami. Każdy krok to gałąź w GitHub.
Pierwsze kroki
Oto kroki, aby rozpocząć pracę w systemie UNIX lub Linux (w tym WSL (opens in a new tab))
-
Jeśli jeszcze go nie masz, pobierz i zainstaluj Pythona (opens in a new tab).
-
Sklonuj repozytorium GitHub.
1git clone https://github.com/qbzzt/260215-ai-agent.git -b 01-getting-started2cd 260215-ai-agent -
Zainstaluj
uv(opens in a new tab). Polecenie w twoim systemie może być inne.1pipx install uv -
Pobierz biblioteki.
1uv sync -
Aktywuj środowisko wirtualne.
1source .venv/bin/activate -
Aby sprawdzić, czy Python i Web3 działają poprawnie, uruchom
python3i podaj mu ten program. Można go wpisać w wierszu poleceń>>>; nie ma potrzeby tworzenia pliku.1from web3 import Web32MAINNET_URL = "https://eth.drpc.org"3w3 = Web3(Web3.HTTPProvider(MAINNET_URL))4w3.eth.block_number5quit()
Odczyt z blockchaina
Następnym krokiem jest odczyt z blockchaina. Aby to zrobić, należy przełączyć się na gałąź 02-read-quote, a następnie użyć uv do uruchomienia programu.
1git checkout 02-read-quote2uv run agent.pyPowinna zostać wyświetlona lista obiektów Quote, z których każdy ma znacznik czasu, cenę i aktywo (obecnie zawsze WETH/USDC).
Oto wyjaśnienie linijka po linijce.
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 sysPokaż wszystkoZaimportuj biblioteki, których potrzebujemy. Są one wyjaśnione poniżej, gdy są używane.
1print = functools.partial(print, flush=True)Zastępuje funkcję print Pythona wersją, która zawsze natychmiast opróżnia bufor wyjścia. Jest to przydatne w długo działającym skrypcie, ponieważ nie chcemy czekać na aktualizacje statusu lub dane wyjściowe debugowania.
1MAINNET_URL = "https://eth.drpc.org"Adres URL do sieci głównej. Możesz uzyskać go z węzła jako usługa lub użyć jednego z tych reklamowanych w Chainlist (opens in a new tab).
1BLOCK_TIME_SECONDS = 122MINUTE_BLOCKS = int(60 / BLOCK_TIME_SECONDS)3HOUR_BLOCKS = MINUTE_BLOCKS * 604DAY_BLOCKS = HOUR_BLOCKS * 24Blok w sieci głównej Ethereum pojawia się zazwyczaj co dwanaście sekund, więc są to liczby bloków, których spodziewamy się w danym okresie. Należy pamiętać, że nie jest to dokładna liczba. Gdy proposer bloku nie działa, ten blok jest pomijany, a czas do następnego bloku wynosi 24 sekundy. Gdybyśmy chcieli uzyskać dokładny blok dla znacznika czasu, użylibyśmy wyszukiwania binarnego (opens in a new tab). Jednak na nasze potrzeby jest to wystarczająco dokładne. Przewidywanie przyszłości nie jest nauką ścisłą.
1CYCLE_BLOCKS = DAY_BLOCKSRozmiar cyklu. Przeglądamy notowania raz na cykl i próbujemy oszacować wartość na koniec następnego cyklu.
1# Adres puli, z której odczytujemy2WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")Wartości notowań są pobierane z puli Uniswap 3 USDC/WETH pod adresem 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640 (opens in a new tab). Ten adres jest już w formie sumy kontrolnej, ale lepiej jest użyć Web3.to_checksum_address (opens in a new tab), aby kod był wielokrotnego użytku.
1POOL_ABI = [2 { "name": "slot0", ... },3 { "name": "token0", ... },4 { "name": "token1", ... },5]67ERC20_ABI = [8 { "name": "symbol", ... },9 { "name": "decimals", ... }10]Pokaż wszystkoSą to ABI (opens in a new tab) dla dwóch kontraktów, z którymi musimy się skontaktować. Aby kod był zwięzły, dołączamy tylko te funkcje, które musimy wywołać.
1w3 = Web3(Web3.HTTPProvider(MAINNET_URL))Zainicjuj bibliotekę Web3 (opens in a new tab) i połącz się z węzłem Ethereum.
1@dataclass(frozen=True)2class ERC20Token:3 address: str4 symbol: str5 decimals: int6 contract: ContractJest to jeden ze sposobów tworzenia klasy danych w Pythonie. Typ danych Contract (opens in a new tab) jest używany do łączenia się z kontraktem. Zwróć uwagę na (frozen=True). W Pythonie wartości logiczne (opens in a new tab) są zdefiniowane jako True lub False, pisane wielką literą. Ta klasa danych jest frozen, co oznacza, że jej pól nie można modyfikować.
Zwróć uwagę na wcięcie. W przeciwieństwie do języków wywodzących się z C (opens in a new tab), Python używa wcięć do oznaczania bloków. Interpreter Pythona wie, że poniższa definicja nie jest częścią tej klasy danych, ponieważ nie zaczyna się na tym samym poziomie wcięcia, co pola klasy danych.
1@dataclass(frozen=True)2class PoolInfo:3 address: str4 token0: ERC20Token5 token1: ERC20Token6 contract: Contract7 asset: str8 decimal_factor: Decimal = 1Typ Decimal (opens in a new tab) służy do dokładnej obsługi ułamków dziesiętnych.
1 def get_price(self, block: int) -> Decimal:W ten sposób definiuje się funkcję w Pythonie. Definicja jest wcięta, aby pokazać, że nadal jest częścią PoolInfo.
W funkcji, która jest częścią klasy danych, pierwszym parametrem jest zawsze self, czyli instancja klasy danych, która została tutaj wywołana. Jest tu jeszcze jeden parametr, numer bloku.
1 assert block <= w3.eth.block_number, "Block is in the future"Gdybyśmy potrafili czytać przyszłość, nie potrzebowalibyśmy AI do handlu.
1 sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])Składnia wywoływania funkcji na EVM z Web3 jest następująca: <obiekt kontraktu>.functions.<nazwa funkcji>"().call(<parametry>). Parametry mogą być parametrami funkcji EVM (jeśli istnieją; tutaj ich nie ma) lub parametrami nazwanymi (opens in a new tab) do modyfikowania zachowania blockchaina. Tutaj używamy jednego, block_identifier, aby określić numer bloku, w którym chcemy uruchomić.
Wynikiem jest ta struktura, w formie tablicy (opens in a new tab). Pierwsza wartość jest funkcją kursu wymiany między dwoma tokenami.
1 raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2Aby zmniejszyć liczbę obliczeń on-chain, Uniswap v3 nie przechowuje rzeczywistego współczynnika wymiany, a raczej jego pierwiastek kwadratowy. Ponieważ EVM nie obsługuje matematyki zmiennoprzecinkowej ani ułamków, zamiast rzeczywistej wartości, odpowiedzią jest
1 # (token1 na token0)2 return 1/(raw_price * self.decimal_factor)Cena surowa, którą otrzymujemy, to liczba token0, którą otrzymujemy za każdy token1. W naszej puli token0 to USDC (stablecoin o tej samej wartości co dolar amerykański), a token1 to WETH (opens in a new tab). Wartością, której tak naprawdę potrzebujemy, jest liczba dolarów za WETH, a nie odwrotność.
Współczynnik dziesiętny to stosunek między współczynnikami dziesiętnymi (opens in a new tab) dla dwóch tokenów.
1@dataclass(frozen=True)2class Quote:3 timestamp: str4 price: Decimal5 asset: strTa klasa danych reprezentuje notowanie: cenę określonego aktywa w danym momencie. W tym momencie pole asset jest nieistotne, ponieważ używamy jednej puli, a zatem mamy jedno aktywo. Jednak później dodamy więcej aktywów.
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 )Pokaż wszystkoFunkcja ta pobiera adres i zwraca informacje o kontrakcie tokena pod tym adresem. Aby utworzyć nowy Web3 Contract (opens in a new tab), podajemy adres i ABI do 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 )Pokaż wszystkoTa funkcja zwraca wszystko, czego potrzebujemy na temat określonej puli (opens in a new tab). Składnia f"<ciąg znaków>" to sformatowany ciąg znaków (opens in a new tab).
1def get_quote(pool: PoolInfo, block_number: int = None) -> Quote:Pobierz obiekt Quote. Domyślna wartość dla block_number to None (brak wartości).
1 if block_number is None:2 block_number = w3.eth.block_numberJeśli numer bloku nie został określony, użyj w3.eth.block_number, który jest najnowszym numerem bloku. Jest to składnia dla instrukcji if (opens in a new tab).
Może się wydawać, że lepiej byłoby po prostu ustawić wartość domyślną na w3.eth.block_number, ale to nie działa dobrze, ponieważ byłby to numer bloku w momencie definiowania funkcji. W przypadku długo działającego agenta byłby to problem.
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 )Użyj biblioteki datetime (opens in a new tab), aby sformatować go do formatu czytelnego dla ludzi i dużych modeli językowych (LLM). Użyj Decimal.quantize (opens in a new tab), aby zaokrąglić wartość do dwóch miejsc po przecinku.
1def get_quotes(pool: PoolInfo, start_block: int, end_block: int, step: int) -> list[Quote]:W Pythonie definiuje się listę (opens in a new tab), która może zawierać tylko określony typ za pomocą list[<type>].
1 quotes = []2 for block in range(start_block, end_block + 1, step):W Pythonie pętla for (opens in a new tab) zwykle iteruje po liście. Lista numerów bloków do znalezienia notowań pochodzi z range (opens in a new tab).
1 quote = get_quote(pool, block)2 quotes.append(quote)3 return quotesDla każdego numeru bloku pobierz obiekt Quote i dołącz go do listy quotes. Następnie zwróć tę listę.
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)Pokaż wszystkoTo jest główny kod skryptu. Odczytaj informacje o puli, uzyskaj dwanaście notowań i wydrukuj je za pomocą pprint (opens in a new tab).
Tworzenie monitu
Następnie musimy przekonwertować tę listę notowań na monit dla LLM i uzyskać oczekiwaną przyszłą wartość.
1git checkout 03-create-prompt2uv run agent.pyDane wyjściowe będą teraz monitem dla LLM, podobnym do:
1Biorąc pod uwagę te notowania:2Aktywa: WETH/USDC3 2026-01-20T16:34 3016,214 .5 .6 .7 2026-02-01T17:49 2299,1089Aktywa: WBTC/WETH10 2026-01-20T16:34 29,8411 .12 .13 .14 2026-02-01T17:50 33,46151617Jakiej wartości WETH/USDC spodziewałbyś się o godzinie 2026-02-02T17:56?1819Podaj odpowiedź jako pojedynczą liczbę zaokrągloną do dwóch miejsc po przecinku,20bez żadnego innego tekstu.Pokaż wszystkoZauważ, że są tu notowania dla dwóch aktywów, WETH/USDC i WBTC/WETH. Dodanie notowań z innego aktywa może poprawić dokładność prognozy.
Jak wygląda monit
Ten monit zawiera trzy sekcje, które są dość powszechne w monitach LLM.
-
Informacje. Modele LLM mają wiele informacji ze swojego szkolenia, ale zazwyczaj nie mają najnowszych. Z tego powodu musimy pobrać tutaj najnowsze notowania. Dodawanie informacji do monitu nazywa się generowaniem rozszerzonym o wyszukiwanie (RAG) (opens in a new tab).
-
Właściwe pytanie. To jest to, co chcemy wiedzieć.
-
Instrukcje formatowania danych wyjściowych. Zwykle LLM poda nam szacunkową wartość wraz z wyjaśnieniem, w jaki sposób do niej doszedł. Jest to lepsze dla ludzi, ale program komputerowy potrzebuje tylko wyniku końcowego.
Wyjaśnienie kodu
Oto nowy kod.
1from datetime import datetime, timezone, timedeltaMusimy podać LLM czas, dla którego chcemy uzyskać oszacowanie. Aby uzyskać czas „n minut/godzin/dni” w przyszłości, używamy klasy timedelta (opens in a new tab).
1# Adresy pul, z których odczytujemy2WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")3WETHWBTC_ADDRESS = Web3.to_checksum_address("0xCBCdF9626bC03E24f779434178A73a0B4bad62eD")Mamy dwie pule, które musimy odczytać.
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_factorPokaż wszystkoW puli WETH/USDC chcemy wiedzieć, ile token0 (USDC) potrzebujemy, aby kupić jeden token1 (WETH). W puli WETH/WBTC chcemy wiedzieć, ile token1 (WETH) potrzebujemy, aby kupić jeden token0 (WBTC, czyli opakowany Bitcoin). Musimy śledzić, czy współczynnik puli wymaga odwrócenia.
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 )Pokaż wszystkoAby dowiedzieć się, czy pula wymaga odwrócenia, musimy uzyskać te dane wejściowe do read_pool. Ponadto symbol aktywa musi być poprawnie skonfigurowany.
Składnia <a> if <b> else <c> jest odpowiednikiem w Pythonie trójargumentowego operatora warunkowego (opens in a new tab), który w języku pochodnym C byłby <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 resultFunkcja ta tworzy ciąg znaków, który formatuje listę obiektów Quote, zakładając, że wszystkie dotyczą tego samego aktywa.
1def make_prompt(quotes: list[list[Quote]], expected_time: str, asset: str) -> str:2 return f"""W Pythonie wieloliniowe literały ciągów znaków (opens in a new tab) są zapisywane jako """ .... """.
1Biorąc pod uwagę te notowania:2{3 functools.reduce(lambda acc, q: acc + '\n' + q,4 map(lambda q: format_quotes(q), quotes))5}W tym miejscu używamy wzorca MapReduce (opens in a new tab) do wygenerowania ciągu znaków dla każdej listy notowań za pomocą format_quotes, a następnie redukujemy je do pojedynczego ciągu znaków do użycia w monicie.
1Jakiej wartości dla {asset} spodziewałbyś się o godzinie {expected_time}?23Podaj swoją odpowiedź jako pojedynczą liczbę zaokrągloną do dwóch miejsc po przecinku,4bez żadnego innego tekstu.5 """Reszta monitu jest zgodna z oczekiwaniami.
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)Pokaż wszystkoPrzejrzyj dwie pule i uzyskaj notowania z obu.
1future_time = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()[0:16]23print(make_prompt(wethusdc_quotes + wethwbtc_quotes, future_time, wethusdc_pool.asset))Określ przyszły punkt czasowy, dla którego chcemy uzyskać oszacowanie i utwórz monit.
Interfejs z LLM
Następnie monitujemy rzeczywisty LLM i otrzymujemy oczekiwaną przyszłą wartość. Napisałem ten program przy użyciu OpenAI, więc jeśli chcesz użyć innego dostawcy, będziesz musiał go dostosować.
-
Zasil konto (opens in a new tab) — minimalna kwota w momencie pisania tego tekstu to 5 USD
-
W wierszu poleceń wyeksportuj klucz API, aby program mógł z niego korzystać
1export OPENAI_API_KEY=sk-<reszta klucza znajduje się tutaj> -
Zamelduj i uruchom agenta
1git checkout 04-interface-llm2uv run agent.py
Oto nowy kod.
1from openai import OpenAI23open_ai = OpenAI() # Klient odczytuje zmienną środowiskową OPENAI_API_KEYZaimportuj i utwórz instancję 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)Wywołaj API OpenAI (open_ai.chat.completions.create), aby utworzyć odpowiedź.
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")Pokaż wszystkoWyświetl cenę i podaj rekomendację kupna lub sprzedaży.
Testowanie prognoz
Teraz, gdy możemy generować prognozy, możemy również wykorzystać dane historyczne, aby ocenić, czy tworzymy użyteczne prognozy.
1uv run test-predictor.pyOczekiwany wynik jest podobny do:
1Prognoza na 2026-01-05T19:50: prognozowano 3138,93 USD, realnie 3218,92 USD, błąd 79,99 USD2Prognoza na 2026-01-06T19:56: prognozowano 3243,39 USD, realnie 3221,08 USD, błąd 22,31 USD3Prognoza na 2026-01-07T20:02: prognozowano 3223,24 USD, realnie 3146,89 USD, błąd 76,35 USD4Prognoza na 2026-01-08T20:11: prognozowano 3150,47 USD, realnie 3092,04 USD, błąd 58,43 USD5.6.7.8Prognoza na 2026-01-31T22:33: prognozowano 2637,73 USD, realnie 2417,77 USD, błąd 219,96 USD9Prognoza na 2026-02-01T22:41: prognozowano 2381,70 USD, realnie 2318,84 USD, błąd 62,86 USD10Prognoza na 2026-02-02T22:49: prognozowano 2234,91 USD, realnie 2349,28 USD, błąd 114,37 USD11Średni błąd prognozy dla 29 prognoz: 83.87103448275862068965517241 USD12Średnia zmiana na rekomendację: 4,787931034482758620689655172 USD13Standardowa wariancja zmian: 104,42 USD14Dni zyskowne: 51,72%15Dni stratne: 48,28%Pokaż wszystkoWiększość testera jest identyczna z agentem, ale oto części, które są nowe lub zmodyfikowane.
1CYCLES_FOR_TEST = 40 # W przypadku testu historycznego, ile cykli testujemy23# Uzyskaj wiele notowań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)Pokaż wszystkoPatrzymy wstecz na CYCLES_FOR_TEST (określone tutaj jako 40) dni.
1# Tworzenie prognoz i sprawdzanie ich pod kątem rzeczywistej historii23total_error = Decimal(0)4changes = []Interesują nas dwa rodzaje błędów. Pierwszy, total_error, to po prostu suma błędów popełnionych przez predyktora.
Aby zrozumieć drugi, changes, musimy pamiętać o celu agenta. Nie chodzi o przewidywanie współczynnika WETH/USDC (ceny ETH). Chodzi o wydawanie zaleceń sprzedaży i kupna. Jeśli cena wynosi obecnie 2000 USD, a jutro przewiduje się 2010 USD, nie ma znaczenia, czy faktyczny wynik wyniesie 2020 USD i zarobimy dodatkowe pieniądze. Ale ma znaczenie, jeśli przewidywano 2010 USD i kupiono ETH na podstawie tej rekomendacji, a cena spadnie do 1990 USD.
1for index in range(0,len(wethusdc_quotes)-CYCLES_BACK):Możemy przyjrzeć się tylko przypadkom, w których dostępna jest pełna historia (wartości użyte do prognozy i wartość rzeczywista do porównania). Oznacza to, że najnowszy przypadek musi być tym, który rozpoczął się CYCLES_BACK temu.
1 wethusdc_slice = wethusdc_quotes[index:index+CYCLES_BACK]2 wethwbtc_slice = wethwbtc_quotes[index:index+CYCLES_BACK]Użyj fragmentów (opens in a new tab), aby uzyskać taką samą liczbę próbek, jakiej używa agent. Kod między tym a następnym segmentem to ten sam kod do uzyskiwania prognoz, który mamy w agencie.
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].priceUzyskaj przewidywaną cenę, cenę rzeczywistą i cenę w momencie prognozy. Potrzebujemy ceny w momencie prognozy, aby określić, czy rekomendacja dotyczyła kupna czy sprzedaży.
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")Ustal błąd i dodaj go do sumy.
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)W przypadku changes chcemy uzyskać wpływ pieniężny kupna lub sprzedaży jednego ETH. Najpierw musimy więc określić rekomendację, a następnie ocenić, jak zmieniła się rzeczywista cena i czy rekomendacja przyniosła zysk (zmiana dodatnia) czy stratę (zmiana ujemna).
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")Zgłoś wyniki.
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%}")Użyj filter (opens in a new tab), aby policzyć liczbę dni zyskownych i liczbę dni kosztownych. Wynikiem jest obiekt filtru, który musimy przekonwertować na listę, aby uzyskać długość.
Wysyłanie transakcji
Teraz musimy faktycznie przesłać transakcje. Nie chcę jednak wydawać prawdziwych pieniędzy na tym etapie, zanim system nie zostanie sprawdzony. Zamiast tego utworzymy lokalny fork sieci głównej i będziemy „handlować” w tej sieci.
Oto kroki, aby utworzyć lokalny fork i włączyć handel.
-
Zainstaluj Foundry (opens in a new tab)
-
Uruchom
anvil(opens in a new tab)1anvil --fork-url https://eth.drpc.org --block-time 12anvilnasłuchuje na domyślnym adresie URL dla Foundry, http://localhost:8545 (opens in a new tab), więc nie musimy określać adresu URL dla poleceniacast(opens in a new tab), którego używamy do manipulowania blockchainem. -
Podczas pracy w
anvildostępnych jest dziesięć kont testowych z ETH — ustaw zmienne środowiskowe dla pierwszego z nich1PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff802ADDRESS=`cast wallet address $PRIVATE_KEY` -
Są to kontrakty, których musimy użyć.
SwapRouter(opens in a new tab) to kontrakt Uniswap v3, którego używamy do faktycznego handlu. Moglibyśmy handlować bezpośrednio przez pulę, ale jest to znacznie łatwiejsze.Dwie ostatnie zmienne to ścieżki Uniswap v3 wymagane do wymiany między WETH i USDC.
1WETH_ADDRESS=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc22USDC_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB483POOL_ADDRESS=0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f56404SWAP_ROUTER=0xE592427A0AEce92De3Edee1F18E0157C058615645WETH_TO_USDC=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB486USDC_TO_WETH=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 -
Każde z kont testowych ma 10 000 ETH. Użyj kontraktu WETH, aby opakować 1000 ETH w celu uzyskania 1000 WETH do handlu.
1cast send $WETH_ADDRESS "deposit()" --value 1000ether --private-key $PRIVATE_KEY -
Użyj
SwapRouterdo handlu 500 WETH na 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_KEYWywołanie
approvetworzy limit wydatków, który pozwalaSwapRouterna wydanie niektórych z naszych tokenów. Kontrakty nie mogą monitorować zdarzeń, więc jeśli przekażemy tokeny bezpośrednio do kontraktuSwapRouter, nie będzie on wiedział, że został opłacony. Zamiast tego pozwalamy kontraktowiSwapRouterwydać określoną kwotę, a następnieSwapRouterto robi. Odbywa się to za pomocą funkcji wywoływanej przezSwapRouter, dzięki czemu wie on, czy się powiodła. -
Sprawdź, czy masz wystarczającą ilość obu tokenów.
1cast call $WETH_ADDRESS "balanceOf(address)" $ADDRESS | cast from-wei2echo `cast call $USDC_ADDRESS "balanceOf(address)" $ADDRESS | cast to-dec`/10^6 | bc
Teraz, gdy mamy WETH i USDC, możemy faktycznie uruchomić agenta.
1git checkout 05-trade2uv run agent.pyWynik będzie wyglądał podobnie do:
1(ai-trading-agent) qbzzt@Ori-Cloudnomics:~/260215-ai-agent$ uv run agent.py2Aktualna cena: 1843.163O 2026-02-06T23:07 spodziewana cena: 1724.41 USD4Sada kont przed transakcją:5Saldo USDC: 927301.5782726Saldo WETH: 5007Sprzedaj, spodziewam się, że cena spadnie o 118,75 USD8Wysłano transakcję zatwierdzenia: 74e367ddbb407c1aaf567d87aa5863049991b1d2aa092b6b85195d925e2bd41f9Transakcja zatwierdzenia wykopana.10Wysłano transakcję sprzedaży: fad1bcf938585c9e90364b26ac7a80eea9efd34c37e5db81e58d7655bcae28bf11Transakcja sprzedaży wykopana.12Salda kont po transakcji:13Saldo USDC: 929143,79711614Saldo WETH: 499Pokaż wszystkoAby faktycznie z niego skorzystać, potrzeba kilku drobnych zmian.
- W wierszu 14 zmień
MAINNET_URLna prawdziwy punkt dostępu, taki jakhttps://eth.drpc.org - W wierszu 28 zmień
PRIVATE_KEYna swój własny klucz prywatny - Chyba że jesteś bardzo bogaty i możesz kupować lub sprzedawać 1 ETH każdego dnia dla niesprawdzonego agenta, możesz chcieć zmienić 29, aby zmniejszyć
WETH_TRADE_AMOUNT
Wyjaśnienie kodu
Oto nowy kod.
1SWAP_ROUTER_ADDRESS=Web3.to_checksum_address("0xE592427A0AEce92De3Edee1F18E0157C05861564")2WETH_TO_USDC=bytes.fromhex("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")3USDC_TO_WETH=bytes.fromhex("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")4PRIVATE_KEY="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"Te same zmienne, których użyliśmy w kroku 4.
1WETH_TRADE_AMOUNT=1Kwota do handlu.
1ERC20_ABI = [2 { "name": "symbol", ... },3 { "name": "decimals", ... },4 { "name": "balanceOf", ...},5 { "name": "approve", ...}6]Aby faktycznie handlować, potrzebujemy funkcji approve. Chcemy również pokazać salda przed i po, więc potrzebujemy również balanceOf.
1SWAP_ROUTER_ABI = [2 { "name": "exactInput", ...},3]W ABI SwapRouter potrzebujemy tylko exactInput. Istnieje powiązana funkcja, exactOutput, której moglibyśmy użyć do zakupu dokładnie jednego WETH, ale dla uproszczenia używamy exactInput w obu przypadkach.
1account = w3.eth.account.from_key(PRIVATE_KEY)2swap_router = w3.eth.contract(3 address=SWAP_ROUTER_ADDRESS,4 abi=SWAP_ROUTER_ABI5)Definicje Web3 dla konta (opens in a new tab) i kontraktu 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 }Parametry transakcji. Potrzebujemy tutaj funkcji, ponieważ nonce (opens in a new tab) musi się zmieniać za każdym razem.
1def approve_token(contract: Contract, amount: int):Zatwierdź limit wydatków tokena dla 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)W ten sposób wysyłamy transakcję w Web3. Najpierw używamy obiektu Contract (opens in a new tab) do zbudowania transakcji. Następnie używamy web3.eth.account.sign_transaction (opens in a new tab) do podpisania transakcji za pomocą PRIVATE_KEY. Na koniec używamy w3.eth.send_raw_transaction (opens in a new tab) do wysłania transakcji.
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) czeka na wykopanie transakcji. W razie potrzeby zwraca potwierdzenie.
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}Są to parametry przy sprzedaży 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 }W przeciwieństwie do SELL_PARAMS, parametry kupna mogą się zmieniać. Kwota wejściowa to koszt 1 WETH, dostępny w 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.")Pokaż wszystkoFunkcje buy() i sell() są prawie identyczne. Najpierw zatwierdzamy wystarczający limit wydatków dla SwapRouter, a następnie wywołujemy go z poprawną ścieżką i kwotą.
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)}")Zgłoś salda użytkowników w obu walutach.
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()Pokaż wszystkoTen agent działa obecnie tylko raz. Można jednak zmienić go tak, aby działał w sposób ciągły, uruchamiając go z crontab (opens in a new tab) lub zawijając wiersze 368-400 w pętlę i używając time.sleep (opens in a new tab), aby poczekać, aż nadejdzie czas na następny cykl.
Możliwe ulepszenia
To nie jest pełna wersja produkcyjna; to tylko przykład, aby nauczyć podstaw. Oto kilka pomysłów na ulepszenia.
Inteligentniejszy handel
Istnieją dwa ważne fakty, które agent ignoruje przy podejmowaniu decyzji, co robić.
- Wielkość przewidywanej zmiany. Agent sprzedaje stałą kwotę
WETH, jeśli oczekuje się spadku ceny, niezależnie od wielkości spadku. Prawdopodobnie lepiej byłoby ignorować drobne zmiany i sprzedawać w oparciu o to, jak bardzo spodziewamy się spadku ceny. - Aktualne portfolio. Jeśli 10% Twojego portfela to WETH i uważasz, że cena wzrośnie, prawdopodobnie warto kupić więcej. Ale jeśli 90% Twojego portfela to WETH, możesz być wystarczająco wyeksponowany i nie ma potrzeby kupować więcej. Odwrotnie jest, jeśli spodziewasz się spadku ceny.
Co, jeśli chcesz zachować swoją strategię handlową w tajemnicy?
Dostawcy AI mogą zobaczyć zapytania, które wysyłasz do ich LLM, co może ujawnić genialny system handlowy, który opracowałeś ze swoim agentem. System handlowy, z którego korzysta zbyt wiele osób, jest bezwartościowy, ponieważ zbyt wiele osób próbuje kupować, gdy chcesz kupić (a cena rośnie) i próbuje sprzedawać, gdy chcesz sprzedać (a cena spada).
Aby uniknąć tego problemu, można uruchomić LLM lokalnie, na przykład za pomocą LM-Studio (opens in a new tab).
Od bota AI do agenta AI
Można argumentować, że jest to bot AI, a nie agent AI. Implementuje on stosunkowo prostą strategię opartą na predefiniowanych informacjach. Możemy włączyć samodoskonalenie, na przykład, dostarczając listę pul Uniswap v3 i ich najnowszych wartości i pytając, która kombinacja ma najlepszą wartość predykcyjną.
Ochrona przed poślizgiem
Obecnie nie ma ochrony przed poślizgiem (opens in a new tab). Jeśli obecne notowanie wynosi 2000 USD, a oczekiwana cena 2100 USD, agent dokona zakupu. Jeśli jednak przed zakupem przez agenta koszt wzrośnie do 2200 USD, dalszy zakup nie ma sensu.
Aby zaimplementować ochronę przed poślizgiem, należy określić wartość amountOutMinimum w wierszach 325 i 334 pliku agent.py (opens in a new tab).
Wnioski
Mam nadzieję, że wiesz już wystarczająco dużo, aby zacząć z agentami AI. Nie jest to kompleksowy przegląd tematu; istnieją całe książki poświęcone temu zagadnieniu, ale to wystarczy, aby zacząć. Powodzenia!
Zobacz więcej mojej pracy tutaj (opens in a new tab).
Strona ostatnio zaktualizowana: 10 lutego 2026