在以太坊上创建你自己的 AI 交易代理
在本教程中,您将学习如何构建一个简单的 AI 交易代理。 该代理的工作步骤如下:
- 读取代币的当前和过去价格以及其他潜在的相关信息
- 利用此信息和用于解释其相关性的背景信息来构建查询
- 提交查询并接收预测价格
- 根据建议进行交易
- 等待并重复
此代理演示了如何读取信息,将其转换为可生成可用答案的查询,以及如何使用该答案。 所有这些都是 AI 代理所需的步骤。 此代理使用 Python 实现,因为它是人工智能领域最常用的语言。
为什么要这样做?
自动化交易代理允许开发者选择并执行交易策略。 AI 代理 允许采用更复杂、更动态的交易策略,可能会使用开发者甚至没有考虑过使用的信息和算法。
工具
本教程使用 Python (opens in a new tab)、Web3 程序库 (opens in a new tab) 和 Uniswap v3 (opens in a new tab) 进行报价和交易。
为什么使用 Python?
AI 领域使用最广泛的语言是 Python (opens in a new tab),所以我们在这里使用它。 如果您不了解 Python,也不用担心。 这种语言非常清晰易懂,我会准确地解释它的作用。
Web3 程序库 (opens in a new tab) 是最常见的 Python 以太坊应用程序接口。 它非常易于使用。
在区块链上交易
有许多去中心化交易所 (DEX) 可以让您在以太坊上交易代币。 然而,由于存在套利,它们的汇率往往类似。
Uniswap (opens in a new tab) 是一个广泛使用的去中心化交易所 (DEX),我们可以用它来进行报价(查看代币的相对价值)和交易。
OpenAI
对于大语言模型,我选择从 OpenAI (opens in a new tab) 开始。 要运行本教程中的应用,您需要付费访问其应用程序接口。 最低支付 5 美元就绰绰有余了。
逐步开发
为简化开发,我们分阶段进行。 每个步骤都是 GitHub 中的一个分支。
入门
在 UNIX 或 Linux(包括 WSL (opens in a new tab))下,您可以按照以下步骤开始
-
如果您还没有安装 Python (opens in a new tab),请下载并安装它。
-
克隆 GitHub 存储库。
1git clone https://github.com/qbzzt/260215-ai-agent.git -b 01-getting-started2cd 260215-ai-agent -
安装
uv(opens in a new tab)。 您系统上的命令可能有所差异。1pipx install uv -
下载程序库。
1uv sync -
激活虚拟环境。
1source .venv/bin/activate -
为了验证 Python 和 Web3 是否正常工作,请运行
python3并向其提供此程序。 您可以在>>>提示符处输入,无需创建文件。1from web3 import Web32MAINNET_URL = "https://eth.drpc.org"3w3 = Web3(Web3.HTTPProvider(MAINNET_URL))4w3.eth.block_number5quit()
从区块链读取
下一步是从区块链中读取信息。 为此,您需要切换到 02-read-quote 分支,然后使用 uv 运行程序。
1git checkout 02-read-quote2uv run agent.py您应该会收到一个 Quote 对象列表,每个对象都包含一个时间戳、一个价格和一个资产(目前始终为 WETH/USDC)。
下面是逐行解释。
1from web3 import Web32from web3.contract import Contract3from decimal import Decimal, ROUND_HALF_UP4from dataclasses import dataclass5from datetime import datetime, timezone6from pprint import pprint7import time8import functools9import sys显示全部导入我们需要的程序库。 在使用时,下面将对它们进行解释。
1print = functools.partial(print, flush=True)用一个总是立即刷新输出的版本替换 Python 的 print。 这在长时间运行的脚本中很有用,因为我们不想等待状态更新或调试输出。
1MAINNET_URL = "https://eth.drpc.org"一个访问主网的 URL。 您可以从节点即服务获取一个,或使用 Chainlist (opens in a new tab) 中宣传的节点。
1BLOCK_TIME_SECONDS = 122MINUTE_BLOCKS = int(60 / BLOCK_TIME_SECONDS)3HOUR_BLOCKS = MINUTE_BLOCKS * 604DAY_BLOCKS = HOUR_BLOCKS * 24以太坊主网区块通常每十二秒产生一个,因此这些是我们预期在一段时间内产生的区块数量。 请注意,这不是一个精确的数字。 当区块提议者宕机时,该区块将被跳过,下一个区块的时间将是 24 秒。 如果我们想获取某个时间戳的精确区块,我们会使用二分查找 (opens in a new tab)。 然而,这对我们的目的来说已经足够接近了。 预测未来并非一门精确的科学。
1CYCLE_BLOCKS = DAY_BLOCKS周期的大小。 我们每个周期审查一次报价,并尝试估算下一个周期结束时的价值。
1# 我们正在读取的池子的地址2WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")报价取自地址为 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640 (opens in a new tab) 的 Uniswap 3 USDC/WETH 池子。 此地址已经是校验和格式,但最好使用 Web3.to_checksum_address (opens in a new tab) 以使代码可重用。
1POOL_ABI = [2 { "name": "slot0", ... },3 { "name": "token0", ... },4 { "name": "token1", ... },5]67ERC20_ABI = [8 { "name": "symbol", ... },9 { "name": "decimals", ... }10]显示全部这些是我们需要联系的两个合约的 ABI (opens in a new tab)。 为保持代码简洁,我们只包含需要调用的函数。
1w3 = Web3(Web3.HTTPProvider(MAINNET_URL))初始化 Web3 (opens in a new tab) 程序库并连接到一个以太坊节点。
1@dataclass(frozen=True)2class ERC20Token:3 address: str4 symbol: str5 decimals: int6 contract: Contract这是在 Python 中创建数据类的一种方法。 Contract (opens in a new tab) 数据类型用于连接到合约。 注意 (frozen=True)。 在 Python 中,布尔值 (opens in a new tab) 定义为大写的 True 或 False。 这个数据类是 frozen 的,意味着字段不能被修改。
注意缩进。 与 C 派生语言 (opens in a new tab) 不同,Python 使用缩进来表示代码块。 Python 解释器知道下面的定义不属于这个数据类,因为它没有与数据类字段相同的缩进。
1@dataclass(frozen=True)2class PoolInfo:3 address: str4 token0: ERC20Token5 token1: ERC20Token6 contract: Contract7 asset: str8 decimal_factor: Decimal = 1Decimal (opens in a new tab) 类型用于精确处理小数。
1 def get_price(self, block: int) -> Decimal:这是在 Python 中定义函数的方式。 该定义被缩进以表明它仍然是 PoolInfo 的一部分。
在作为数据类一部分的函数中,第一个参数总是 self,即调用此函数的数据类实例。 这里有另一个参数,即区块号。
1 assert block <= w3.eth.block_number, "区块在未来"如果我们能预知未来,我们就不需要用 AI 来进行交易了。
1 sqrt_price_x96 = Decimal(self.contract.functions.slot0().call(block_identifier=block)[0])从 Web3 调用 EVM 上的函数的语法是:<contract object>.functions.<function name>().call(<parameters>)。 参数可以是 EVM 函数的参数(如果有的话;这里没有),也可以是用于修改区块链行为的命名参数 (opens in a new tab)。 这里我们使用一个参数 block_identifier 来指定我们希望运行的区块号。
结果是这个结构体,以数组形式 (opens in a new tab)。 第一个值是两个代币之间汇率的函数。
1 raw_price = (sqrt_price_x96 / Decimal(2**96)) ** 2为减少链上计算,Uniswap v3 不存储实际的兑换系数,而是存储其平方根。 由于 EVM 不支持浮点数学或分数,因此响应不是实际值,而是
1 # (每个 token0 对应的 token1 数量)2 return 1/(raw_price * self.decimal_factor)我们得到的原始价格是每个 token1 可以兑换的 token0 的数量。 在我们的池子中,token0 是 USDC(与美元等值的稳定币),token1 是 WETH (opens in a new tab)。 我们真正想要的价值是每 WETH 对应的美元数量,而不是其倒数。
小数位数因数是两个代币的小数位数因数 (opens in a new tab)之间的比率。
1@dataclass(frozen=True)2class Quote:3 timestamp: str4 price: Decimal5 asset: str这个数据类代表一个报价:特定资产在特定时间点的价格。 此时,asset 字段是无关紧要的,因为我们使用单个池子,因此只有一个资产。 但是,我们稍后会添加更多资产。
1def read_token(address: str) -> ERC20Token:2 token = w3.eth.contract(address=address, abi=ERC20_ABI)3 symbol = token.functions.symbol().call()4 decimals = token.functions.decimals().call()56 return ERC20Token(7 address=address,8 symbol=symbol,9 decimals=decimals,10 contract=token11 )显示全部该函数接受一个地址,并返回该地址上代币合约的信息。 要创建一个新的 Web3 Contract (opens in a new tab),我们需要向 w3.eth.contract 提供地址和 ABI。
1def read_pool(address: str) -> PoolInfo:2 pool_contract = w3.eth.contract(address=address, abi=POOL_ABI)3 token0Address = pool_contract.functions.token0().call()4 token1Address = pool_contract.functions.token1().call()5 token0 = read_token(token0Address)6 token1 = read_token(token1Address)78 return PoolInfo(9 address=address,10 asset=f"{token1.symbol}/{token0.symbol}",11 token0=token0,12 token1=token1,13 contract=pool_contract,14 decimal_factor=Decimal(10) ** Decimal(token0.decimals - token1.decimals)15 )显示全部该函数返回我们需要的关于特定池子 (opens in a new tab)的所有信息。 f"<string>" 语法是格式化字符串 (opens in a new tab)。
1def get_quote(pool: PoolInfo, block_number: int = None) -> Quote:获取一个 Quote 对象。 block_number 的默认值为 None(无值)。
1 if block_number is None:2 block_number = w3.eth.block_number如果未指定区块号,则使用 w3.eth.block_number,即最新的区块号。 这是 if 语句 (opens in a new tab)的语法。
看起来似乎将默认值设置为 w3.eth.block_number 会更好,但这样做效果不佳,因为这会是函数定义时的区块号。 在一个长期运行的代理中,这会是一个问题。
1 block = w3.eth.get_block(block_number)2 price = pool.get_price(block_number)3 return Quote(4 timestamp=datetime.fromtimestamp(block.timestamp, timezone.utc).isoformat(),5 price=price.quantize(Decimal("0.01")),6 asset=pool.asset7 )使用 datetime 程序库 (opens in a new tab)将其格式化为人类和大型语言模型 (LLM) 可读的格式。 使用 Decimal.quantize (opens in a new tab) 将值四舍五入到两位小数。
1def get_quotes(pool: PoolInfo, start_block: int, end_block: int, step: int) -> list[Quote]:在 Python 中,您可以使用 list[<type>] 定义一个只能包含特定类型的列表 (opens in a new tab)。
1 quotes = []2 for block in range(start_block, end_block + 1, step):在 Python 中,for 循环 (opens in a new tab)通常遍历一个列表。 用于查找报价的区块号列表来自 range (opens in a new tab)。
1 quote = get_quote(pool, block)2 quotes.append(quote)3 return quotes对于每个区块号,获取一个 Quote 对象并将其附加到 quotes 列表中。 然后返回该列表。
1pool = read_pool(WETHUSDC_ADDRESS)2quotes = get_quotes(3 pool,4 w3.eth.block_number - 12*CYCLE_BLOCKS,5 w3.eth.block_number,6 CYCLE_BLOCKS7)89pprint(quotes)显示全部这是脚本的主代码。 读取池子信息,获取十二个报价,并使用 pprint (opens in a new tab) 打印它们。
创建提示
接下来,我们需要将此报价列表转换为对大语言模型 (LLM) 的提示,并获取预期的未来价值。
1git checkout 03-create-prompt2uv run agent.py现在的输出将是给大语言模型 (LLM) 的提示,类似如下:
1给定以下报价:2资产:WETH/USDC3 2026-01-20T16:34 3016.214 .5 .6 .7 2026-02-01T17:49 2299.1089资产:WBTC/WETH10 2026-01-20T16:34 29.8411 .12 .13 .14 2026-02-01T17:50 33.46151617您预计在 2026-02-02T17:56 时 WETH/USDC 的价值会是多少?1819请以一个四舍五入到两位小数的数字作为您的答案,20不要包含任何其他文本。显示全部请注意,这里有两种资产的报价:WETH/USDC 和 WBTC/WETH。 添加另一种资产的报价可能会提高预测准确性。
提示是什么样的
此提示包含三个部分,这在 LLM 提示中非常常见。
-
信息。 LLM 从训练中获得了大量信息,但通常没有最新的信息。 这就是我们在这里需要检索最新报价的原因。 向提示中添加信息称为检索增强生成 (RAG) (opens in a new tab)。
-
实际问题。 这是我们想知道的。
-
输出格式说明。 通常情况下,LLM 会给我们一个估算值,并解释它是如何得出的。 这对人类来说更好,但计算机程序只需要最终结果。
代码说明
这是新的代码。
1from datetime import datetime, timezone, timedelta我们需要向 LLM 提供我们想要估算的时间。 要获得未来“n 分钟/小时/天”的时间,我们使用 timedelta 类 (opens in a new tab)。
1# 我们正在读取的池子的地址2WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")3WETHWBTC_ADDRESS = Web3.to_checksum_address("0xCBCdF9626bC03E24f779434178A73a0B4bad62eD")我们需要读取两个池子。
1@dataclass(frozen=True)2class PoolInfo:3 .4 .5 .6 reverse: bool = False78 def get_price(self, block: int) -> Decimal:9 assert block <= w3.eth.block_number, "区块在未来"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显示全部在 WETH/USDC 池子中,我们想知道需要多少 token0 (USDC) 来购买一个 token1 (WETH)。 在 WETH/WBTC 池子中,我们想知道需要多少 token1 (WETH) 来购买一个 token0(WBTC,即包装比特币)。 我们需要跟踪池子的比率是否需要反转。
1def read_pool(address: str, reverse: bool = False) -> PoolInfo:2 .3 .4 .56 return PoolInfo(7 .8 .9 .1011 asset= f"{token1.symbol}/{token0.symbol}" if reverse else f"{token0.symbol}/{token1.symbol}",12 reverse=reverse13 )显示全部要了解是否需要反转池子,我们将其作为 read_pool 的输入。 此外,资产符号需要正确设置。
语法 <a> if <b> else <c> 是 Python 中三元条件运算符 (opens in a new tab) 的等价形式,在 C 派生语言中为 <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该函数构建一个字符串来格式化 Quote 对象列表,假设它们都适用于同一资产。
1def make_prompt(quotes: list[list[Quote]], expected_time: str, asset: str) -> str:2 return f"""在 Python 中,多行字符串字面量 (opens in a new tab)写为 """ .... """。
1给定以下报价:2{3 functools.reduce(lambda acc, q: acc + '\n' + q,4 map(lambda q: format_quotes(q), quotes))5}在这里,我们使用 MapReduce (opens in a new tab) 模式,通过 format_quotes 为每个报价列表生成一个字符串,然后将它们归约为一个字符串以在提示中使用。
1您预计 {asset} 在 {expected_time} 的价值会是多少?23请以一个四舍五入到两位小数的数字作为您的答案,4不要包含任何其他文本。5 """提示的其余部分与预期一致。
1wethusdc_pool = read_pool(WETHUSDC_ADDRESS, True)2wethusdc_quotes = get_quotes(3 wethusdc_pool,4 w3.eth.block_number - 12*CYCLE_BLOCKS,5 w3.eth.block_number,6 CYCLE_BLOCKS,7)89wethwbtc_pool = read_pool(WETHWBTC_ADDRESS)10wethwbtc_quotes = get_quotes(11 wethwbtc_pool,12 w3.eth.block_number - 12*CYCLE_BLOCKS,13 w3.eth.block_number,14 CYCLE_BLOCKS15)显示全部查看两个池子并从两者获取报价。
1future_time = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()[0:16]23print(make_prompt(wethusdc_quotes + wethwbtc_quotes, future_time, wethusdc_pool.asset))确定我们想要估算的未来时间点,并创建提示。
与 LLM 交互
接下来,我们提示一个实际的大语言模型 (LLM),并接收预期的未来价值。 我用 OpenAI 编写了这个程序,所以如果你想使用不同的提供商,你需要进行调整。
-
为帐户注资 (opens in a new tab)——在撰写本文时,最低金额为 5 美元
-
在命令行中,导出应用程序接口密钥,以便您的程序可以使用它
1export OPENAI_API_KEY=sk-<密钥的其余部分写在这里> -
检出并运行代理
1git checkout 04-interface-llm2uv run agent.py
这是新的代码。
1from openai import OpenAI23open_ai = OpenAI() # 客户端读取 OPENAI_API_KEY 环境变量导入并实例化 OpenAI 应用程序接口。
1response = open_ai.chat.completions.create(2 model="gpt-4-turbo",3 messages=[4 {"role": "user", "content": prompt}5 ],6 temperature=0.0,7 max_tokens=16,8)调用 OpenAI 应用程序接口 (open_ai.chat.completions.create) 来创建响应。
1expected_price = Decimal(response.choices[0].message.content.strip())2current_price = wethusdc_quotes[-1].price34print ("Current price:", wethusdc_quotes[-1].price)5print(f"In {future_time}, expected price: {expected_price} USD")67if (expected_price > current_price):8 print(f"Buy, I expect the price to go up by {expected_price - current_price} USD")9else:10 print(f"Sell, I expect the price to go down by {current_price - expected_price} USD")显示全部输出价格并提供买入或卖出建议。
测试预测
既然我们可以生成预测,我们也可以使用历史数据来评估我们是否产生了有用的预测。
1uv run test-predictor.py预期结果类似:
12026-01-05T19:50 的预测:预测 3138.93 美元,实际 3218.92 美元,误差 79.99 美元22026-01-06T19:56 的预测:预测 3243.39 美元,实际 3221.08 美元,误差 22.31 美元32026-01-07T20:02 的预测:预测 3223.24 美元,实际 3146.89 美元,误差 76.35 美元42026-01-08T20:11 的预测:预测 3150.47 美元,实际 3092.04 美元,误差 58.43 美元5.6.7.82026-01-31T22:33 的预测:预测 2637.73 美元,实际 2417.77 美元,误差 219.96 美元92026-02-01T22:41 的预测:预测 2381.70 美元,实际 2318.84 美元,误差 62.86 美元102026-02-02T22:49 的预测:预测 2234.91 美元,实际 2349.28 美元,误差 114.37 美元1129 次预测的平均预测误差:83.87103448275862068965517241 美元12每个建议的平均变化:4.787931034482758620689655172 美元13变化的 标准方差:104.42 美元14盈利天数:51.72%15亏损天数:48.28%显示全部测试器的大部分内容与代理相同,但以下是新的或修改过的部分。
1CYCLES_FOR_TEST = 40 # 对于回测,我们测试多少个周期23# 获取大量报价4wethusdc_pool = read_pool(WETHUSDC_ADDRESS, True)5wethusdc_quotes = get_quotes(6 wethusdc_pool,7 w3.eth.block_number - CYCLE_BLOCKS*CYCLES_FOR_TEST,8 w3.eth.block_number,9 CYCLE_BLOCKS,10)1112wethwbtc_pool = read_pool(WETHWBTC_ADDRESS)13wethwbtc_quotes = get_quotes(14 wethwbtc_pool,15 w3.eth.block_number - CYCLE_BLOCKS*CYCLES_FOR_TEST,16 w3.eth.block_number,17 CYCLE_BLOCKS18)显示全部我们回顾 CYCLES_FOR_TEST(此处指定为 40)天前的数据。
1# 创建预测并与真实历史进行比较23total_error = Decimal(0)4changes = []我们感兴趣的错误有两种类型。 第一种,total_error,就是预测器所犯错误的总和。
要理解第二种,changes,我们需要记住代理的目的。 它不是为了预测 WETH/USDC 比率(ETH 价格)。 而是为了发出卖出和买入的建议。 如果当前价格是 2000 美元,而它预测明天是 2010 美元,如果实际结果是 2020 美元并且我们赚了额外的钱,我们不会介意。 但如果它预测为 2010 美元,并基于该建议购买了 ETH,而价格下跌到 1990 美元,我们_确实_会介意。
1for index in range(0,len(wethusdc_quotes)-CYCLES_BACK):我们只能看那些历史记录完整(用于预测的值和与之比较的真实世界值都可用)的情况。 这意味着最新的案例必须是 CYCLES_BACK 之前开始的那个。
1 wethusdc_slice = wethusdc_quotes[index:index+CYCLES_BACK]2 wethwbtc_slice = wethwbtc_quotes[index:index+CYCLES_BACK]使用切片 (opens in a new tab)来获取与代理使用的样本数量相同的样本。 这里到下一个片段之间的代码与我们在代理中使用的获取预测的代码相同。
1 predicted_price = Decimal(response.choices[0].message.content.strip())2 real_price = wethusdc_quotes[index+CYCLES_BACK].price3 prediction_time_price = wethusdc_quotes[index+CYCLES_BACK-1].price获取预测价格、真实价格以及预测时的价格。 我们需要预测时的价格来决定建议是买入还是卖出。
1 error = abs(predicted_price - real_price)2 total_error += error3 print (f"Prediction for {prediction_time}: predicted {predicted_price} USD, real {real_price} USD, error {error} USD")计算误差,并将其加到总误差中。
1 recomended_action = 'buy' if predicted_price > prediction_time_price else 'sell'2 price_increase = real_price - prediction_time_price3 changes.append(price_increase if recomended_action == 'buy' else -price_increase)对于 changes,我们想要的是买入或卖出一个 ETH 的货币影响。 因此,首先我们需要确定建议,然后评估实际价格的变化,以及建议是盈利(正向变化)还是亏损(负向变化)。
1print (f"Mean prediction error over {len(wethusdc_quotes)-CYCLES_BACK} predictions: {total_error / Decimal(len(wethusdc_quotes)-CYCLES_BACK)} USD")23length_changes = Decimal(len(changes))4mean_change = sum(changes, Decimal(0)) / length_changes5print (f"Mean change per recommendation: {mean_change} USD")6var = sum((x - mean_change) ** 2 for x in changes) / length_changes7print (f"Standard variance of changes: {var.sqrt().quantize(Decimal("0.01"))} USD")报告结果。
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%}")使用 filter (opens in a new tab) 来计算盈利天数和亏损天数。 结果是一个过滤器对象,我们需要将其转换为列表以获取其长度。
提交交易
现在我们需要实际提交交易。 然而,在系统被证明之前,我不想在这个阶段花真钱。 相反,我们将在本地创建一个主网分叉,并在此网络上进行“交易”。
以下是创建本地分叉并启用交易的步骤。
-
1anvil --fork-url https://eth.drpc.org --block-time 12
anvil正在 Foundry 的默认 URL http://localhost:8545 (opens in a new tab) 上监听,所以我们不需要为用于操作区块链的cast命令 (opens in a new tab)指定 URL。 -
在
anvil中运行时,有十个拥有 ETH 的测试帐户——为第一个帐户设置环境变量1PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff802ADDRESS=`cast wallet address $PRIVATE_KEY` -
这些是我们需要使用的合约。
SwapRouter(opens in a new tab) 是我们用来实际交易的 Uniswap v3 合约。 我们可以直接通过池子进行交易,但这要容易得多。底部的两个变量是在 WETH 和 USDC 之间进行交换所需的 Uniswap v3 路径。
1WETH_ADDRESS=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc22USDC_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB483POOL_ADDRESS=0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f56404SWAP_ROUTER=0xE592427A0AEce92De3Edee1F18E0157C058615645WETH_TO_USDC=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB486USDC_TO_WETH=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 -
每个测试帐户都有 10,000 ETH。 使用 WETH 合约包装 1000 ETH 以获得 1000 WETH 用于交易。
1cast send $WETH_ADDRESS "deposit()" --value 1000ether --private-key $PRIVATE_KEY -
使用
SwapRouter交易 500 WETH 以换取 USDC。1cast send $WETH_ADDRESS "approve(address,uint256)" $SWAP_ROUTER 500ether --private-key $PRIVATE_KEY2MAXINT=`cast max-int uint256`3cast send $SWAP_ROUTER \4 "exactInput((bytes,address,uint256,uint256,uint256))" \5 "($WETH_TO_USDC,$ADDRESS,$MAXINT,500ether,1000000)" \6 --private-key $PRIVATE_KEYapprove调用创建了一个许可,允许SwapRouter花费我们的一些代币。 合约无法监听事件,因此如果我们直接将代币转移到SwapRouter合约,它将不知道已收到付款。 相反,我们允许SwapRouter合约花费一定金额,然后由SwapRouter执行。 这是通过SwapRouter调用的一个函数完成的,因此它知道是否成功。 -
验证您是否拥有足够的两种代币。
1cast call $WETH_ADDRESS "balanceOf(address)" $ADDRESS | cast from-wei2echo `cast call $USDC_ADDRESS "balanceOf(address)" $ADDRESS | cast to-dec`/10^6 | bc
现在我们有了 WETH 和 USDC,可以实际运行代理了。
1git checkout 05-trade2uv run agent.py输出将类似如下:
1(ai-trading-agent) qbzzt@Ori-Cloudnomics:~/260215-ai-agent$ uv run agent.py2当前价格:1843.1632026-02-06T23:07,预期价格:1724.41 美元4交易前帐户余额:5USDC 余额:927301.5782726WETH 余额:5007卖出,我预计价格将下跌 118.75 美元8批准交易已发送:74e367ddbb407c1aaf567d87aa5863049991b1d2aa092b6b85195d925e2bd41f9批准交易已挖出。10卖出交易已发送:fad1bcf938585c9e90364b26ac7a80eea9efd34c37e5db81e58d7655bcae28bf11卖出交易已挖出。12交易后帐户余额:13USDC 余额:929143.79711614WETH 余额:499显示全部要实际使用它,您需要进行一些小的更改。
- 在第 14 行,将
MAINNET_URL更改为真实的访问点,例如https://eth.drpc.org - 在第 28 行,将
PRIVATE_KEY更改为您自己的私钥 - 除非您非常富有,并且可以为一个未经证实的代理每天买卖 1 个 ETH,否则您可能希望更改第 29 行以减少
WETH_TRADE_AMOUNT
代码说明
这是新的代码。
1SWAP_ROUTER_ADDRESS=Web3.to_checksum_address("0xE592427A0AEce92De3Edee1F18E0157C05861564")2WETH_TO_USDC=bytes.fromhex("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc20001F4A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")3USDC_TO_WETH=bytes.fromhex("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480001F4C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")4PRIVATE_KEY="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"我们在第 4 步中使用的相同变量。
1WETH_TRADE_AMOUNT=1交易金额。
1ERC20_ABI = [2 { "name": "symbol", ... },3 { "name": "decimals", ... },4 { "name": "balanceOf", ...},5 { "name": "approve", ...}6]要实际交易,我们需要 approve 函数。 我们还希望显示交易前后的余额,因此我们还需要 balanceOf。
1SWAP_ROUTER_ABI = [2 { "name": "exactInput", ...},3]在 SwapRouter ABI 中,我们只需要 exactInput。 还有一个相关函数 exactOutput,我们可以用它来精确购买一个 WETH,但为简单起见,我们在这两种情况下都只使用 exactInput。
1account = w3.eth.account.from_key(PRIVATE_KEY)2swap_router = w3.eth.contract(3 address=SWAP_ROUTER_ADDRESS,4 abi=SWAP_ROUTER_ABI5)Web3 为 account (opens in a new tab) 和 SwapRouter 合约的定义。
1def txn_params() -> dict:2 return {3 "from": account.address,4 "value": 0,5 "gas": 300000,6 "nonce": w3.eth.get_transaction_count(account.address),7 }交易参数。 我们在这里需要一个函数,因为随机数 (opens in a new tab)每次都必须改变。
1def approve_token(contract: Contract, amount: int):批准 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)这是我们在 Web3 中发送交易的方式。 首先我们使用 Contract 对象 (opens in a new tab) 来构建交易。 然后我们使用 web3.eth.account.sign_transaction (opens in a new tab) 来使用 PRIVATE_KEY 签署交易。 最后,我们使用 w3.eth.send_raw_transaction (opens in a new tab) 来发送交易。
1 print(f"Approve transaction sent: {tx_hash.hex()}")2 w3.eth.wait_for_transaction_receipt(tx_hash)3 print("Approve transaction mined.")w3.eth.wait_for_transaction_receipt (opens in a new tab) 会等到交易被挖出。 如果需要,它会返回收据。
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}这些是卖出 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 }与 SELL_PARAMS 不同,买入参数可能会改变。 输入金额是 1 WETH 的成本,可在 quote 中获取。
1def buy(quote: Quote):2 buy_params = make_buy_params(quote)3 approve_token(wethusdc_pool.token0.contract, buy_params["amountIn"])4 txn = swap_router.functions.exactInput(buy_params).build_transaction(txn_params())5 signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)6 tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)7 print(f"Buy transaction sent: {tx_hash.hex()}")8 w3.eth.wait_for_transaction_receipt(tx_hash)9 print("Buy transaction mined.")101112def sell():13 approve_token(wethusdc_pool.token1.contract,14 WETH_TRADE_AMOUNT * 10**wethusdc_pool.token1.decimals)15 txn = swap_router.functions.exactInput(SELL_PARAMS).build_transaction(txn_params())16 signed_txn = w3.eth.account.sign_transaction(txn, private_key=PRIVATE_KEY)17 tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)18 print(f"Sell transaction sent: {tx_hash.hex()}")19 w3.eth.wait_for_transaction_receipt(tx_hash)20 print("Sell transaction mined.")显示全部buy() 和 sell() 函数几乎相同。 我们首先为 SwapRouter 批准足够的许可,然后用正确的路径和金额调用它。
1def balances():2 token0_balance = wethusdc_pool.token0.contract.functions.balanceOf(account.address).call()3 token1_balance = wethusdc_pool.token1.contract.functions.balanceOf(account.address).call()45 print(f"{wethusdc_pool.token0.symbol} Balance: {Decimal(token0_balance) / Decimal(10 ** wethusdc_pool.token0.decimals)}")6 print(f"{wethusdc_pool.token1.symbol} Balance: {Decimal(token1_balance) / Decimal(10 ** wethusdc_pool.token1.decimals)}")报告两种货币的用户余额。
1print("交易前帐户余额:")2balances()34if (expected_price > current_price):5 print(f"买入,我预计价格将上涨 {expected_price - current_price} 美元")6 buy(wethusdc_quotes[-1])7else:8 print(f"卖出,我预计价格将下跌 {current_price - expected_price} 美元")9 sell()1011print("交易后帐户余额:")12balances()显示全部该代理目前只能工作一次。 然而,您可以通过从 crontab (opens in a new tab) 运行它,或者将 368-400 行包装在一个循环中并使用 time.sleep (opens in a new tab) 等待下一个周期的时间,来使其连续工作。
可能的改进
这不是一个完整的生产版本;它仅仅是一个教授基础知识的例子。 以下是一些改进建议。
更智能的交易
代理在决定做什么时忽略了两个重要的事实。
- 预期变化的幅度。 如果价格预期下跌,代理会卖出固定数量的
WETH,而不管下跌的幅度如何。 可以说,更好的做法是忽略微小的变化,并根据我们预期的价格下跌幅度来决定卖出。 - 当前投资组合。 如果你的投资组合中有 10% 是 WETH,并且你认为价格会上涨,那么买入更多可能是明智的。 但如果你的投资组合中有 90% 是 WETH,你可能已经有足够的敞口,没有必要再买更多。 如果你预期价格会下跌,情况则相反。
如果你想对你的交易策略保密怎么办?
AI 供应商可以看到你发送给他们的大语言模型 (LLM) 的查询,这可能会暴露你用代理开发的绝妙交易系统。 一个太多人使用的交易系统是毫无价值的,因为太多人会在你想买的时候尝试买入(价格上涨),在你像卖的时候尝试卖出(价格下跌)。
您可以在本地运行一个大语言模型 (LLM),例如使用 LM-Studio (opens in a new tab),以避免此问题。
从 AI 机器人到 AI 代理
你可以很有力地论证这是一个 AI 机器人,而不是一个 AI 代理。 它实现了一个依赖于预定义信息的相对简单的策略。 我们可以启用自我改进,例如,通过提供 Uniswap v3 池子及其最新值的列表,并询问哪种组合具有最佳的预测价值。
滑点保护
目前没有滑点保护 (opens in a new tab)。 如果当前报价是 2000 美元,预期价格是 2100 美元,代理将会买入。 然而,如果在代理买入之前,成本上升到 2200 美元,那么再买入就没有意义了。
要实现滑点保护,请在 agent.py (opens in a new tab) 的第 325 行和 334 行中指定一个 amountOutMinimum 值。
结论
希望现在您已经足够了解,可以开始使用 AI 代理了。 这不是对该主题的全面概述;有整本书专门讨论这个问题,但这足以让您入门。 祝你好运!
点击此处查看我的更多作品 (opens in a new tab)。
页面最后更新: 2026年2月10日