在以太坊上打造你自己的 AI 交易代理
在本教學中,你將學會如何建立一個簡單的 AI 交易代理。 此代理的運作方式包含以下步驟:
- 讀取代幣的當前和過去價格,以及其他潛在的相關資訊
- 使用此資訊建立查詢,並附上背景資訊以解釋其可能存在的關聯性
- 提交查詢並接收回傳的預測價格
- 根據建議進行交易
- 等待並重複
此代理示範了如何讀取資訊、將其轉譯為能產生可用答案的查詢,並使用該答案。 這些都是 AI 代理所需的步驟。 此代理以 Python 實作,因為它是 AI 領域中最常用的語言。
為何要這麼做?
自動化交易代理讓開發者可以選擇並執行交易策略。 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 以太坊應用程式介面 (API)。 它相當容易使用。
在區塊鏈上交易
有許多去中心化交易所 (DEX) 讓你在以太坊上交易代幣。 然而,由於套利的關係,它們的匯率往往相似。
Uniswap (opens in a new tab) 是一個廣泛使用的去中心化交易所 (DEX),我們可以用它來進行報價(查看代幣的相對價值)和交易。
OpenAI
在大型語言模型方面,我選擇從 OpenAI (opens in a new tab) 開始。 要執行本教學中的應用程式,你需要付費取得 API 存取權限。 最低付款金額 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以太坊主網區塊通常每 12 秒產生一個,所以這些是我們預期在一段時間內會產生的區塊數量。 請注意,這不是一個精確的數字。 當區塊提議者離線時,該區塊會被跳過,而下一個區塊的時間將是 24 秒。 如果我們想為某個時間戳取得確切的區塊,我們會使用二元搜尋法 (opens in a new tab)。 然而,這對我們的目的來說已經足夠接近了。 預測未來並不是一門精確的科學。
1CYCLE_BLOCKS = DAY_BLOCKS週期的規模。 我們每個週期會審視一次報價,並嘗試估計下一個週期結束時的價值。
1# 我們正在讀取的資金池地址2WETHUSDC_ADDRESS = Web3.to_checksum_address("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640")報價價值取自 Uniswap 3 USDC/WETH 資金池,地址為 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640 (opens in a new tab)。 這個地址已經是總和檢查碼格式,但最好使用 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),我們將地址和應用程式二進位介面 (ABI) 提供給 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)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 # (每 token0 的 token1 數量)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您預期在 {expected_time} 時,{asset} 的價值會是多少?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 美元
-
在命令列中,匯出 API 金鑰,以便你的程式可以使用它
1export OPENAI_API_KEY=sk-<金鑰的其餘部分放在這裡> -
簽出並執行代理
1git checkout 04-interface-llm2uv run agent.py
以下是新的程式碼。
1from openai import OpenAI23open_ai = OpenAI() # 用戶端會讀取 OPENAI_API_KEY 環境變數匯入並實例化 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)呼叫 OpenAI API (open_ai.chat.completions.create) 來建立回應。
1expected_price = Decimal(response.choices[0].message.content.strip())2current_price = wethusdc_quotes[-1].price34print ("目前價格:", wethusdc_quotes[-1].price)5print(f"在 {future_time},預期價格:{expected_price} USD")67if (expected_price > current_price):8 print(f"買入,我預期價格會上漲 {expected_price - current_price} USD")9else:10 print(f"賣出,我預期價格會下跌 {current_price - expected_price} USD")顯示全部輸出價格並提供買入或賣出建議。
測試預測
既然我們能產生預測,我們也可以使用歷史資料來評估我們是否產生了有用的預測。
1uv run test-predictor.py預期結果類似於:
12026-01-05T19:50 的預測:預測 3138.93 USD,實際 3218.92 USD,誤差 79.99 USD22026-01-06T19:56 的預測:預測 3243.39 USD,實際 3221.08 USD,誤差 22.31 USD32026-01-07T20:02 的預測:預測 3223.24 USD,實際 3146.89 USD,誤差 76.35 USD42026-01-08T20:11 的預測:預測 3150.47 USD,實際 3092.04 USD,誤差 58.43 USD5.6.7.82026-01-31T22:33 的預測:預測 2637.73 USD,實際 2417.77 USD,誤差 219.96 USD92026-02-01T22:41 的預測:預測 2381.70 USD,實際 2318.84 USD,誤差 62.86 USD102026-02-02T22:49 的預測:預測 2234.91 USD,實際 2349.28 USD,誤差 114.37 USD1129 次預測的平均預測誤差:83.87103448275862068965517241 USD12每次建議的平均變動:4.787931034482758620689655172 USD13變動的標準差:104.42 USD14獲利天數: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]使用切片 (slices) (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_time} 的預測:預測 {predicted_price} USD,實際 {real_price} USD,誤差 {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"在 {len(wethusdc_quotes)-CYCLES_BACK} 次預測中的平均預測誤差:{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} USD")6var = sum((x - mean_change) ** 2 for x in changes) / length_changes7print (f"變動的標準差:{var.sqrt().quantize(Decimal("0.01"))} USD")回報結果。
1print (f"獲利天數:{len(list(filter(lambda x: x > 0, changes)))/length_changes:.2%}")2print (f"虧損天數:{len(list(filter(lambda x: x < 0, changes)))/length_changes:.2%}")使用 filter (opens in a new tab) 來計算獲利天數和虧損天數。 結果是一個 filter 物件,我們需要將它轉換成列表才能取得長度。
提交交易
現在我們需要實際提交交易。 然而,在系統被證實有效之前,我不想在此時花費真金白銀。 取而代之,我們將建立一個主網的本地分叉,並在該網路上進行「交易」。
以下是建立本地分叉並啟用交易的步驟。
-
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.163在 2026-02-06T23:07,預期價格:1724.41 USD4交易前帳戶餘額:5USDC 餘額:927301.5782726WETH 餘額:5007賣出,我預期價格會下跌 118.75 USD8授權交易已發送: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)account (opens in a new tab) 和 SwapRouter 合約的 Web3 定義。
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 }交易參數。 我們在這裡需要一個函式,因為隨機數 (nonce) (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"授權交易已發送:{tx_hash.hex()}")2 w3.eth.wait_for_transaction_receipt(tx_hash)3 print("授權交易已上鏈。")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"買入交易已發送:{tx_hash.hex()}")8 w3.eth.wait_for_transaction_receipt(tx_hash)9 print("買入交易已上鏈。")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"賣出交易已發送:{tx_hash.hex()}")19 w3.eth.wait_for_transaction_receipt(tx_hash)20 print("賣出交易已上鏈。")顯示全部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} 餘額:{Decimal(token0_balance) / Decimal(10 ** wethusdc_pool.token0.decimals)}")6 print(f"{wethusdc_pool.token1.symbol} 餘額:{Decimal(token1_balance) / Decimal(10 ** wethusdc_pool.token1.decimals)}")回報使用者兩種貨幣的餘額。
1print("交易前帳戶餘額:")2balances()34if (expected_price > current_price):5 print(f"買入,我預期價格會上漲 {expected_price - current_price} USD")6 buy(wethusdc_quotes[-1])7else:8 print(f"賣出,我預期價格會下跌 {current_price - expected_price} USD")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日