Ruka kwenda kwenye maudhui makuu

Make your own AI trading agent on Ethereum

AI
trading
agent
python
Intermediate
Ori Pomerantz
13 Februari 2026
24 minute read

In this tutorial you learn how to build a simple AI trading agent. This agent works using these steps:

  1. Read the current and past prices of a token, as well as other potentially relevant information
  2. Build a query with this information, along with background information to explain how it might be relevant
  3. Submit the query and receive back a projected price
  4. Trade based on the recommendation
  5. Wait and repeat

This agent demonstrates how to read information, translate it into a query that yields a usable answer, and use that answer. All of these are steps required for an AI agent. This agent is implemented in Python because it is the most common language used in AI.

Why do this?

Automated trading agents allow developers to select and execute a trading strategy. AI agents allow for more complex and dynamic trading strategies, potentially using information and algorithms the developer has not even considered using.

The tools

This tutorial uses Pythonopens in a new tab, the Web3 libraryopens in a new tab, and Uniswap v3opens in a new tab for quotes and trading.

Why Python?

The most widely used language for AI is Pythonopens in a new tab, so we use it here. Don't worry if you don't know Python. The language is very clear, and I will explain exactly what it does.

The Web3 libraryopens in a new tab is the most common Python Ethereum API. It is pretty easy to use.

Trading on the blockchain

There are many distributed exchanges (DEX) that let you trade tokens on Ethereum. However, they tend to have similar exchange rates due to arbitrage.

Uniswapopens in a new tab is a widely used DEX that we can use for both quotes (to see token relative values) and trades.

OpenAI

For a large language model, I chose to get started with OpenAIopens in a new tab. To run the application in this tutorial you'll need to pay for API access. The minimum payment of $5 is more than sufficient.

Development, step by step

To simplify development, we proceed in stages. Each step is a branch in GitHub.

Getting started

There are steps to get started under UNIX or Linux (including WSLopens in a new tab)

  1. If you don't already have it, download and install Pythonopens in a new tab.

  2. Clone the GitHub repository.

    1git clone https://github.com/qbzzt/260215-ai-agent.git -b 01-getting-started
    2cd 260215-ai-agent
  3. Install uvopens in a new tab. The command on your system might be different.

    1pipx install uv
  4. Download the libraries.

    1uv sync
  5. Activate the virtual environment.

    1source .venv/bin/activate
  6. To verify Python and Web3 are working correctly, run python3 and provide it with this program. You can enter it at the >>> prompt; there is no need to create a file.

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

Reading from the blockchain

The next step is to read from the blockchain. To do that, you need to change to the 02-read-quote branch and then use uv to run the program.

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

You should receive a list of Quote objects, each with a timestamp, a price, and the asset (currently always WETH/USDC).

Here is a line-by-line explanation.

1from web3 import Web3
2from web3.contract import Contract
3from decimal import Decimal, ROUND_HALF_UP
4from dataclasses import dataclass
5from datetime import datetime, timezone
6from pprint import pprint
7import time
8import functools
9import sys
Onyesha yote

Import the libraries we need. They are explained below when used.

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

Replaces Python’s print with a version that always flushes output immediately. This is useful in a long-running script because we don't want to wait for status updates or debugging output.

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

A URL to get to mainnet. You can get one from Node as a service or use one of those advertised in Chainlistopens in a new tab.

1BLOCK_TIME_SECONDS = 12
2MINUTE_BLOCKS = int(60 / BLOCK_TIME_SECONDS)
3HOUR_BLOCKS = MINUTE_BLOCKS * 60
4DAY_BLOCKS = HOUR_BLOCKS * 24

An Ethereum mainnet block typically happens every twelve seconds, so these are the number of blocks we'd expect to happen in a time period. Note that this is not an exact figure. When the block proposer is down, that block is skipped, and the time for the next block is 24 seconds. If we wanted to get the exact block for a timestamp, we'd use binary searchopens in a new tab. However, this is close enough for our purposes. Predicting the future is not an exact science.

1CYCLE_BLOCKS = DAY_BLOCKS

The size of the cycle. We review quotes once per cycle and try to estimate the value at the end of the next cycle.

1# The address of the pool we're reading
2WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")

The quote values are taken from the Uniswap 3 USDC/WETH pool at address 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640opens in a new tab. This address is already in checksum form, but it's better to use Web3.to_checksum_addressopens in a new tab to make the code reusable.

1POOL_ABI = [
2 { "name": "slot0", ... },
3 { "name": "token0", ... },
4 { "name": "token1", ... },
5]
6
7ERC20_ABI = [
8 { "name": "symbol", ... },
9 { "name": "decimals", ... }
10]
Onyesha yote

These are the ABIsopens in a new tab for the two contracts we need to contact. To keep the code concise, we include only the functions we need to call.

1w3 = Web3(Web3.HTTPProvider(MAINNET_URL))

Initiate the Web3opens in a new tab library and connect to an Ethereum node.

1@dataclass(frozen=True)
2class ERC20Token:
3 address: str
4 symbol: str
5 decimals: int
6 contract: Contract

This is one way to create a data class in Python. The Contractopens in a new tab data type is used to connect to the contract. Note the (frozen=True). In Python booleansopens in a new tab are defined as True or False, capitalized. This data class is frozen, meaning the fields cannot be modified.

Note the indentation. In contrast to C-derived languagesopens in a new tab, Python uses indentation to denote blocks. The Python interpreter knows that the following definition is not part of this data class because it doesn't start at the same indentation as the data class fields.

1@dataclass(frozen=True)
2class PoolInfo:
3 address: str
4 token0: ERC20Token
5 token1: ERC20Token
6 contract: Contract
7 asset: str
8 decimal_factor: Decimal = 1

The Decimalopens in a new tab type is used for accurately handling decimal fractions.

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

This is the way to define a function in Python. The definition is indented to show it is still part of PoolInfo.

In a function that is part of a data class the first parameter is always self, the data class instance that called here. Here there is another parameter, the block number.

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

If we could read the future, we wouldn't need AI for trading.

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

The syntax for calling a function on the EVM from Web3 is this: <contract object>.functions.<function name>().call(<parameters>). The parameters can be the EVM function's parameters (if any; here there aren't) or named parametersopens in a new tab for modifying blockchain behavior. Here we use one, block_identifier, to specify the block number we wish to run in.

The result is this struct, in array formopens in a new tab. The first value is a function of the exchange rate between the two tokens.

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

To reduce onchain calculations, Uniswap v3 does not store the actual exchange factor but rather its square root. Because the EVM does not support floating point math or fractions, instead of the actual value, the response is price296

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

The raw price we get is the number of token0 we get for each token1. In our pool token0 is USDC (stablecoin with the same value as a US dollar) and token1 is WETHopens in a new tab. The value we really want is the number of dollars per WETH, not the inverse.

The decimal factor is the ratio between the decimal factorsopens in a new tab for the two tokens.

1@dataclass(frozen=True)
2class Quote:
3 timestamp: str
4 price: Decimal
5 asset: str

This data class represents a quote: the price of a specific asset at a given point in time. At this point, the asset field is irrelevant because we use a single pool and therefore have a single asset. However, we will add more assets later.

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()
5
6 return ERC20Token(
7 address=address,
8 symbol=symbol,
9 decimals=decimals,
10 contract=token
11 )
Onyesha yote

This function takes an address and returns information about the token contract at that address. To create a new Web3 Contractopens in a new tab, we provide the address and ABI to 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)
7
8 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 )
Onyesha yote

This function returns everything we need about a specific poolopens in a new tab. The syntax f"<string>" is a formatted stringopens in a new tab.

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

Get a Quote object. The default value for block_number is None (no value).

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

If a block number was not specified, use w3.eth.block_number, which is the latest block number. This is the syntax for an if statementopens in a new tab.

It might look as if it would have been better to just set the default to w3.eth.block_number, but that doesn't work well because it would be the block number at the time the function is defined. In a long-running agent, this would be a 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.asset
7 )

Use the datetime libraryopens in a new tab to format it to a format readable for humans and large language models (LLMs). Use Decimal.quantizeopens in a new tab to round the value to two decimal places.

1def get_quotes(pool: PoolInfo, start_block: int, end_block: int, step: int) -> list[Quote]:

In Python you define a listopens in a new tab that can only contain a specific type using list[<type>].

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

In Python a for loopopens in a new tab typically iterates over a list. The list of block numbers to find quotes in comes from rangeopens in a new tab.

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

For each block number, get a Quote object and append it to the quotes list. Then return that 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_BLOCKS
7)
8
9pprint(quotes)
Onyesha yote

This is the main code of the script. Read the pool information, get twelve quotes, and pprintopens in a new tab them.

Creating a prompt

Next, we need to convert this list of quotes into a prompt for an LLM and obtain an expected future value.

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

The output is now going to be a prompt to an LLM, similar to:

1Given these quotes:
2Asset: WETH/USDC
3 2026-01-20T16:34 3016.21
4 .
5 .
6 .
7 2026-02-01T17:49 2299.10
8
9Asset: WBTC/WETH
10 2026-01-20T16:34 29.84
11 .
12 .
13 .
14 2026-02-01T17:50 33.46
15
16
17What would you expect the value for WETH/USDC to be at time 2026-02-02T17:56?
18
19Provide your answer as a single number rounded to two decimal places,
20without any other text.
Onyesha yote

Notice that there are quotes for two assets here, WETH/USDC and WBTC/WETH. Adding quotes from another asset might improve the prediction accuracy.

What a prompt looks like

This prompt contains three sections, which are pretty common in LLM prompts.

  1. Information. LLMs have a lot of information from their training, but they usually don't have the latest. This is the reason we need to retrieve the latest quotes here. Adding information to a prompt is called retrieval augmented generation (RAG)opens in a new tab.

  2. The actual question. This is what we want to know.

  3. Output formatting instructions. Normally, an LLM will give us an estimate with an explanation of how it arrived at it. This is better for humans, but a computer program just needs the bottom line.

Code explanation

Here is the new code.

1from datetime import datetime, timezone, timedelta

We need to provide the LLM with the time for which we want an estimate. To get a time "n minutes/hours/days" in the future, we use the timedelta classopens in a new tab.

1# The addresses of the pools we're reading
2WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")
3WETHWBTC_ADDRESS = Web3.to_checksum_address("0xCBCdF9626bC03E24f779434178A73a0B4bad62eD")

We have two pools we need to read.

1@dataclass(frozen=True)
2class PoolInfo:
3 .
4 .
5 .
6 reverse: bool = False
7
8 def get_price(self, block: int) -> Decimal:
9 assert block <= w3.eth.block_number, "Block is in the future"
10 sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])
11 raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2 # (token1 per token0)
12 if self.reverse:
13 return 1/(raw_price * self.decimal_factor)
14 else:
15 return raw_price * self.decimal_factor
Onyesha yote

In the WETH/USDC pool, we want to know how many of token0 (USDC) we need to buy one of token1 (WETH). In the WETH/WBTC pool, we want to know how many token1 (WETH) we need to buy one token0 (WBTC, which is wrapped Bitcoin). We need to track whether the pool’s ratio needs to be reversed.

1def read_pool(address: str, reverse: bool = False) -> PoolInfo:
2 .
3 .
4 .
5
6 return PoolInfo(
7 .
8 .
9 .
10
11 asset= f"{token1.symbol}/{token0.symbol}" if reverse else f"{token0.symbol}/{token1.symbol}",
12 reverse=reverse
13 )
Onyesha yote

To know if a pool needs to be reversed, we get to get that as input to read_pool. Also, the asset symbol needs to be set up correctly.

The syntax <a> if <b> else <c> is the Python equivalent of the ternary conditional operatoropens in a new tab, which in a C-derived language would be <b> ? <a> : <c>.

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

This function builds a string that formats a list of Quote objects, assuming they all apply to the same asset.

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

In Python multi-line string literalsopens in a new tab are written as """ .... """.

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

Here, we use the MapReduceopens in a new tab pattern to generate a string for each quote list with format_quotes, then reduce them into a single string for use in the prompt.

1What would you expect the value for {asset} to be at time {expected_time}?
2
3Provide your answer as a single number rounded to two decimal places,
4without any other text.
5 """

The rest of the prompt is as expected.

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)
8
9wethwbtc_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_BLOCKS
15)
Onyesha yote

Review the two pools and obtain quotes from both.

1future_time = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()[0:16]
2
3print(make_prompt(wethusdc_quotes + wethwbtc_quotes, future_time, wethusdc_pool.asset))

Determine the future time point for which we want the estimate, and create the prompt.

Interfacing with an LLM

Next, we prompt an actual LLM and receive an expected future value. I wrote this program using OpenAI, so if you want to use a different provider, you'll need to adjust it.

  1. Get an OpenAI accountopens in a new tab

  2. Fund the accountopens in a new tab—the minimum amount at the time of writing is $5

  3. Create an API keyopens in a new tab

  4. In the command line, export the API key so your program can use it

    1export OPENAI_API_KEY=sk-<the rest of the key goes here>
  5. Checkout and run the agent

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

Here is the new code.

1from openai import OpenAI
2
3open_ai = OpenAI() # The client reads the OPENAI_API_KEY environment variable

Import and instantiate the OpenAI API.

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)

Call the OpenAI API (open_ai.chat.completions.create) to create the response.

1expected_price = Decimal(response.choices[0].message.content.strip())
2current_price = wethusdc_quotes[-1].price
3
4print ("Current price:", wethusdc_quotes[-1].price)
5print(f"In {future_time}, expected price: {expected_price} USD")
6
7if (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")
Onyesha yote

Output the price and provide a buy or sell recommendation.

Testing the predictions

Now that we can generate predictions, we can also use historical data to assess whether we produce useful predictions.

1uv run test-predictor.py

The expected result is similar to:

1Prediction for 2026-01-05T19:50: predicted 3138.93 USD, real 3218.92 USD, error 79.99 USD
2Prediction for 2026-01-06T19:56: predicted 3243.39 USD, real 3221.08 USD, error 22.31 USD
3Prediction for 2026-01-07T20:02: predicted 3223.24 USD, real 3146.89 USD, error 76.35 USD
4Prediction for 2026-01-08T20:11: predicted 3150.47 USD, real 3092.04 USD, error 58.43 USD
5.
6.
7.
8Prediction for 2026-01-31T22:33: predicted 2637.73 USD, real 2417.77 USD, error 219.96 USD
9Prediction for 2026-02-01T22:41: predicted 2381.70 USD, real 2318.84 USD, error 62.86 USD
10Prediction for 2026-02-02T22:49: predicted 2234.91 USD, real 2349.28 USD, error 114.37 USD
11Mean prediction error over 29 predictions: 83.87103448275862068965517241 USD
12Mean change per recommendation: 4.787931034482758620689655172 USD
13Standard variance of changes: 104.42 USD
14Profitable days: 51.72%
15Losing days: 48.28%
Onyesha yote

Most of the tester is identical to the agent, but here are the parts that are new or modified.

1CYCLES_FOR_TEST = 40 # For the backtest, how many cycles we test over
2
3# Get lots of quotes
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)
11
12wethwbtc_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_BLOCKS
18)
Onyesha yote

We look at CYCLES_FOR_TEST (specified as 40 here) days back.

1# Create predictions and check them against real history
2
3total_error = Decimal(0)
4changes = []

There are two types of errors we are interested in. The first, total_error, is simply the sum of errors the predictor made.

To understand the second, changes, we need to remember the agent's purpose. It's not to predict the WETH/USDC ratio (ETH price). It's to issue sell and buy recommendations. If the price is currently $2000 and it predicts $2010 tomorrow, we don't mind if the actual result is $2020 and we earn extra money. But we do mind if it predicted $2010, and bought ETH based on that recommendation, and the price drops to $1990.

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

We can only look at cases where the complete history (the values used for the prediction and the real-world value to compare it to) is available. This means the newest case must be the one that started CYCLES_BACK ago.

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

Use slicesopens in a new tab to get the same number of samples as the number the agent uses. The code between here and the next segment is the same get-a-prediction code we have in the agent.

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

Get the predicted price, the real price, and the price at the time of the prediction. We need the price at the time of the prediction to determine whether the recommendation was to buy or sell.

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

Figure the error, and add it to the total.

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

For changes, we want the monetary impact of buying or selling one ETH. So first, we need to determine the recommendation, then assess how the actual price changed, and whether the recommendation made money (positive change) or cost money (negative change).

1print (f"Mean prediction error over {len(wethusdc_quotes)-CYCLES_BACK} predictions: {total_error / Decimal(len(wethusdc_quotes)-CYCLES_BACK)} USD")
2
3length_changes = Decimal(len(changes))
4mean_change = sum(changes, Decimal(0)) / length_changes
5print (f"Mean change per recommendation: {mean_change} USD")
6var = sum((x - mean_change) ** 2 for x in changes) / length_changes
7print (f"Standard variance of changes: {var.sqrt().quantize(Decimal("0.01"))} USD")

Report the results.

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%}")

Use filteropens in a new tab to count the number of profitable days and the number of costly days. The result is a filter object, which we need to convert to a list to get the length.

Submitting transactions

Now we need to actually submit transactions. However, I don't want to spend real money at this point, before the system is proven. Instead, we will create a local fork of mainnet, and "trade" on that network.

Here are the steps to create a local fork and enable trading.

  1. Install Foundryopens in a new tab

  2. Start anvilopens in a new tab

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

    anvil is listening on the default URL for Foundry, http://localhost:8545opens in a new tab, so we don't need to specify the URL for the cast commandopens in a new tab we use to manipulate the blockchain.

  3. When running in anvil, there are ten test accounts that have ETH—set the environment variables for the first one

    1PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
    2ADDRESS=`cast wallet address $PRIVATE_KEY`
  4. These are the contracts we need to use. SwapRouteropens in a new tab is the Uniswap v3 contract we use to actually trade. We could trade directly through the pool, but this is much easier.

    The two bottom variables are the Uniswap v3 paths required to swap between WETH and USDC.

    1WETH_ADDRESS=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
    2USDC_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
    3POOL_ADDRESS=0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640
    4SWAP_ROUTER=0xE592427A0AEce92De3Edee1F18E0157C05861564
    5WETH_TO_USDC=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
    6USDC_TO_WETH=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
  5. Each of the test accounts has 10,000 ETH. Use the WETH contract to wrap 1000 ETH to obtain 1000 WETH for trading.

    1cast send $WETH_ADDRESS "deposit()" --value 1000ether --private-key $PRIVATE_KEY
  6. Use SwapRouter to trade 500 WETH for USDC.

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

    The approve call creates an allowance that allows SwapRouter to spend some of our tokens. Contracts cannot monitor events, so if we transfer tokens directly to the SwapRouter contract, it wouldn't know it was paid. Instead, we permit the SwapRouter contract to spend a certain amount, and then SwapRouter does it. This is done through a function called by SwapRouter, so it knows if it was successful.

  7. Verify you have enough of both tokens.

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

Now that we have WETH and USDC, we can actually run the agent.

1git checkout 05-trade
2uv run agent.py

The output will look similar to:

1(ai-trading-agent) qbzzt@Ori-Cloudnomics:~/260215-ai-agent$ uv run agent.py
2Current price: 1843.16
3In 2026-02-06T23:07, expected price: 1724.41 USD
4Account balances before trade:
5USDC Balance: 927301.578272
6WETH Balance: 500
7Sell, I expect the price to go down by 118.75 USD
8Approve transaction sent: 74e367ddbb407c1aaf567d87aa5863049991b1d2aa092b6b85195d925e2bd41f
9Approve transaction mined.
10Sell transaction sent: fad1bcf938585c9e90364b26ac7a80eea9efd34c37e5db81e58d7655bcae28bf
11Sell transaction mined.
12Account balances after trade:
13USDC Balance: 929143.797116
14WETH Balance: 499
Onyesha yote

To actually use it, you need a few minor changes.

  • In line 14, change MAINNET_URL to a real access point, such as https://eth.drpc.org
  • In line 28, change PRIVATE_KEY to your own private key
  • Unless you are very wealthy and can buy or sell 1 ETH each day for an unproven agent, you might want to change 29 to decrease WETH_TRADE_AMOUNT

Code explanation

Here is the new code.

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

The same variables we used in step 4.

1WETH_TRADE_AMOUNT=1

The amount to trade.

1ERC20_ABI = [
2 { "name": "symbol", ... },
3 { "name": "decimals", ... },
4 { "name": "balanceOf", ...},
5 { "name": "approve", ...}
6]

To actually trade, we need the approve function. We also want to show balances before and after, so we also need balanceOf.

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

In the SwapRouter ABI we just need exactInput. There is a related function, exactOutput, we could use to buy exactly one WETH, but for simplicity we just use exactInput in both cases.

1account = w3.eth.account.from_key(PRIVATE_KEY)
2swap_router = w3.eth.contract(
3 address=SWAP_ROUTER_ADDRESS,
4 abi=SWAP_ROUTER_ABI
5)

The Web3 definitions for the accountopens in a new tab and the SwapRouter contract.

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 }

The transaction parameters. We need a function here because the nonceopens in a new tab must change each time.

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

Approve a token allowance for 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)

This is how we send a transaction in Web3. First we use the Contract objectopens in a new tab to build the transaction. Then we use web3.eth.account.sign_transactionopens in a new tab to sign the transaction, using PRIVATE_KEY. Finally, we use w3.eth.send_raw_transactionopens in a new tab to send the transaction.

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_receiptopens in a new tab waits until the transaction is mined. It returns the receipt if needed.

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}

These are the parameters when selling 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 }

In contrast to SELL_PARAMS, the buy parameters can change. The input amount is the cost of 1 WETH, as available in 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.")
10
11
12def 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.")
Onyesha yote

The buy() and sell() functions are nearly identical. First we approve a sufficient allowance for SwapRouter, and then we call it with the correct path and amount.

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()
4
5 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)}")

Report user balances in both currencies.

1print("Account balances before trade:")
2balances()
3
4if (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()
10
11print("Account balances after trade:")
12balances()
Onyesha yote

This agent currently only works once. However, you can change it to work continuously either by running it from crontabopens in a new tab or by wrapping lines 368-400 in a loop and using time.sleepopens in a new tab to wait until it is time for the next cycle.

Possible improvements

This is not a full production version; it is merely an example to teach the basics. Here are some ideas for improvements.

Smarter trading

There are two important facts the agent ignores when deciding what to do.

  • The magnitude of anticipated change. The agent sells a fixed amount of WETH if the price is expected to decline, regardless of the magnitude of the decline. Arguably, it would be better to ignore minor changes and sell based on how much we expect the price to decline.
  • The current portfolio. If 10% of your portfolio is in WETH and you think the price will go up, it probably makes sense to buy more. But if 90% of your portfolio is in WETH, you may be sufficiently exposed, and there is no need to buy more. The reverse is true if you expect the price to go down.

What if you want to keep your trading strategy a secret?

AI vendors can see the queries you send to their LLMs, which could expose the genius trading system you developed with your agent. A trading system that too many people use is worthless because too many people try to buy when you want to buy (and the price goes up) and try to sell when you want to sell (and the price goes down).

You can run an LLM locally, for example, using LM-Studioopens in a new tab, to avoid this problem.

From AI bot to AI agent

You can make a good case that this is an AI bot, not an AI agent. It implements a relatively simple strategy that relies on predefined information. We can enable self-improvement, for example, by providing a list of Uniswap v3 pools and their latest values and asking which combination has the best predictive value.

Slippage protection

Currently there is no slippage protectionopens in a new tab. If the current quote is $2000, and the expected price is $2100, the agent will buy. However, if before the agent buys the cost rises to $2200, it makes no sense to buy anymore.

To implement slippage protection, specify an amountOutMinimum value in lines 325 and 334 of agent.pyopens in a new tab.

Conclusion

Hopefully, now you know enough to get started with AI agents. This is not a comprehensive overview of the subject; there are whole books dedicated to that, but this is enough to get you started. Good luck!

See here for more of my workopens in a new tab.

Ukurasa ulihaririwa mwisho: 10 Februari 2026

Umesaidika na mafunzo haya?