arbitrage botの構築: 裁定取引の機会を見つける (記事 3/n)

引用元:

https://blog.blockmagnates.com/building-an-arbitrage-bot-finding-arbitrage-opportunities-article-3-n-1ea3c5e0b8ca

post image

この記事では、関心のあるトークンペアの事前選択を行います。次に、同じトークンペアの2つのプール間の最適なアービトラージを見つける数学的な式を導出します。最後に、その式をコードに実装して、可能性のあるアービトラージ機会のリストを返します。

トークンペアの選択

裁定取引戦略の精度

アービトラージ戦略に関する注意点 アービトラージ機会を探し始める前に、アービトラージBOTの範囲を明確に定義する必要があります。具体的には、どのような種類のアービトラージに対応させるのかです。最も安全な種類のアービトラージはETHを含むプール間のものです。ETHは取引のガス料金の支払いに使用されるため、アービトラージ後にETHを得ることが自然です。しかし、誰もがこのように考える誘惑があります。瞬時的な機会は、より多くの人がそれに対応するほど、利益が少なくなっていくことに留意してください。

シンプルさのために、ETHを含むプール間のアービトラージ機会に焦点を当てます。2つのプールのみを検討します。2つ以上のプールを含む取引経路の機会は扱いません。この戦略をリスクのあるものにアップグレードすることは、BOTの収益性を高めるための最初のステップとなります。

この戦略を改善するには、例えば、ステーブルコインの在庫を保持し、ステーブルコインを得るアービトラージ機会に対応することができます。定期的にポートフォリオをETHに再バランスしてガス料金を支払うことができます。

別の方向性は、暗黙の原子性の仮定を放棄し、戦略に統計的推論を導入することです。トークンの価格がある量を超えて有利な方向に動いたときに1つのプールでトークンを購入し、後で売却する(平均回帰戦略)。これは、より効率的な中央集権型取引所で上場されていないか、正しくオンチェーンで価格が追跡されていないトークンに理想的です。これは、より多くの可動部品を含み、この記事の範囲外です。

トークンペアの選択

アービトラージボットの境界を定義したので、取引したいトークンペアを選択する必要があります。ここで使用する 2 つの選択基準は次のとおりです。

  • 選択されるペアにはETHが含まれている必要があります。

  • ペアは少なくとも 2 つの異なるプールで取引する必要があります。

記事 2: プール価格の効率的な読み取り のコードを再利用すると、提供されたファクトリー コントラクトによってデプロイされたすべてのトークン ペアをリストする次のコードが得られます。

# ファクトリーコントラクトのアドレスを読み込む
with open("FactoriesV2.json", "r") as f: 
    factories = json.load(f)

# [...]
# 各ファクトリーコントラクトのプールリストを取得する
pairDataList = []
for factoryName, factoryData in factories.items():
    events = getPairEvents(w3.eth.contract(address=factoryData['factory'], abi=factory_abi), 0, w3.eth.block_number)
    print(f'Found {len(events)} pools for {factoryName}')
    for e in events:
        pairDataList.append({
            "token0": e["args"]["token0"], 
            "token1": e["args"]["token1"],
            "pair": e["args"]["pair"],
            "factory": factoryName
        })

私たちはpairDataListを単純に反転させ、キーがトークンペア、値がそのペアを取引するプールのリストとなる辞書にします。リストをループ処理する際、ETHを含まないペアは無視します。ループが終了したら、少なくとも2つのプールを持つペアが選択され、少なくとも2つの要素を持つリストに格納されます。

# [...]
WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
pair_pool_dict = {}
for pair_object in pairDataList:
    # ETH (WETH)がペアに含まれているかを確認。
    pair = (pair_object['token0'], pair_object['token1'])
    if WETH not in pair:
        continue

    # 辞書でペアが参照されていることを確認。
    if pair not in pair_pool_dict:
        pair_pool_dict[pair] = []

    # このペアを取引するプールのリストにプールを追加。
    pair_pool_dict[pair].append(pair_object)

# 取引されるプールの最終的な辞書を作成。
pool_dict = {}
for pair, pool_list in pair_pool_dict.items():
    if len(pool_list) >= 2:
        pool_dict[pair] = pool_list

私たちが取り組んでいるデータをよりよく理解するために、いくつかの統計を出力する必要があります。

# 異なるペアの数
print(f'We have {len(pool_dict)} different pairs.')

# プールの総数
print(f'We have {sum([len(pool_list) for pool_list in pool_dict.values()])} pools in total.')

# 最もプールが多いペア
print(f'The pair with the most pools is {max(pool_dict, key=lambda k: len(pool_dict[k]))} with {len(max(pool_dict.values(), key=len))} pools.')

# ペアごとのプール数の分布、デシル(十分位数)
pool_count_list = [len(pool_list) for pool_list in pool_dict.values()]
pool_count_list.sort(reverse=True)
print(f'Number of pools per pair, in deciles: {pool_count_list[::int(len(pool_count_list)/10)]}')

# ペアごとのプール数の分布、パーセンタイル(最初のデシル(十分位数)のデシル(十分位数))
pool_count_list.sort(reverse=True)
print(f'Number of pools per pair, in percentiles: {pool_count_list[::int(len(pool_count_list)/100)][:10]}')

この時点での出力は以下の通りです:

私たちは1431の異なるペアを持っています。 合計で3081のプールがあります。 最もプールが多いペアは('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', '0xdAC17F958D2ee523a2206206994597C13D831ec7')で、16のプールがあります。

ペアごとのプール数、デシル:[16, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2] 
ペアごとのプール数、パーセンタイル:[16, 5, 4, 3, 3, 3, 3, 3, 3, 3] 

公開RPCノードを使用して、3000のプールのリザーブを1秒未満で取得することができます。これは合理的な時間です。

さて、必要なデータがすべて揃ったので、アービトラージの機会を見つける作業を開始する必要があります。

裁定取引の機会を見つける

一般的なアイデア

一般的な考え方 同じペアを取引する2つのプール間の価格に差がある場合、アービトラージの機会があります。しかし、すべての価格の違いが利用可能なわけではありません:取引のガスコストは、取引によって回収される最小値を設定し、各プールの流動性は、価格の違いから抽出できる値を制限します。

最も利益の出るアービトラージの機会を見つけるためには、各価格の違いから抽出できる潜在的な値を計算し、取引のガスコストを見積もる必要があります。

裁定取引の最適取引サイズの計算式

サイズの式 アービトラージの機会が利用されると、入力トークンを購入するプールの価格は下がり、売却するプールの価格は上がります。価格の動きは、定数製品の式で説明されています。

私たちは、入力量とそのプールのリザーブを与えられた場合、プールを通じたスワップの出力を計算する方法を既に見ています。

最適な取引サイズを見つけるために、ある入力量と、スワップに関与する2つのプールのリザーブを与えられた場合、2つの連続したスワップの出力の式を最初に見つけます。

最初のスワップの入力はtoken0であり、2つ目のスワップの入力はtoken1であり、最終的にtoken0の出力をもたらします。

xを入力量とし、(a1, b1)を最初のプールのリザーブ、(a2, b2)を2番目のプールのリザーブとします。feeはプールが取る手数料で、両方のプールで同じと仮定されます(ほとんどの場合0.3%)。

入力xとリザーブ(a,b)が与えられた場合、スワップの出力を計算する関数を定義します。

この関数は、2つの連続したスワップの出力を計算するために使用されます。最初のスワップはtoken0からtoken1への変換、2番目のスワップはtoken1からtoken0への変換を行います。この2つのスワップの結果として得られるtoken0の量は、アービトラージの機会を評価するための基準として使用されます。

この関数を使用して、2つのプール間の価格差から得られる潜在的な利益を計算します。この利益は、取引のガスコストと比較され、アービトラージの機会が実際に利益をもたらすかどうかを判断するための基準として使用されます。

最後に、すべての可能なプールのペアに対してこのプロセスを繰り返し、最も利益の出るアービトラージの機会を特定します。この機会が見つかった場合、対応する取引を実行することで利益を実現することができます。

この方法により、データを効果的に分析し、アービトラージの機会を迅速に特定することができます。このアプローチは、リアルタイムの市場データを使用して、継続的にアービトラージの機会を探索するための基盤として使用することができます。

裁定取引の機会を見つける

一般的なアイデア

同じペアを取引する 2 つのプール間に価格の差異がある場合には、裁定取引の機会が存在します。ただし、すべての価格差を利用できるわけではありません。取引のガスコストによって、取引によって回収しなければならない最小値が設定され、各プールの流動性によって、特定の価格差から抽出できる価値が制限されます。

私たちが利用できる最も収益性の高い裁定取引の機会を見つけるには、各プールの埋蔵量/流動性を考慮して、各価格差から抽出できる潜在的な価値を計算し、取引のガスコストを見積もる必要があります。

裁定取引の最適取引サイズの計算式

裁定取引の機会が利用されると、入力トークンを購入するプールの価格は下がり、販売するプールの価格は上がります。価格の動きは一定の積の式で表されます。

プールのリザーブと入力量を考慮して、プールを介したスワップの出力を計算する方法を記事 1ですでに説明しました。

最適な取引サイズを見つけるために、最初に、一定の入力量とスワップに関与する 2 つのプールの準備金を与えて、2 つの連続するスワップの出力の公式を見つけます。

最初のスワップの入力が token0で、2 番目のスワップの入力が token1、最終的に token0が出力されると仮定します。

投入量x、最初のプールの埋蔵量(a1, b1)、および2 番目のプールの埋蔵量を とします。feeはプールが受け取る料金で、両方のプールで同じであると想定されます (ほとんどの場合 0.3%)。

入力 x と予約 (a,b) を指定して、スワップの出力を計算する関数を定義します。

f(x, a, b) = b * (1 - a/(a + x*(1-fee)))

最初のスワップの出力は次のようになります。

out1(x) = f(x, a1, b1)

out1(x) = b1 * (1 - a1/(a1 + x*(1-fee)))

2 番目のスワップの出力は次のとおりです (スワップされた予約変数に注目してください)。

out2(x) = f(out1(x), b2, a2)
out2(x) = f(f(x, a1, b1), b2, a2)
out2(x) = a2 * (1 - b2/(b2 + f(x, a1, b1)*(1-fee)))
out2(x) = a2 * (1 - b2/(b2 + b1 * (1 - a1/(a1 + x * (1-fee))) * (1-fee)))

desmosを使用してこの関数をプロットできます。1 ETH と 1750 USDC を持つ最初のプールと 1340 USDC と 1 ETH を持つ 2 番目のプールをシミュレートするように予約値を選択すると、次のグラフが得られます。

post image

実際にプロットしたのはout2(x) - x、取引の利益から入力金額を差し引いたものであることに注意してください。

グラフで見ると、最適な取引サイズは0.0607 ETHのinputであり、 0.0085 ETHの利益が得られることがわかります。この機会を活用するには、契約に少なくとも 0.0607 WETH の流動性が必要です。

この利益値0.0085 ETH(この記事の執筆時点では約 16 ドル) は、トランザクションのガスコストを考慮する必要があるため、トレードの最終的な利益ではありません。これについては次の記事で説明します。

MEVボットに最適な取引サイズを自動的に計算したいと思っています。これは初歩的な微積分によって行うことができます。最大化したい変数xの関数があり、この関数は関数の導関数が0になるxの値で最大になります。

関数の導関数を記号的に計算するために、Wolfram alphaなどのさまざまな無料のオンライン ツールを使用できます。

post image

Wolfram Alphaを使用してこのような導関数を見つけることは非常に簡単です。数学のスキルに自信がない場合は、手で計算することもできます。

Wolfram Alphaは次の導関数を提供します:

dout2(x)/dx = (a1b1a2b2(1-fee)^2)/(a1b2 + (1-fee)x(b1(1-fee)+b2))^2

利益を最大化するxの値(out2(x) - x)を見つけたいので、導関数が1(0でない)の場所でxの値を見つける必要があります。

Wolfram Alphaは、方程式dout2(x)/dx = 1のxに対して次の解を提供します:

x = (sqrt(a1b1a2b2(1-fee)^4 * (b1*(1-fee)+b2)^2) - a1b2(1-fee)(b1(1-fee)+b2)) / ((1-fee) * (b1*(1-fee) + b2))^2

上記のグラフで使用したリザーブの値を使用すると、x_optimal = 0.0607203782551が得られ、これは私たちの式を検証します(グラフの値0.0607と比較して)。

この式は非常に読みにくいかもしれませんが、コードで実装するのは簡単です。以下は、2つのスワップの出力と最適な取引サイズを計算するためのPython実装の式です:

#最適な取引サイズを計算するための補助関数
#1回のスワップの出力
def swap_output(x, a, b, fee=0.003): return b * (1 - a/(a + x*(1-fee)))
#2回の連続したスワップの総利益
def trade_profit(x, reserves1, reserves2, fee=0.003): a1, b1 = reserves1 a2, b2 = reserves2 return swap_output(swap_output(x, a1, b1, fee), b2, a2, fee) - x
#最適な入力量
def optimal_trade_size(reserves1, reserves2, fee=0.003): a1, b1 = reserves1 a2, b2 = reserves2 return (math.sqrt(a1b1a2b2(1-fee)*4 * (b1(1-fee)+b2)*2) - a1b2(1-fee)(b1(1-fee)+b2)) / ((1-fee) * (b1(1-fee) + b2))**2

裁定取引機会ファインダー

同じトークン ペアの任意の 2 つのプール間の裁定取引機会からの粗利益を計算する方法がわかったので、すべてのトークン ペアを反復処理し、トークン ペアを持つすべてのプールを 2 つずつテストするだけです。同じトークンペア。これにより、戦略の境界内にあるすべての可能な裁定取引の機会の総利益が得られます。

取引の純利益を見積もるには、特定の機会を活用するためのガスコストを見積もる必要があります。これは、RPC ノードへの eth_call を介してトランザクションをシミュレートすることで正確に実行できますが、時間がかかり、ブロックごとに数十回しか実行できません。

まず、固定の取引ガスコスト (実際には下限) を仮定してガスコストの総見積りを作成し、ガスコストをカバーするのに十分な収益性のない機会を除外します。そうして初めて、残りの機会のガスコストの正確な見積もりが実行されます。

以下は、すべてのペアとすべてのプールを調べて、利益別に機会を並べ替えるコードです。

# [...]
# pool_dict内の各プールのリザーブを取得
to_fetch = [] # リザーブを取得する必要があるプールのアドレスのリスト。
for pair, pool_list in pool_dict.items():
    for pair_object in pool_list:
        to_fetch.append(pair_object["pair"]) # プールのアドレスを追加
print(f"Fetching reserves of {len(to_fetch)} pools...")

# getReservesParallel()はMEV botシリーズの記事2からです
reserveList = asyncio.get_event_loop().run_until_complete(getReservesParallel(to_fetch, providersAsync))

# 取引の機会のリストを作成
index = 0
opps = []
for pair, pool_list in pool_dict.items():
    # 後で使用するためにプールオブジェクトにリザーブを保存
    for pair_object in pool_list:
        pair_object["reserves"] = reserveList[index]
        index += 1

    # ペアのすべてのプールを反復処理
    for poolA in pool_list:
        for poolB in pool_list:
            # 同じプールの場合はスキップ
            if poolA["pair"] == poolB["pair"]:
                continue

            # リザーブのいずれかが0の場合はスキップ(0での除算)
            if 0 in poolA["reserves"] or 0 in poolB["reserves"]:
                continue

            # WETHが常に最初のトークンであるようにリザーブを再オーダー
            if poolA["token0"] == WETH:
                res_A = (poolA["reserves"][0], poolA["reserves"][1])
                res_B = (poolB["reserves"][0], poolB["reserves"][1])
            else:
                res_A = (poolA["reserves"][1], poolA["reserves"][0])
                res_B = (poolB["reserves"][1], poolB["reserves"][0])

            # 式を通じて最適な入力の値を計算
            x = optimal_trade_size(res_A, res_B)

            # 最適な入力が負の場合はスキップ(プールの順序が逆転)
            if x < 0:
                continue

            # ガスコストを除く総利益をWeiで計算
            profit = trade_profit(x, res_A, res_B)

            # 機会の詳細を保存。値はETHで表示。(1e18 Wei = 1 ETH)
            opps.append({
                "profit": profit / 1e18,
                "input": x / 1e18,
                "pair": pair,
                "poolA": poolA,
                "poolB": poolB,
            })

print(f"Found {len(opps)} opportunities.")

これにより、次の出力が生成されます。

3081 プールのリザーブを取得しています。
1791 件の機会が見つかりました。

これで、すべての機会のリストが完成しました。彼らの利益を見積もる必要があるだけです。現時点では、機会に基づいて取引するためのガスコストが一定であると単純に仮定します。

Uniswap V2 でのスワップのガスコストには下限を使用する必要があります。実験により、この値は 43k ガスに近いことがわかりました。

機会を活用するには 2 つのスワップが必要で、イーサリアムでトランザクションを実行するには一律 21,000 ガスがかかり、機会ごとに合計 107,000 ガスがかかります。

各機会の推定純利益を計算するコードは次のとおりです。

# [...]
# 機会ごとに107kガスのハードコードされたガスコストを使用
gp = w3.eth.gas_price
for opp in opps:
    opp["net_profit"] = opp["profit"] - 107000 * gp / 1e18

# 推定純利益でソート
opps.sort(key=lambda x: x["net_profit"], reverse=True)

# 正の機会を保持
positive_opps = [opp for opp in opps if opp["net_profit"] > 0]

### 統計を表示
# 正の機会の数
print(f"{len(positive_opps)}の正の機会を見つけました。")

# 各機会の詳細
ETH_PRICE = 1900 # ETHの価格を動的に取得する必要があります
for opp in positive_opps:
    print(f"利益: {opp['net_profit']} ETH (${opp['net_profit'] * ETH_PRICE}ドル)")
    print(f"入力: {opp['input']} ETH (${opp['input'] * ETH_PRICE}ドル)")
    print(f"プールA: {opp['poolA']['pair']}")
    print(f"プールB: {opp['poolB']['pair']}")
    print()


スクリプトの出力は次のとおりです。

Found 57 positive opportunities.

Profit: 4.936025725859028 ETH ($9378.448879132153)
Input: 1.7958289984719014 ETH ($3412.075097096613)
Pool A: 0x1498bd576454159Bb81B5Ce532692a8752D163e8
Pool B: 0x7D7E813082eF6c143277c71786e5bE626ec77b20
{'profit': 4.9374642090282865, 'input': 1.7958(...)
Profit: 4.756587769768892 ETH ($9037.516762560894)
Input: 0.32908348765283796 ETH ($625.2586265403921)
Pool A: 0x486c1609f9605fA14C28E311b7D708B0541cd2f5
Pool B: 0x5e81b946b61F3C7F73Bf84dd961dE3A0A78E8c33
{'profit': 4.7580262529381505, 'input': 0.329(...)
Profit: 0.8147203063054365 ETH ($1547.9685819803292)
Input: 0.6715171730669338 ETH ($1275.8826288271744)
Pool A: 0x1f1B4836Dde1859e2edE1C6155140318EF5931C2
Pool B: 0x1f7efDcD748F43Fc4BeAe6897e5a6DDd865DcceA
{'profit': 0.8161587894746954, 'input': 0.671(...)
(...)

疑わしいほどの高利益です。最初に実行する必要があるのは、コードが正しいことを確認することです。コードを慎重にチェックした結果、コードが正しいことがわかりました。

この利益は本物なのでしょうか?結局のところ、そうではありません。私たちは、戦略で考慮するプールを選択する際に網を広げすぎたため、有毒なトークンのプールを手に入れてしまいました。

ERC20 トークン標準では、相互運用性のためのインターフェイスのみが説明されています。誰でもこのインターフェイスを実装するトークンをデプロイし、非正統的な動作を実装することを選択できます。これがまさにここで問題となっているものです。

一部のトークン作成者は、取引されるプールが販売できず、トークンの購入のみが行われるように ERC20 を作成します。一部のトークン コントラクトには、作成者がすべてのユーザーをラグ プルできるようにするキルスイッチ メカニズムが備わっています。

MEV ボットでは、これらの有害なトークンを除外する必要があります。これについては今後の記事で取り上げます。

明らかに有害なトークンを手動で除外すると、次の 42 の機会が残ります。

Profit:  0.004126583158496902  ETH  ($7.840508001144114) 
Input:  0.008369804833786892  ETH  ($15.902629184195094) 
Pool A:  0xdF42388059692150d0A9De836E4171c7B9c09CBf 
Pool B:  0xf98fCEB2DC0Fa2B3f32ABccc5e8495E961370B23
 { 'profit':  0.005565066327755902 , (...) 

Profit:  0.004092580415474992  ETH  ($7.775902789402485) 
Input:  0.014696360216108083  ETH  ($27.92308441060536) 
Pool A :  0xfDBFb4239935A15C2C348400570E34De3b044c5F
プール B:  0x0F15d69a7E5998252ccC39Ad239Cef67fa2a9369
 { '利益': 0.005531063584733992 , (...) 

Profit:  0.003693235163284344  ETH  ($7.017146810240254) 
Input:  0.1392339178514088  ETH  ($264.5444439176767) 
Pool A:  0x2957215d0473d2c811A075725Da3C31D2af075F1 
Pool B:  0xF110783EbD020DCFBA91Cd1976b79a6E510846AA
 { 'profit':  0.005131718332543344 , (...) 

Profit:  0.003674128918827048  ETH  ($6.980844945771391) 
Input:  0.2719041848570484  ETH  ($516.617951228392)
プール A:  0xBa19343ff3E9f496F17C7333cdeeD212D65A8425
プール B: 0xD30567f1d084f411572f202ebb13261CE9F46325
 { '利益':  0.005112612088086048 , (...) 
(...)

一般に、利益はトランザクションの実行に必要な投入額よりも低いことに注意してください。

これらの利益ははるかに合理的です。ただし、各機会のガスコストの非常に大まかな見積もりを使用したため、これらは依然として最良のシナリオの利益であることに注意してください。

今後の記事では、各機会のガスコストの正確な値を取得するために、取引の実行をシミュレーションします。

実行をシミュレートするには、まず取引を実行するスマート コントラクトを開発する必要があります。これは次の記事のトピックです。

結論

これで、MEV アービトラージ ボットの境界を明確に定義できました。

私たちは裁定取引戦略の背後にある数学理論を調査し、それを Python で実装しました。

これで潜在的な裁定取引の機会のリストができたので、最終的な利益値を得るためにその実行をシミュレートする必要があります。そのためには、取引スマートコントラクトを準備する必要があります。

次の記事では、Solidity でそのようなスマート コントラクトを開発し、最初の裁定取引をシミュレーションします。

完全なコードは、この記事に関連する github リポジトリにあります。スクリプトは Jupyter ノートブックで実行するのが最適です。