在以太坊上打造你自己的AI交易代理

Ethereum.org 发布于 2026-02-13 阅读 56

本文详细教程教你如何用Python、Web3、Uniswap v3和OpenAI构建一个简单的AI交易代理。该代理读取过去和当前的代币价格,结合其他资产信息构造提示词,通过LLM获得未来价格预测,根据预测决定买入或卖出,并自动执行交易。文章逐步讲解从读取链上数据、构造提示、调用LLM到提交交易的完整流程,并附带测试预测准确性的代码。最后讨论了改进方向,如优化交易策略、本地运行LLM保护策略隐私、以及从AI bot升级为AI agent。适合有Python基础并对区块链、AI交易感兴趣的开发者。

在本教程中,你将学习如何构建一个简单的 AI 交易代理。该代理按以下步骤工作:

  1. 读取某个代币的当前及历史价格,以及其他潜在的相关信息。
  2. 利用这些信息以及背景信息构建一个查询,说明这些信息之间可能的关联。
  3. 提交查询并接收一个预测价格。
  4. 根据建议进行交易。
  5. 等待并重复。

这个代理展示了如何读取信息、将其转换为能产生可用答案的查询,以及如何使用该答案。这些都是 AI 代理所需的步骤。该代理使用 Python 实现,因为 Python 是 AI 领域最常用的语言。

为什么要这样做?

自动化交易代理让开发者可以选择并执行交易策略。AI 代理支持更复杂和动态的交易策略,可能利用开发者甚至未曾考虑过的信息和算法。

使用的工具

本教程使用 PythonWeb3 库以及 Uniswap v3来获取报价和执行交易。

为什么选择 Python?

AI 领域最广泛使用的语言是 Python,因此我们在这里使用它。如果你不了解 Python,不用担心,该语言非常清晰,我会准确解释它的功能。

Web3 库是最常用的 Python 以太坊 API,它相当易用。

在区块链上交易

有许多去中心化交易所(DEX)允许你在以太坊上交易代币。不过,由于套利的存在,它们的汇率通常很接近。

Uniswap是一个广泛使用的 DEX,我们可以用它来获取报价(查看代币相对价值)和执行交易。

OpenAI

对于大语言模型,我选择从 OpenAI开始。要运行本教程中的应用程序,你需要为 API 访问付费。最低支付 5 美元就足够了。

开发,一步一步来

为了简化开发,我们分阶段进行。每一步对应 GitHub 上的一个分支。

起步

在 UNIX 或 Linux(包括 WSL)下,需要完成以下起步步骤。

  1. 如果还没有安装,请下载并安装 Python

  2. 克隆 GitHub 仓库。

git clone https://github.com/qbzzt/260215-ai-agent.git -b 01-getting-started
cd 260215-ai-agent
  1. 安装 uv。你系统上的命令可能不同。
pipx install uv
  1. 下载库。
uv sync
  1. 激活虚拟环境。
source .venv/bin/activate
  1. 要验证 Python 和 Web3 是否正常工作,运行 python3 并提供以下程序。你可以在 >>> 提示符下输入,无需创建文件。
from web3 import Web3
MAINNET_URL = "https://eth.drpc.org"
w3 = Web3(Web3.HTTPProvider(MAINNET_URL))
w3.eth.block_number
quit()

从区块链读取

下一步是从区块链读取数据。为此,你需要切换到 02-read-quote 分支,然后使用 uv 运行程序。

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

你应该会收到一个 Quote 对象列表,每个对象包含时间戳、价格和资产(目前始终是 WETH/USDC)。

以下是逐行解释。

from web3 import Web3
from web3.contract import Contract
from decimal import Decimal, ROUND_HALF_UP
from dataclasses import dataclass
from datetime import datetime, timezone
from pprint import pprint
import time
import functools
import sys

导入所需的库,使用时会解释它们的作用。

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

将 Python 的 print 替换为始终立即刷新输出的版本。这在长时间运行的脚本中很有用,因为我们不想等待状态更新或调试输出。

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

访问主网的 URL。你可以从节点即服务获取,或者使用 Chainlist中列出的 URL。

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

以太坊主网区块通常每 12 秒产生一个,因此这些数字表示在一个时间段内预期会发生的区块数量。请注意,这并非精确数字。当区块提议者离线时,该区块会被跳过,下一个区块的时间变为 24 秒。如果我们想获得某个时间戳的精确区块,需要使用二分查找。不过,对于我们的目的来说,这已经足够接近了——预测未来本就不是精确科学。

CYCLE_BLOCKS = DAY_BLOCKS

周期的长度。我们每个周期回顾一次报价,并尝试预测下一个周期结束时的价值。

## 我们正在读取的池子的地址
WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")

报价值取自地址为 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640 的 Uniswap 3 USDC/WETH 池。此地址已经是校验和格式,但最好使用 Web3.to_checksum_address 以确保代码可复用。

POOL_ABI = [\
    { "name": "slot0", ... },\
    { "name": "token0", ... },\
    { "name": "token1", ... },\
]

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

这些是我们需要联系的两个合约的 ABI。为了代码简洁,我们只包含需要调用的函数。

w3 = Web3(Web3.HTTPProvider(MAINNET_URL))

初始化 Web3 库并连接到一个以太坊节点。

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

这是在 Python 中创建数据类的一种方式。Contract 数据类型用于连接合约。注意 (frozen=True)。在 Python 中,布尔值 定义为 TrueFalse,首字母大写。这个数据类是 frozen 的,意味着字段不能被修改。

注意缩进。与 C 派生语言 不同,Python 使用缩进来表示代码块。Python 解释器知道以下定义不属于这个数据类,因为它没有以数据类字段相同的缩进开始。

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

Decimal 类型用于精确处理小数。

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

这是在 Python 中定义函数的方式。函数定义缩进以表明它仍然是 PoolInfo 的一部分。

在属于数据类的函数中,第一个参数总是 self,即调用该函数的数据类实例。这里还有一个参数,即区块号。

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

如果我们能读取未来,就不需要 AI 进行交易了。

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

从 Web3 调用 EVM 函数的语法如下:<合约对象>.functions.<函数名称>().call(<参数>)。参数可以是 EVM 函数的参数(如果有的话,这里没有),也可以是用于修改区块链行为的命名参数。这里我们使用一个参数 block_identifier 来指定我们希望运行在哪个区块号

结果是这个结构体,以数组形式返回。第一个值是两种代币之间汇率的函数。

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

为了减少链上计算量,Uniswap v3 存储的不是实际的汇率因子,而是其平方根。由于 EVM 不支持浮点数或分数,返回的值是 price⋅2^96。

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

我们得到的原始价格是每 token1 可以换得 token0 的数量。在我们的池中,token0 是 USDC(与美元价值相同的稳定币),token1WETH。我们真正想要的是每 WETH 的美元数量,而不是相反。

decimal_factor 是两种代币 decimal 因子 之间的比率。

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

这个数据类代表一个报价:特定资产在某个时间点的价格。目前 asset 字段无关紧要,因为我们只使用一个池子,只有一种资产。不过,稍后我们会增加更多资产。

def read_token(address: str) -> ERC20Token:
    token = w3.eth.contract(address=address, abi=ERC20_ABI)
    symbol = token.functions.symbol().call()
    decimals = token.functions.decimals().call()

    return ERC20Token(
        address=address,
        symbol=symbol,
        decimals=decimals,
        contract=token
    )

这个函数接受一个地址,返回该地址上代币合约的信息。要创建一个新的 Web3 Contract,我们向 w3.eth.contract 提供地址和 ABI。

def read_pool(address: str) -> PoolInfo:
    pool_contract = w3.eth.contract(address=address, abi=POOL_ABI)
    token0Address = pool_contract.functions.token0().call()
    token1Address = pool_contract.functions.token1().call()
    token0 = read_token(token0Address)
    token1 = read_token(token1Address)

    return PoolInfo(
        address=address,
        asset=f"{token1.symbol}/{token0.symbol}",
        token0=token0,
        token1=token1,
        contract=pool_contract,
        decimal_factor=Decimal(10) ** Decimal(token0.decimals - token1.decimals)
    )

这个函数返回关于特定池子的所有信息。语法 f"<string>" 是一个格式化字符串

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

获取一个 Quote 对象。block_number 的默认值是 None(无值)。

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

如果没有指定区块号,就使用 w3.eth.block_number,即最新的区块号。这是 if 语句 的语法。

看起来直接设置默认值为 w3.eth.block_number 可能会更好,但那样做效果不好,因为它将是函数定义时的区块号。在长时间运行的代理中,这会成为问题。

    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
    )

使用 datetime 将其格式化为人类和大语言模型(LLM)可读的格式。使用 Decimal.quantize 将值四舍五入到两位小数。

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

在 Python 中,你可以使用 list[<type>] 定义一个只能包含特定类型的列表

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

在 Python 中,for 循环 通常遍历一个列表。要查找报价的区块号列表来自 range

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

对于每个区块号,获取一个 Quote 对象并追加到 quotes 列表中,然后返回该列表。

pool = read_pool(WETHUSDC_ADDRESS)
quotes = get_quotes(
    pool,
    w3.eth.block_number - 12*CYCLE_BLOCKS,
    w3.eth.block_number,
    CYCLE_BLOCKS
)

pprint(quotes)

这是脚本的主代码。读取池子信息,获取 12 个报价,并使用 pprint 打印它们。

创建提示词

接下来,我们需要将这个报价列表转换为 LLM 的提示词,并获得一个预期的未来价格。

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

现在的输出将是一个给 LLM 的提示词,类似于:

Given these quotes:
Asset: WETH/USDC
        2026-01-20T16:34 3016.21
        .
        .
        .
        2026-02-01T17:49 2299.10

Asset: WBTC/WETH
        2026-01-20T16:34 29.84
        .
        .
        .
        2026-02-01T17:50 33.46

What would you expect the value for WETH/USDC to be at time 2026-02-02T17:56?

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

注意这里有两种资产的报价:WETH/USDCWBTC/WETH。添加另一种资产的报价可能会提高预测准确性。

提示词的结构

这个提示词包含三个常见部分:

  1. 信息。LLM 从训练中获得了大量信息,但它们通常没有最新的信息。这就是我们需要在此处检索最新报价的原因。向提示词添加信息称为检索增强生成(RAG)

  2. 实际问题。这是我们想要知道的内容。

  3. 输出格式指令。通常,LLM 会给出一个估计值,并附带如何得出该估计值的解释。这对人类来说更好,但计算机程序只需要底线。

代码解释

以下是新代码。

from datetime import datetime, timezone, timedelta

我们需要向 LLM 提供我们希望估计的时间点。要获得未来“n 分钟/小时/天”的时间,我们使用 timedelta

## 我们正在读取的池子的地址
WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")
WETHWBTC_ADDRESS = Web3.to_checksum_address("0xCBCdF9626bC03E24f779434178A73a0B4bad62eD")

我们需要读取两个池子。

@dataclass(frozen=True)
class PoolInfo:
    .
    .
    .
    reverse: bool = False

    def get_price(self, block: int) -> Decimal:
        assert block <= w3.eth.block_number, "Block is in the future"
        sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])
        raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2  # (token1 per token0)
        if self.reverse:
            return 1/(raw_price * self.decimal_factor)
        else:
            return raw_price * self.decimal_factor

在 WETH/USDC 池中,我们想知道需要多少 token0(USDC)才能买到一个 token1(WETH)。在 WETH/WBTC 池中,我们想知道需要多少 token1(WETH)才能买到一个 token0(WBTC,即封装比特币)。我们需要跟踪池子的比率是否需要反转。

def read_pool(address: str, reverse: bool = False) -> PoolInfo:
    .
    .
    .

    return PoolInfo(
        .
        .
        .

        asset= f"{token1.symbol}/{token0.symbol}" if reverse else f"{token0.symbol}/{token1.symbol}",
        reverse=reverse
    )

要知道一个池子是否需要反转,我们需要将其作为 read_pool 的输入。此外,资产符号也需要正确设置。

语法 <a> if <b> else <c> 是 Python 中三元条件运算符的等价形式,在 C 派生语言中会是 <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

这个函数构建一个字符串,格式化一个 Quote 对象列表,假设它们都适用于同一种资产。

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

在 Python 中,多行字符串字面量 写作 """ .... """

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

这里,我们使用 MapReduce 模式:对每个报价列表调用 format_quotes 生成字符串,然后将它们合并成一个单独字符串用于提示词。

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.
    """

提示词的其余部分符合预期。

wethusdc_pool = read_pool(WETHUSDC_ADDRESS, True)
wethusdc_quotes = get_quotes(
    wethusdc_pool,
    w3.eth.block_number - 12*CYCLE_BLOCKS,
    w3.eth.block_number,
    CYCLE_BLOCKS,
)

wethwbtc_pool = read_pool(WETHWBTC_ADDRESS)
wethwbtc_quotes = get_quotes(
    wethwbtc_pool,
    w3.eth.block_number - 12*CYCLE_BLOCKS,
    w3.eth.block_number,
    CYCLE_BLOCKS
)

回顾两个池子并从两者获取报价。

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

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

确定我们希望估计的未来时间点,并创建提示词。

与 LLM 交互

接下来,我们将提示实际的 LLM 并接收预期的未来价格。我使用 OpenAI 编写了这个程序,所以如果你使用不同的服务商,需要相应调整。

  1. 获取 OpenAI 账户

  2. 为账户充值——撰写本文时的最低金额为 5 美元

  3. 创建 API 密钥

  4. 在命令行中导出 API 密钥,以便你的程序可以使用它

export OPENAI_API_KEY=sk-<密钥的其余部分>
  1. 切换分支并运行代理
git checkout 04-interface-llm
uv run agent.py

以下是新代码。

from openai import OpenAI

open_ai = OpenAI()  # 客户端会读取 OPENAI_API_KEY 环境变量

导入并实例化 OpenAI API。

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

调用 OpenAI API(open_ai.chat.completions.create)来生成响应。

expected_price = Decimal(response.choices[0].message.content.strip())
current_price = wethusdc_quotes[-1].price

print ("Current price:", wethusdc_quotes[-1].price)
print(f"In {future_time}, expected price: {expected_price} USD")

if (expected_price > current_price):
    print(f"Buy, I expect the price to go up by {expected_price - current_price} USD")
else:
    print(f"Sell, I expect the price to go down by {current_price - expected_price} USD")

输出价格并提供买入或卖出建议。

测试预测

现在我们可以生成预测了,也可以使用历史数据来评估我们产生的预测是否有用。

uv run test-predictor.py

预期结果类似于:

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

测试器的大部分代码与代理相同,但以下是新增或修改的部分。

CYCLES_FOR_TEST = 40 # 在回测中,我们测试多少个周期

## 获取大量报价
wethusdc_pool = read_pool(WETHUSDC_ADDRESS, True)
wethusdc_quotes = get_quotes(
    wethusdc_pool,
    w3.eth.block_number - CYCLE_BLOCKS*CYCLES_FOR_TEST,
    w3.eth.block_number,
    CYCLE_BLOCKS,
)

wethwbtc_pool = read_pool(WETHWBTC_ADDRESS)
wethwbtc_quotes = get_quotes(
    wethwbtc_pool,
    w3.eth.block_number - CYCLE_BLOCKS*CYCLES_FOR_TEST,
    w3.eth.block_number,
    CYCLE_BLOCKS
)

我们回顾 CYCLES_FOR_TEST(这里指定为 40)天之前的数据。

## 创建预测并与真实历史对比

total_error = Decimal(0)
changes = []

我们关心两种类型的误差。第一种是 total_error,即预测器产生的误差总和。

要理解第二个指标 changes,我们需要回想一下代理的目的。它不是预测 WETH/USDC 比率(ETH 价格),而是发出卖出和买入建议。如果当前价格是 2000 美元,它预测明天是 2010 美元,那么实际结果为 2020 美元我们并不介意,因为我们会赚更多钱。但如果它预测 2010 美元,我们基于该建议买入,结果价格却跌到了 1990 美元,那我们就介意了。

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

我们只能查看完整历史(用于预测的值和用于比较的真实值)都存在的案例。这意味着最新的案例必须是从 CYCLES_BACK 之前开始的。

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

使用切片获取与代理使用的样本数量相同的样本。此处与下一段之间的代码与代理中获取预测的代码相同。

    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

获取预测价格、真实价格以及预测时的价格。我们需要预测时的价格来确定建议是买入还是卖出。

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

计算误差,并累加到总计中。

    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)

对于 changes,我们想要的是买入或卖出 1 个 ETH 带来的财务影响。因此,首先我们需要确定建议,然后评估实际价格如何变化,以及建议是赚钱(正变化)还是亏钱(负变化)。

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

报告结果。

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

使用 filter 统计盈利天数和亏损天数。结果是一个 filter 对象,我们需要将其转换为列表才能获取长度。

提交交易

现在我们需要实际提交交易。不过,在系统被证明有效之前,我不想花真钱。因此,我们将创建一个本地主网分叉,并在该网络上“交易”。

以下是创建本地分叉并启用交易的步骤。

  1. 安装 Foundry

  2. 启动 anvil

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

anvil 监听 Foundry 的默认 URL http://localhost:8545,因此我们不需要为用于操作区块链的 cast 命令 指定 URL。

  1. anvil 中运行时,有十个测试账户拥有 ETH——为第一个账户设置环境变量
PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
ADDRESS=`cast wallet address $PRIVATE_KEY`
  1. 以下是我们需要使用的合约。SwapRouter 是我们用于实际交易的 Uniswap v3 合约。我们可以直接通过池子交易,但使用 SwapRouter 要容易得多。

底部两个变量是 WETH 和 USDC 之间交换所需的 Uniswap v3 路径。

WETH_ADDRESS=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
USDC_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
POOL_ADDRESS=0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640
SWAP_ROUTER=0xE592427A0AEce92De3Edee1F18E0157C05861564
WETH_TO_USDC=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
USDC_TO_WETH=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
  1. 每个测试账户拥有 10,000 ETH。使用 WETH 合约将 1000 ETH 封装以获得 1000 WETH 用于交易。
cast send $WETH_ADDRESS "deposit()" --value 1000ether --private-key $PRIVATE_KEY
  1. 使用 SwapRouter 将 500 WETH 兑换为 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

approve 调用创建了一个额度,允许 SwapRouter 花费我们部分代币。合约无法监听事件,因此如果我们直接将代币转账给 SwapRouter 合约,它不会知道已经付款。相反,我们允许 SwapRouter 合约花费一定数量,然后 SwapRouter 执行转账。这是通过 SwapRouter 调用的一个函数完成的,因此它可以知道是否成功。

  1. 验证你拥有足够的两种代币。
cast call $WETH_ADDRESS "balanceOf(address)" $ADDRESS | cast from-wei
echo `cast call $USDC_ADDRESS "balanceOf(address)" $ADDRESS | cast to-dec`/10^6 | bc

现在我们有了 WETH 和 USDC,就可以实际运行代理了。

git checkout 05-trade
uv run agent.py

输出将类似于:

(ai-trading-agent) qbzzt@Ori-Cloudnomics:~/260215-ai-agent$ uv run agent.py
Current price: 1843.16
In 2026-02-06T23:07, expected price: 1724.41 USD
Account balances before trade:
USDC Balance: 927301.578272
WETH Balance: 500
Sell, I expect the price to go down by 118.75 USD
Approve transaction sent: 74e367ddbb407c1aaf567d87aa5863049991b1d2aa092b6b85195d925e2bd41f
Approve transaction mined.
Sell transaction sent: fad1bcf938585c9e90364b26ac7a80eea9efd34c37e5db81e58d7655bcae28bf
Sell transaction mined.
Account balances after trade:
USDC Balance: 929143.797116
WETH Balance: 499

要实际使用它,你需要做一些小的修改。

  • 在第 14 行,将 MAINNET_URL 改为真实的接入点,例如 https://eth.drpc.org
  • 在第 28 行,将 PRIVATE_KEY 改为你自己的私钥
  • 除非你非常富有并且每天可以为未经证实的代理买入或卖出 1 ETH,否则你可能想要修改第 29 行,减小 WETH_TRADE_AMOUNT
代码解释

以下是新代码。

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

与我们在步骤 4 中使用的变量相同。

WETH_TRADE_AMOUNT=1

交易的数量。

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

为了实际交易,我们需要 approve 函数。我们还希望显示交易前后的余额,因此还需要 balanceOf

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

SwapRouter ABI 中,我们只需要 exactInput。还有一个相关的函数 exactOutput,我们可以用来精确购买 1 个 WETH,但为了简单起见,我们在两种情况下都使用 exactInput

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

Web3 中 accountSwapRouter 合约的定义。

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

交易参数。我们需要一个函数,因为 nonce 每次必须更改。

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

批准 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)

这是在 Web3 中发送交易的方式。首先,我们使用 Contract 对象 构建交易。然后,我们使用 web3.eth.account.sign_transaction 并使用 PRIVATE_KEY 对交易进行签名。最后,我们使用 w3.eth.send_raw_transaction 发送交易。

    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 等待交易被挖矿。如果需要,它会返回收据。

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

这些是卖出 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,
    }

SELL_PARAMS 相反,买入参数可以变化。输入数量是 1 个 WETH 的成本,如 quote 所示。

def buy(quote: Quote):
    buy_params = make_buy_params(quote)
    approve_token(wethusdc_pool.token0.contract, buy_params["amountIn"])
    txn = swap_router.functions.exactInput(buy_params).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)
    print(f"Buy transaction sent: {tx_hash.hex()}")
    w3.eth.wait_for_transaction_receipt(tx_hash)
    print("Buy transaction mined.")

def sell():
    approve_token(wethusdc_pool.token1.contract,
                  WETH_TRADE_AMOUNT * 10**wethusdc_pool.token1.decimals)
    txn = swap_router.functions.exactInput(SELL_PARAMS).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)
    print(f"Sell transaction sent: {tx_hash.hex()}")
    w3.eth.wait_for_transaction_receipt(tx_hash)
    print("Sell transaction mined.")

buy()sell() 函数几乎相同。首先我们为 SwapRouter 批准足够的额度,然后使用正确的路径和数量调用它。

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

报告用户两种货币的余额。

print("Account balances before trade:")
balances()

if (expected_price > current_price):
    print(f"Buy, I expect the price to go up by {expected_price - current_price} USD")
    buy(wethusdc_quotes[-1])
else:
    print(f"Sell, I expect the price to go down by {current_price - expected_price} USD")
    sell()

print("Account balances after trade:")
balances()

这个代理目前只运行一次。但是,你可以通过从 crontab 运行它,或者将第 368-400 行包装在一个循环中并使用 time.sleep 等待下一个周期,来使其连续运行。

可能的改进

这不是一个完整的生产版本,它只是一个教授基础的示例。以下是一些改进思路。

更智能的交易

代理在决定做什么时忽略了两个重要事实。

  • 预期变化的幅度。无论价格下跌幅度多大,只要预期价格下跌,代理都会卖出固定数量的 WETH。更合理的做法是忽略微小变化,仅根据预期下跌的幅度来决定卖出数量。
  • 当前投资组合。如果你的投资组合中有 10% 是 WETH,并且你认为价格会上涨,那么买入更多可能是有意义的。但如果你的投资组合中有 90% 是 WETH,你可能已经足够暴露,无需再买入。如果你预期价格下跌,情况则相反。

如果你希望保密交易策略该怎么办?

AI 服务商可以看到你发送给它们的 LLM 的查询,这可能会暴露你使用代理开发的出色交易系统。太多人使用的交易系统是没有价值的,因为太多人试图在你买入时买入(导致价格上涨),并在你卖出时卖出(导致价格下跌)。

你可以在本地运行 LLM,例如使用 LM-Studio,以避免这个问题。

从 AI 机器人到 AI 代理

你可以很有理由地说这是一个 AI 机器人,而不是 AI 代理。它实现了一个相对简单的策略,依赖于预定义的信息。我们可以启用自我改进,例如,通过提供一个 Uniswap v3 池子列表及其最新值,并询问哪种组合具有最佳预测价值。

滑点保护

目前没有滑点保护。如果当前报价为2000美元,预期价格为2100美元,代理会进行购买。但如果在代理买入前价格已上涨至2200美元,那么继续购买就不再合理了。 要实现滑点保护,请在 agent.py 的第 325 和 334 行中指定 amountOutMinimum 值。

结论

希望现在你已经了解了足够的知识,可以开始使用AI代理了。这并不是该主题的全面概述,毕竟有整本书专门讲述这个内容,但这些知识足以让你入门。好运!

  • 原文链接: ethereum.org/developers/...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论