Przejdź do głównej treści

Stwórz własnego agenta AI do tradingu na Ethereum

AI
trading
agent
Python
Średniozaawansowany
Ori Pomerantz
13 lutego 2026
22 minut czytania

W tym samouczku dowiesz się, jak zbudować prostego agenta AI do tradingu. Agent ten działa w następujących krokach:

  1. Odczytuje obecne i przeszłe ceny tokena, a także inne potencjalnie istotne informacje
  2. Buduje zapytanie z tymi informacjami, wraz z informacjami kontekstowymi wyjaśniającymi, dlaczego mogą być one istotne
  3. Przesyła zapytanie i otrzymuje z powrotem prognozowaną cenę
  4. Handluje na podstawie rekomendacji
  5. Czeka i powtarza proces

Ten agent demonstruje, 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 dla agenta AI. Ten agent jest zaimplementowany w języku Python, ponieważ jest to najpopularniejszy język używany w AI.

Dlaczego warto to zrobić?

Zautomatyzowani agenci do tradingu pozwalają deweloperom na wybór i realizację strategii. Agenci AI pozwalają na bardziej złożone i dynamiczne strategie, potencjalnie wykorzystując informacje i algorytmy, o których deweloper nawet nie pomyślał.

Narzędzia

W tym samouczku wykorzystano język Python (opens in a new tab), bibliotekę Web3 (opens in a new tab) oraz Uniswap v3 (opens in a new tab) do wycen i tradingu.

Dlaczego Python?

Najczęściej używanym językiem w AI jest Python (opens in a new tab), dlatego używamy go tutaj. Nie martw się, jeśli nie znasz Pythona. Język ten jest bardzo przejrzysty, a ja dokładnie wyjaśnię, co robi.

Biblioteka Web3 (opens in a new tab) to najpopularniejsze API Ethereum dla Pythona. Jest dość łatwa w użyciu.

Trading na blockchainie

Istnieje wiele zdecentralizowanych giełd (DEX), które pozwalają na wymianę tokenów na Ethereum. Zazwyczaj mają one jednak podobne kursy wymiany ze względu na arbitraż.

Uniswap (opens in a new tab) to powszechnie używany DEX, którego możemy użyć zarówno do wycen (aby zobaczyć względne wartości tokenów), jak i do transakcji.

OpenAI

Jako duży model językowy na początek wybrałem OpenAI (opens in a new tab). Aby uruchomić aplikację z tego samouczka, będziesz musiał zapłacić za dostęp do API. Minimalna wpłata w wysokości 5 USD jest więcej niż wystarczająca.

Rozwój krok po kroku

Aby uprościć rozwój, będziemy postępować etapami. Każdy krok to gałąź na GitHubie.

Zaczynamy

Oto kroki, aby zacząć w systemach UNIX lub Linux (w tym WSL (opens in a new tab))

  1. Jeśli jeszcze go nie masz, pobierz i zainstaluj Python (opens in a new tab).

  2. Sklonuj repozytorium GitHub.

    git clone https://github.com/qbzzt/260215-ai-agent.git -b 01-getting-started
    cd 260215-ai-agent
    
  3. Zainstaluj uv (opens in a new tab). Polecenie w Twoim systemie może się różnić.

    pipx install uv
    
  4. Pobierz biblioteki.

    uv sync
    
  5. Aktywuj środowisko wirtualne.

    source .venv/bin/activate
    
  6. Aby sprawdzić, czy Python i Web3 działają poprawnie, uruchom python3 i podaj mu ten program. Możesz go wpisać w wierszu poleceń >>>; nie ma potrzeby tworzenia pliku.

    from web3 import Web3
    MAINNET_URL = "https://eth.drpc.org"
    w3 = Web3(Web3.HTTPProvider(MAINNET_URL))
    w3.eth.block_number
    quit()
    

Odczyt z blockchaina

Kolejnym krokiem jest odczyt z blockchaina. Aby to zrobić, musisz zmienić gałąź na 02-read-quote, a następnie użyć uv do uruchomienia programu.

git checkout 02-read-quote
uv run agent.py

Powinieneś otrzymać listę obiektów Quote, z których każdy zawiera znacznik czasu, cenę i aktywo (obecnie zawsze WETH/USDC).

Oto wyjaśnienie linijka po linijce.

Importujemy potrzebne biblioteki. Zostały one wyjaśnione poniżej w miejscach ich użycia.

print = functools.partial(print, flush=True)

Zastępuje print w Pythonie wersją, która zawsze natychmiast opróżnia bufor wyjściowy. Jest to przydatne w długo działającym skrypcie, ponieważ nie chcemy czekać na aktualizacje statusu ani dane wyjściowe debugowania.

MAINNET_URL = "https://eth.drpc.org"

Adres URL umożliwiający dostęp do sieci głównej (Mainnet). Możesz go uzyskać z węzła jako usługi (Node as a service) lub użyć jednego z tych reklamowanych na Chainlist (opens in a new tab).

BLOCK_TIME_SECONDS = 12
MINUTE_BLOCKS = int(60 / BLOCK_TIME_SECONDS)
HOUR_BLOCKS = MINUTE_BLOCKS * 60
DAY_BLOCKS = HOUR_BLOCKS * 24

Blok w sieci głównej Ethereum pojawia się zazwyczaj co dwanaście sekund, więc jest to liczba bloków, jakiej spodziewalibyśmy się w danym okresie. Zauważ, że nie jest to dokładna liczba. Kiedy proponujący blok jest niedostępny, ten blok jest pomijany, a czas do następnego bloku wynosi 24 sekundy. Gdybyśmy chcieli uzyskać dokładny blok dla danego znacznika czasu, użylibyśmy wyszukiwania binarnego (opens in a new tab). Jednak do naszych celów jest to wystarczająco bliskie. Przewidywanie przyszłości nie jest nauką ścisłą.

CYCLE_BLOCKS = DAY_BLOCKS

Rozmiar cyklu. Przeglądamy wyceny raz na cykl i próbujemy oszacować wartość na koniec następnego cyklu.

# Adres puli, którą odczytujemy
WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")

Wartości wycen 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.

Są to ABI (opens in a new tab) dla dwóch kontraktów, z którymi musimy się połączyć. Aby kod był zwięzły, dołączamy tylko te funkcje, które musimy wywołać.

w3 = Web3(Web3.HTTPProvider(MAINNET_URL))

Inicjujemy bibliotekę Web3 (opens in a new tab) i łączymy się z węzłem Ethereum.

@dataclass(frozen=True)
class ERC20Token:
    address: str
    symbol: str
    decimals: int
    contract: Contract

To jeden ze sposobów na utworzenie klasy danych (data class) w Pythonie. Typ danych Contract (opens in a new tab) służy do łączenia się z kontraktem. Zwróć uwagę na (frozen=True). W Pythonie wartości logiczne (booleans) (opens in a new tab) są definiowane jako True lub False, pisane wielką literą. Ta klasa danych jest frozen, co oznacza, że jej pola nie mogą być modyfikowane.

Zwróć uwagę na wcięcia. 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ę od tego samego wcięcia co pola klasy danych.

@dataclass(frozen=True)
class PoolInfo:
    address: str
    token0: ERC20Token
    token1: ERC20Token
    contract: Contract
    asset: str
    decimal_factor: Decimal = 1

Typ Decimal (opens in a new tab) służy do dokładnej obsługi ułamków dziesiętnych.

    def get_price(self, block: int) -> Decimal:

W ten sposób definiuje się funkcję w Pythonie. Definicja ma wcięcie, aby pokazać, że nadal jest częścią PoolInfo.

W funkcji będącej częścią klasy danych pierwszym parametrem jest zawsze self, czyli instancja klasy danych, która została tu wywołana. Tutaj znajduje się jeszcze jeden parametr, numer bloku.

        assert block <= w3.eth.block_number, "Block is in the future"

Gdybyśmy potrafili czytać przyszłość, nie potrzebowalibyśmy AI do tradingu.

        sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])

Składnia wywoływania funkcji na EVM z Web3 wygląda tak: <contract object>.functions.<function name>().call(<parameters>). Parametrami mogą być parametry funkcji EVM (jeśli istnieją; tutaj ich nie ma) lub parametry nazwane (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ć funkcję.

Wynikiem jest ta struktura w formie tablicy (opens in a new tab). Pierwsza wartość jest funkcją kursu wymiany między dwoma tokenami.

        raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2

Aby zmniejszyć liczbę obliczeń onchain, Uniswap v3 nie przechowuje rzeczywistego współczynnika wymiany, ale raczej jego pierwiastek kwadratowy. Ponieważ EVM nie obsługuje matematyki zmiennoprzecinkowej ani ułamków, zamiast rzeczywistej wartości odpowiedź to price296

         # (token1 na token0)
        return 1/(raw_price * self.decimal_factor)

Surowa cena, którą otrzymujemy, to liczba token0, którą dostajemy 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ść, której tak naprawdę szukamy, to liczba dolarów za WETH, a nie odwrotnie.

Współczynnik dziesiętny to stosunek między współczynnikami dziesiętnymi (opens in a new tab) dla obu tokenów.

@dataclass(frozen=True)
class Quote:
    timestamp: str
    price: Decimal
    asset: str

Ta klasa danych reprezentuje wycenę: cenę określonego aktywa w danym momencie. W tym momencie pole asset jest nieistotne, ponieważ używamy pojedynczej puli i dlatego mamy jedno aktywo. Jednak później dodamy więcej aktywów.

Ta funkcja przyjmuje adres i zwraca informacje o kontrakcie tokena pod tym adresem. Aby utworzyć nowy Contract w Web3 (opens in a new tab), podajemy adres i ABI do w3.eth.contract.

Ta funkcja zwraca wszystko, czego potrzebujemy na temat konkretnej puli (opens in a new tab). Składnia f"<string>" to sformatowany ciąg znaków (f-string) (opens in a new tab).

def get_quote(pool: PoolInfo, block_number: int = None) -> Quote:

Pobiera obiekt Quote. Domyślną wartością dla block_number jest None (brak wartości).

    if block_number is None:
        block_number = w3.eth.block_number

Jeśli numer bloku nie został określony, używa w3.eth.block_number, co oznacza najnowszy numer bloku. Jest to składnia dla instrukcji if (opens in a new tab).

Mogłoby 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 długo działającym agencie stanowiłoby to problem.

    block = w3.eth.get_block(block_number)
    price = pool.get_price(block_number)
    return Quote(
        timestamp=datetime.fromtimestamp(block.timestamp, timezone.utc).isoformat(),
        price=price.quantize(Decimal("0.01")),
        asset=pool.asset
    )

Używa biblioteki datetime (opens in a new tab), aby sformatować to do formatu czytelnego dla ludzi i dużych modeli językowych (LLM). Używa Decimal.quantize (opens in a new tab), aby zaokrąglić wartość do dwóch miejsc po przecinku.

def 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, używając list[<type>].

    quotes = []
    for block in range(start_block, end_block + 1, step):

W Pythonie pętla for (opens in a new tab) zazwyczaj iteruje po liście. Lista numerów bloków, w których należy znaleźć wyceny, pochodzi z range (opens in a new tab).

        quote = get_quote(pool, block)
        quotes.append(quote)
    return quotes

Dla każdego numeru bloku pobiera obiekt Quote i dołącza go do listy quotes. Następnie zwraca tę listę.

To jest główny kod skryptu. Odczytuje informacje o puli, pobiera dwanaście wycen i pprint je (wypisuje) (opens in a new tab).

Tworzenie promptu

Następnie musimy przekonwertować tę listę wycen na prompt dla LLM i uzyskać oczekiwaną przyszłą wartość.

git checkout 03-create-prompt
uv run agent.py

Wynikiem będzie teraz prompt do LLM, podobny do:

Zauważ, że są tu wyceny dla dwóch aktywów, WETH/USDC i WBTC/WETH. Dodanie wycen z innego aktywa może poprawić dokładność przewidywań.

Jak wygląda prompt

Ten prompt zawiera trzy sekcje, które są dość powszechne w promptach dla LLM.

  1. Informacje. Modele LLM mają wiele informacji ze swojego treningu, ale zazwyczaj nie mają tych najnowszych. Z tego powodu musimy tutaj pobrać najnowsze wyceny. Dodawanie informacji do promptu nazywa się generowaniem rozszerzonym o wyszukiwanie (RAG) (opens in a new tab).

  2. Właściwe pytanie. To jest to, co chcemy wiedzieć.

  3. Instrukcje formatowania wyjścia. Zazwyczaj LLM poda nam szacunek wraz z wyjaśnieniem, jak do niego doszedł. Jest to lepsze dla ludzi, ale program komputerowy potrzebuje tylko ostatecznego wyniku.

Wyjaśnienie kodu

Oto nowy kod.

from datetime import datetime, timezone, timedelta

Musimy podać LLM czas, dla którego chcemy uzyskać szacunek. Aby uzyskać czas „n minut/godzin/dni” w przyszłości, używamy klasy timedelta (opens in a new tab).

# Adresy pul, które odczytujemy
WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")
WETHWBTC_ADDRESS = Web3.to_checksum_address("0xCBCdF9626bC03E24f779434178A73a0B4bad62eD")

Mamy dwie pule, które musimy odczytać.

W 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 stosunek puli musi zostać odwrócony.

Aby wiedzieć, czy pula musi zostać odwrócona, przekazujemy to jako wejście do read_pool. Ponadto symbol aktywa musi być poprawnie ustawiony.

Składnia <a> if <b> else <c> to pythonowy odpowiednik trójargumentowego operatora warunkowego (opens in a new tab), który w języku wywodzącym się z C wyglądałby tak: <b> ? <a> : <c>.

def format_quotes(quotes: list[Quote]) -> str:
    result = f"Asset: {quotes[0].asset}\n"
    for quote in quotes:
        result += f"\t{quote.timestamp[0:16]} {quote.price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}\n"
    return result

Ta funkcja buduje ciąg znaków, który formatuje listę obiektów Quote, zakładając, że wszystkie dotyczą tego samego aktywa.

def make_prompt(quotes: list[list[Quote]], expected_time: str, asset: str) -> str:
    return f"""

W Pythonie wieloliniowe literały łańcuchowe (opens in a new tab) są zapisywane jako """ .... """.

Given these quotes:
{
    functools.reduce(lambda acc, q: acc + '\n' + q,
        map(lambda q: format_quotes(q), quotes))
}

Tutaj używamy wzorca MapReduce (opens in a new tab), aby wygenerować ciąg znaków dla każdej listy wycen za pomocą format_quotes, a następnie zredukować je do pojedynczego ciągu znaków do użycia w prompcie.

What would you expect the value for {asset} to be at time {expected_time}?

Provide your answer as a single number rounded to two decimal places,
without any other text.
    """

Reszta promptu jest zgodna z oczekiwaniami.

Przegląda dwie pule i pobiera wyceny z obu.

future_time = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()[0:16]

print(make_prompt(wethusdc_quotes + wethwbtc_quotes, future_time, wethusdc_pool.asset))

Określa przyszły punkt w czasie, dla którego chcemy uzyskać szacunek, i tworzy prompt.

Komunikacja z LLM

Następnie wysyłamy prompt do rzeczywistego LLM i otrzymujemy oczekiwaną przyszłą wartość. Napisałem ten program używając OpenAI, więc jeśli chcesz użyć innego dostawcy, będziesz musiał go dostosować.

  1. Załóż konto OpenAI (opens in a new tab)

  2. Zasil konto (opens in a new tab) — minimalna kwota w momencie pisania tego tekstu to 5 USD

  3. Utwórz klucz API (opens in a new tab)

  4. W wierszu poleceń wyeksportuj klucz API, aby Twój program mógł z niego korzystać

    export OPENAI_API_KEY=sk-<the rest of the key goes here>
    
  5. Pobierz (checkout) i uruchom agenta

    git checkout 04-interface-llm
    uv run agent.py
    

Oto nowy kod.

from openai import OpenAI

open_ai = OpenAI()  # Klient odczytuje zmienną środowiskową OPENAI_API_KEY

Importuje i tworzy instancję API OpenAI.

response = open_ai.chat.completions.create(
    model="gpt-4-turbo",
    messages=[
        {"role": "user", "content": prompt}
    ],
    temperature=0.0,
    max_tokens=16,
)

Wywołuje API OpenAI (open_ai.chat.completions.create), aby utworzyć odpowiedź.

Wypisuje cenę i podaje rekomendację kupna lub sprzedaży.

Testowanie przewidywań

Teraz, gdy potrafimy generować przewidywania, możemy również użyć danych historycznych, aby ocenić, czy tworzymy użyteczne prognozy.

uv run test-predictor.py

Oczekiwany wynik jest podobny do:

Większość testera jest identyczna z agentem, ale oto części, które są nowe lub zmodyfikowane.

Patrzymy CYCLES_FOR_TEST (tutaj określone jako 40) dni wstecz.

# Utwórz prognozy i porównaj je z rzeczywistą historią

total_error = Decimal(0)
changes = []

Interesują nas dwa rodzaje błędów. Pierwszy, total_error, to po prostu suma błędów popełnionych przez predyktor.

Aby zrozumieć drugi, changes, musimy pamiętać o celu agenta. Nie jest nim przewidywanie stosunku WETH/USDC (ceny ETH). Jego celem jest wydawanie rekomendacji sprzedaży i kupna. Jeśli cena wynosi obecnie 2000 USD, a on przewiduje 2010 USD na jutro, nie przeszkadza nam, jeśli rzeczywisty wynik wyniesie 2020 USD i zarobimy dodatkowe pieniądze. Ale przeszkadza nam, jeśli przewidział 2010 USD i kupił ETH na podstawie tej rekomendacji, a cena spadnie do 1990 USD.

for index in range(0,len(wethusdc_quotes)-CYCLES_BACK):

Możemy patrzeć tylko na przypadki, w których dostępna jest pełna historia (wartości użyte do przewidywania i rzeczywista wartość do porównania). Oznacza to, że najnowszy przypadek musi być tym, który rozpoczął się CYCLES_BACK temu.

    wethusdc_slice = wethusdc_quotes[index:index+CYCLES_BACK]
    wethwbtc_slice = wethwbtc_quotes[index:index+CYCLES_BACK]

Używa wycinków (slices) (opens in a new tab), aby uzyskać taką samą liczbę próbek, jakiej używa agent. Kod między tym miejscem a następnym segmentem to ten sam kod pobierania przewidywań, który mamy w agencie.

    predicted_price = Decimal(response.choices[0].message.content.strip())
    real_price = wethusdc_quotes[index+CYCLES_BACK].price
    prediction_time_price = wethusdc_quotes[index+CYCLES_BACK-1].price

Pobiera przewidywaną cenę, rzeczywistą cenę i cenę w momencie przewidywania. Potrzebujemy ceny w momencie przewidywania, aby określić, czy rekomendacja dotyczyła kupna, czy sprzedaży.

    error = abs(predicted_price - real_price)
    total_error += error
    print (f"Prediction for {prediction_time}: predicted {predicted_price} USD, real {real_price} USD, error {error} USD")

Oblicza błąd i dodaje go do sumy.

    recomended_action = 'buy' if predicted_price > prediction_time_price else 'sell'
    price_increase = real_price - prediction_time_price
    changes.append(price_increase if recomended_action == 'buy' else -price_increase)

Dla changes chcemy poznać wpływ finansowy 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).

print (f"Mean prediction error over {len(wethusdc_quotes)-CYCLES_BACK} predictions: {total_error / Decimal(len(wethusdc_quotes)-CYCLES_BACK)} USD")

length_changes = Decimal(len(changes))
mean_change = sum(changes, Decimal(0)) / length_changes
print (f"Mean change per recommendation: {mean_change} USD")
var = sum((x - mean_change) ** 2 for x in changes) / length_changes
print (f"Standard variance of changes: {var.sqrt().quantize(Decimal("0.01"))} USD")

Raportuje wyniki.

print (f"Profitable days: {len(list(filter(lambda x: x > 0, changes)))/length_changes:.2%}")
print (f"Losing days: {len(list(filter(lambda x: x < 0, changes)))/length_changes:.2%}")

Używa filter (opens in a new tab), aby policzyć liczbę zyskownych i stratnych dni. Wynikiem jest obiekt filtra, który musimy przekonwertować na listę, aby uzyskać jego długość.

Wysyłanie transakcji

Teraz musimy faktycznie wysyłać transakcje. Nie chcę jednak na tym etapie wydawać prawdziwych pieniędzy, zanim system nie zostanie sprawdzony. Zamiast tego utworzymy lokalne rozwidlenie sieci głównej i będziemy „handlować” w tej sieci.

Oto kroki, aby utworzyć lokalne rozwidlenie i umożliwić trading.

  1. Zainstaluj Foundry (opens in a new tab)

  2. Uruchom anvil (opens in a new tab)

    anvil --fork-url https://eth.drpc.org --block-time 12
    

    anvil nasł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 polecenia cast (opens in a new tab), którego używamy do manipulowania blockchainem.

  3. Podczas działania w anvil dostępnych jest dziesięć kont testowych, które posiadają ETH — ustaw zmienne środowiskowe dla pierwszego z nich

    PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
    ADDRESS=`cast wallet address $PRIVATE_KEY`
    
  4. To są kontrakty, których musimy użyć. SwapRouter (opens in a new tab) to kontrakt Uniswap v3, którego używamy do faktycznego tradingu. Moglibyśmy handlować bezpośrednio przez pulę, ale to jest znacznie łatwiejsze.

    Dwie dolne zmienne to ścieżki Uniswap v3 wymagane do wymiany między WETH a USDC.

    WETH_ADDRESS=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
    USDC_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
    POOL_ADDRESS=0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640
    SWAP_ROUTER=0xE592427A0AEce92De3Edee1F18E0157C05861564
    WETH_TO_USDC=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
    USDC_TO_WETH=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
    
  5. Każde z kont testowych ma 10 000 ETH. Użyj kontraktu WETH, aby opakować 1000 ETH i uzyskać 1000 WETH do tradingu.

    cast send $WETH_ADDRESS "deposit()" --value 1000ether --private-key $PRIVATE_KEY
    
  6. Użyj SwapRouter, aby wymienić 500 WETH na USDC.

    cast send $WETH_ADDRESS "approve(address,uint256)" $SWAP_ROUTER 500ether --private-key $PRIVATE_KEY
    MAXINT=`cast max-int uint256`
    cast send $SWAP_ROUTER \
        "exactInput((bytes,address,uint256,uint256,uint256))" \
        "($WETH_TO_USDC,$ADDRESS,$MAXINT,500ether,1000000)" \
        --private-key $PRIVATE_KEY
    

    Wywołanie approve tworzy limit wydatków, który pozwala SwapRouter na wydanie części naszych tokenów. Kontrakty nie mogą monitorować zdarzeń, więc gdybyśmy przetransferowali tokeny bezpośrednio do kontraktu SwapRouter, nie wiedziałby on, że otrzymał zapłatę. Zamiast tego pozwalamy kontraktowi SwapRouter na wydanie określonej kwoty, a następnie SwapRouter to robi. Odbywa się to poprzez funkcję wywoływaną przez SwapRouter, dzięki czemu wie on, czy operacja się powiodła.

  7. Sprawdź, czy masz wystarczająco dużo obu tokenów.

    cast call $WETH_ADDRESS "balanceOf(address)" $ADDRESS | cast from-wei
    echo `cast call $USDC_ADDRESS "balanceOf(address)" $ADDRESS | cast to-dec`/10^6 | bc
    

Teraz, gdy mamy WETH i USDC, możemy faktycznie uruchomić agenta.

git checkout 05-trade
uv run agent.py

Wynik będzie wyglądał podobnie do:

Aby faktycznie z niego korzystać, potrzebujesz kilku drobnych zmian.

  • W linii 14 zmień MAINNET_URL na rzeczywisty punkt dostępu, taki jak https://eth.drpc.org
  • W linii 28 zmień PRIVATE_KEY na swój własny klucz prywatny
  • O ile nie jesteś bardzo bogaty i nie możesz kupować lub sprzedawać 1 ETH każdego dnia dla niesprawdzonego agenta, możesz chcieć zmienić linię 29, aby zmniejszyć WETH_TRADE_AMOUNT

Wyjaśnienie kodu

Oto nowy kod.

SWAP_ROUTER_ADDRESS=Web3.to_checksum_address("0xE592427A0AEce92De3Edee1F18E0157C05861564")
WETH_TO_USDC=bytes.fromhex("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
USDC_TO_WETH=bytes.fromhex("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
PRIVATE_KEY="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"

Te same zmienne, których użyliśmy w kroku 4.

WETH_TRADE_AMOUNT=1

Kwota do handlu.

ERC20_ABI = [
    { "name": "symbol", ... },
    { "name": "decimals", ... },
    { "name": "balanceOf", ...},
    { "name": "approve", ...}
]

Aby faktycznie handlować, potrzebujemy funkcji approve. Chcemy również pokazać salda przed i po, więc potrzebujemy również balanceOf.

SWAP_ROUTER_ABI = [
  { "name": "exactInput", ...},
]

W ABI SwapRouter potrzebujemy tylko exactInput. Istnieje powiązana funkcja, exactOutput, której moglibyśmy użyć do kupienia dokładnie jednego WETH, ale dla uproszczenia w obu przypadkach używamy po prostu exactInput.

account = w3.eth.account.from_key(PRIVATE_KEY)
swap_router = w3.eth.contract(
    address=SWAP_ROUTER_ADDRESS,
    abi=SWAP_ROUTER_ABI
)

Definicje Web3 dla account (opens in a new tab) i kontraktu SwapRouter.

def txn_params() -> dict:
    return {
        "from": account.address,
        "value": 0,
        "gas": 300000,
        "nonce": w3.eth.get_transaction_count(account.address),
    }

Parametry transakcji. Potrzebujemy tutaj funkcji, ponieważ nonce (opens in a new tab) musi się zmieniać za każdym razem.

def approve_token(contract: Contract, amount: int):

Zatwierdza limit wydatków tokenów dla SwapRouter.

    txn = contract.functions.approve(SWAP_ROUTER_ADDRESS, amount).build_transaction(txn_params())
    signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)
    tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)

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, używając PRIVATE_KEY. Na koniec używamy w3.eth.send_raw_transaction (opens in a new tab) do wysłania transakcji.

    print(f"Approve transaction sent: {tx_hash.hex()}")
    w3.eth.wait_for_transaction_receipt(tx_hash)
    print("Approve transaction mined.")

w3.eth.wait_for_transaction_receipt (opens in a new tab) czeka, aż transakcja zostanie wydobyta. W razie potrzeby zwraca pokwitowanie.

SELL_PARAMS = {
    "path": WETH_TO_USDC,
    "recipient": account.address,
    "deadline": 2**256 - 1,
    "amountIn": WETH_TRADE_AMOUNT * 10 ** wethusdc_pool.token1.decimals,
    "amountOutMinimum": 0,
}

To są parametry podczas sprzedaży WETH.

def make_buy_params(quote: Quote) -> dict:
    return {
        "path": USDC_TO_WETH,
        "recipient": account.address,
        "deadline": 2**256 - 1,
        "amountIn": int(quote.price*WETH_TRADE_AMOUNT) * 10**wethusdc_pool.token0.decimals,
        "amountOutMinimum": 0,
    }

W przeciwieństwie do SELL_PARAMS, parametry kupna mogą się zmieniać. Kwota wejściowa to koszt 1 WETH, dostępny w quote.

Funkcje buy() i sell() są niemal identyczne. Najpierw zatwierdzamy wystarczający limit wydatków dla SwapRouter, a następnie wywołujemy go z odpowiednią ścieżką i kwotą.

def balances():
    token0_balance = wethusdc_pool.token0.contract.functions.balanceOf(account.address).call()
    token1_balance = wethusdc_pool.token1.contract.functions.balanceOf(account.address).call()

    print(f"{wethusdc_pool.token0.symbol} Balance: {Decimal(token0_balance) / Decimal(10 ** wethusdc_pool.token0.decimals)}")
    print(f"{wethusdc_pool.token1.symbol} Balance: {Decimal(token1_balance) / Decimal(10 ** wethusdc_pool.token1.decimals)}")

Raportuje salda użytkownika w obu walutach.

Ten agent obecnie działa tylko raz. Możesz jednak zmienić go tak, aby działał w sposób ciągły, uruchamiając go z crontab (opens in a new tab) lub opakowując linie 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 jedynie przykład mający na celu nauczenie podstaw. Oto kilka pomysłów na ulepszenia.

Inteligentniejszy trading

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 tego spadku. Prawdopodobnie lepiej byłoby ignorować drobne zmiany i sprzedawać w oparciu o to, jak dużego spadku ceny się spodziewamy.
  • Obecne portfolio. Jeśli 10% Twojego portfolio jest w WETH i uważasz, że cena wzrośnie, prawdopodobnie ma sens kupienie więcej. Ale jeśli 90% Twojego portfolio jest w WETH, możesz być wystarczająco wyeksponowany i nie ma potrzeby kupowania więcej. Odwrotna sytuacja ma miejsce, jeśli spodziewasz się spadku ceny.

Co jeśli chcesz utrzymać swoją strategię w tajemnicy?

Dostawcy AI mogą zobaczyć zapytania, które wysyłasz do ich LLM, co mogłoby ujawnić genialny system tradingowy, który opracowałeś ze swoim agentem. System tradingowy, z którego korzysta zbyt wiele osób, jest bezwartościowy, ponieważ zbyt wiele osób próbuje kupować, kiedy Ty chcesz kupić (i cena rośnie), oraz próbuje sprzedawać, kiedy Ty chcesz sprzedać (i cena spada).

Możesz uruchomić LLM lokalnie, na przykład używając LM-Studio (opens in a new tab), aby uniknąć tego problemu.

Od bota AI do agenta AI

Można śmiało argumentować, że jest to bot AI, a nie agent AI. Implementuje on stosunkowo prostą strategię, która opiera się na predefiniowanych informacjach. Możemy umożliwić samodoskonalenie, na przykład dostarczając listę pul Uniswap v3 i ich najnowsze wartości oraz pytając, która kombinacja ma najlepszą wartość predykcyjną.

Ochrona przed poślizgiem cenowym

Obecnie nie ma ochrony przed poślizgiem cenowym (opens in a new tab). Jeśli obecna wycena wynosi 2000 USD, a oczekiwana cena to 2100 USD, agent dokona zakupu. Jeśli jednak przed zakupem przez agenta koszt wzrośnie do 2200 USD, kupowanie nie ma już sensu.

Aby zaimplementować ochronę przed poślizgiem cenowym, określ wartość amountOutMinimum w liniach 325 i 334 w agent.py (opens in a new tab).

Podsumowanie

Miejmy nadzieję, że teraz wiesz wystarczająco dużo, aby zacząć pracę z agentami AI. Nie jest to kompleksowy przegląd tematu; poświęcono temu całe książki, ale to wystarczy, aby zacząć. Powodzenia!

Zobacz tutaj więcej moich prac (opens in a new tab).