在以太坊上打造你自己的AI交易代理
本文详细教程教你如何用Python、Web3、Uniswap v3和OpenAI构建一个简单的AI交易代理。该代理读取过去和当前的代币价格,结合其他资产信息构造提示词,通过LLM获得未来价格预测,根据预测决定买入或卖出,并自动执行交易。文章逐步讲解从读取链上数据、构造提示、调用LLM到提交交易的完整流程,并附带测试预测准确性的代码。最后讨论了改进方向,如优化交易策略、本地运行LLM保护策略隐私、以及从AI bot升级为AI agent。适合有Python基础并对区块链、AI交易感兴趣的开发者。
在本教程中,你将学习如何构建一个简单的 AI 交易代理。该代理按以下步骤工作:
- 读取某个代币的当前及历史价格,以及其他潜在的相关信息。
- 利用这些信息以及背景信息构建一个查询,说明这些信息之间可能的关联。
- 提交查询并接收一个预测价格。
- 根据建议进行交易。
- 等待并重复。
这个代理展示了如何读取信息、将其转换为能产生可用答案的查询,以及如何使用该答案。这些都是 AI 代理所需的步骤。该代理使用 Python 实现,因为 Python 是 AI 领域最常用的语言。
为什么要这样做?
自动化交易代理让开发者可以选择并执行交易策略。AI 代理支持更复杂和动态的交易策略,可能利用开发者甚至未曾考虑过的信息和算法。
使用的工具
本教程使用 Python、Web3 库以及 Uniswap v3来获取报价和执行交易。
为什么选择 Python?
AI 领域最广泛使用的语言是 Python,因此我们在这里使用它。如果你不了解 Python,不用担心,该语言非常清晰,我会准确解释它的功能。
Web3 库是最常用的 Python 以太坊 API,它相当易用。
在区块链上交易
有许多去中心化交易所(DEX)允许你在以太坊上交易代币。不过,由于套利的存在,它们的汇率通常很接近。
Uniswap是一个广泛使用的 DEX,我们可以用它来获取报价(查看代币相对价值)和执行交易。
OpenAI
对于大语言模型,我选择从 OpenAI开始。要运行本教程中的应用程序,你需要为 API 访问付费。最低支付 5 美元就足够了。
开发,一步一步来
为了简化开发,我们分阶段进行。每一步对应 GitHub 上的一个分支。
起步
在 UNIX 或 Linux(包括 WSL)下,需要完成以下起步步骤。
-
如果还没有安装,请下载并安装 Python。
-
克隆 GitHub 仓库。
git clone https://github.com/qbzzt/260215-ai-agent.git -b 01-getting-started
cd 260215-ai-agent
- 安装
uv。你系统上的命令可能不同。
pipx install uv
- 下载库。
uv sync
- 激活虚拟环境。
source .venv/bin/activate
- 要验证 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 中,布尔值 定义为 True 或 False,首字母大写。这个数据类是 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(与美元价值相同的稳定币),token1 是 WETH。我们真正想要的是每 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/USDC 和 WBTC/WETH。添加另一种资产的报价可能会提高预测准确性。
提示词的结构
这个提示词包含三个常见部分:
-
信息。LLM 从训练中获得了大量信息,但它们通常没有最新的信息。这就是我们需要在此处检索最新报价的原因。向提示词添加信息称为检索增强生成(RAG)。
-
实际问题。这是我们想要知道的内容。
-
输出格式指令。通常,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 编写了这个程序,所以如果你使用不同的服务商,需要相应调整。
export OPENAI_API_KEY=sk-<密钥的其余部分>
- 切换分支并运行代理
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 对象,我们需要将其转换为列表才能获取长度。
提交交易
现在我们需要实际提交交易。不过,在系统被证明有效之前,我不想花真钱。因此,我们将创建一个本地主网分叉,并在该网络上“交易”。
以下是创建本地分叉并启用交易的步骤。
anvil --fork-url https://eth.drpc.org --block-time 12
anvil 监听 Foundry 的默认 URL http://localhost:8545,因此我们不需要为用于操作区块链的 cast 命令 指定 URL。
- 在
anvil中运行时,有十个测试账户拥有 ETH——为第一个账户设置环境变量
PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
ADDRESS=`cast wallet address $PRIVATE_KEY`
- 以下是我们需要使用的合约。
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
- 每个测试账户拥有 10,000 ETH。使用 WETH 合约将 1000 ETH 封装以获得 1000 WETH 用于交易。
cast send $WETH_ADDRESS "deposit()" --value 1000ether --private-key $PRIVATE_KEY
- 使用
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 调用的一个函数完成的,因此它可以知道是否成功。
- 验证你拥有足够的两种代币。
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 中 account 和 SwapRouter 合约的定义。
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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~