仮想通貨botter Advent Calendar 2023 の記事です。2022年の記事に引き続き、BotFramework RustyBot (rbot)の紹介記事となります。
バックテストから本番まで簡単に実行できるという開発コンセプトが、一旦の実現をしましたのでリリースします。
動作やバグ報告、要望など、いつでも歓迎します(Discordで場所つくりました)
※1年の間に気がつけばBTCは去年の3倍の価格になっています。投入した時間をアルバイトしてBTCをHODLした方が良かったかもしれませんが、2025年の夏に向けて大きく回収するために頑張りましょう。
RustyBotのアピールポイント
- バックテスト、フォワードテスト、本番の開発ライフサイクル全てをサポートするフレームワーク。
- PythonでBotのロジックを簡単に記述できます。
- 簡単インストール(pypiからpipでインストール。Mac、Linux、Windows対応)
- OHLCV,板や約定ログをPolarsのDataFrame形式で提供。Jupyter Notebookとの相性がよい(簡単にグラフ化可能。pandasへも変換が簡単)
- Botを評価するためにPlotlyを使った結果のチャート化サンプル提供
- BacktestモードはColabでも利用可能(ColabがWebSocket接続がNGのためリアルタイムデータ利用のdry_run/real_runはローカル環境で動かしてください)
- 小ネタのニクイ機能を提供
- WebSocketの再接続:Binanceの仕様で最大24Hまでのところ、12H毎に再接続。切れたら再接続するのではなく、切れる前に2つ並行して接続しデータ同期を確認した後切り替えのためデータロスなし。
- データの差分ダウンロード:約定履歴を最新に維持するためにWebアーカイブ、RESTAPI、WebSocketを自動的に組み合わせ。
- Botはスレッドを意識しない:WebSocket2つ、DBで1つ合計3つのワーカースレッドがが動いていてもBotに意識させない作り(asyncとか難しいの出てこない)
- オーダー時にsize=(0.01*0.995) のように指定不可の少数を指定しても適宜四捨五入してエラーを回避(内部的にDecimal型を全面採用)。
(太字は2022年から2023年にかけての進捗)
想定しているBotのタイプは、①CEXの取引履歴を参照し、②数秒から数時間で注文を出すタイプのBotです。一定の注文数があることで、バックテストから本番まで同様の取引環境で動作させることが可能になります。もちろん、暴落を検知して取引を行うようなBotも実装可能です。しかし、過去に発生していない稀なイベントを検出するロジックでは、バックテストは有効ではなく、フレームワークが支援する開発プロセスのメリットを十分に享受できません。
取引所はAPIの充実度からBinanceを選んで実装しました。これは、儲かるからとか日本に進出した記念等で推奨しているわけではありません[ここにアフィリエイトのリンクを貼る]。
ぼくがかんがえる最強の開発のライフサイクル
2022年と基本コンセプトは変わっていません。できるだけ高速にアイディアを実装し、テストし本番にもっていくためのフレームワークを目指して開発しています。(詳しくは去年の記事もあわせて読んでください)
去年はアイディアをバックテストするところまででしたが、今回BackTest → ForwardTest → Productionへのループが完成しました。3つのフェーズで同じBotのロジックを動かすことができます(板情報を除く)。動作の切り替えは、Botの呼び出し方法を1行変更するだけです。
Botの開発各フェーズ
開発フェーズには3つあると考えました。まとめると以下のようになります。
4本足でロジックを組み立てることができるチャネルブレークアウト系は「BackTest → ForwardTest → 本番」のサイクルを繰り返すことになります。
板情報が必要なMMBotなどは「ForwardTest → 本番」のサイクルを繰り返します。
開発のフェーズ | 特徴 | limitオーダー | marketオーダ | 取引情報 | 板情報 | 注意事項 |
---|---|---|---|---|---|---|
バックテスト back_test | 過去のデータを利用してBotの性能を評価します。 | 相対するTickで処理 | 最終約定価格より一定値スリップ | ⭕️ | ❌ | ・過去の約定データを順番にBotに与えていきます。 ・任意の過去時間からのテストが可能ですが、データが膨大になりCPU・メモリ・ディスクを消費します。 ・バックテストだけやり込むと過去のデータに対してオーバーフィットする危険性があります。 |
フォーワードテスト dry_run | 現在の市場データをリアルタイムで使用してBotの性能を評価します。 | 相対するTickで処理 | 板情をみて処理(ただし、板は消費できないので、同じ条件で何回でも約定する) | ⭕️ | ⭕️ | ・リアルタイムのデータをBotに与えます。 ・オーダー処理だけ内部でシュミレートします。 ・常に未知の最新データでテストできる一方、リアルタイム時間が必要になります。 |
本番 real_run | Botが実際の市場で稼働し、取引を行います。 | 取引所エンジン | 取引所エンジン | ⭕️ | ⭕️ | ・back_test, dry_runとテストしてきたロジックは、ほぼそのまま動くはずです。 ・残高が不足するなどの本番ならではのエラーに注意が必要です。 ・取引所が提供するTESTNETを活用することも良いです。 |
テストの段階では、すぐにグラフ化して分析できるように、Jupyter上で動作するサンプルを提供しました。
しかし、本番環境では、純粋なPythonを使用する必要があります。Jupyterでは、ログがメモリに蓄積されて長時間運用すると停止したり不安定になる可能性があるため、注意が必要です。
全体アーキテクチャ図
YourAgent(クラス名は任意)は、自身のロジックを記述するBOTになります。on_init
、on_tick
、on_clock
、on_update
を実装することで、各イベントを受け取ります。
YourAgentは、送られてくるSessionオブジェクトを利用して市況データや自身のポジション状況を取得し、売買判断ロジックを実行します。その結果は、Sessionオブジェクトのオーダー発行メソッドを使用してオーダーを発行します。
売買判断ロジックで使用したインジケータは、Sessionオブジェクトのlog_indicator
メソッドを使用して保存すると、後で分析が可能となり便利です。
このように、AgentクラスはSessionオブジェクトを通じてMarket(取引所)とやりとりします。これにより、バックテストから本番まで同じコードでの運用が可能となります。
各クラスマニュアル
クラスのリファレンスマニュアルは、GitHubにおいてありますので参照してください。
Agent実装スケルトン
イベント毎に内容をプリントだけのスケルトンを示します。ここから各関数の中でsessionオブジェクト(マニュアル)を利用して市況データを取得し、オーダーを発行するように拡張していってください。
執行ロジック作成に不要な関数は削除またはコメントアウトしてください。
動かしながらのほうがわかりやすい方は、GitHubからColabでうごかしてください。以下のリンクで試せます。バックテストモードはBinanceのアカウント不要です。
class SkeltonAgent: # クラス名は任意です
def __init__(self):
"""Botの初期化処理です。パラメータなどを設定するといいでしょう。利用しなくても構いません。
"""
self.tick_count = 0 # on_tickが呼び出された回数をカウントします。
def on_init(self, session):
"""Botの初期化処理。Botの初期化時に一度だけ呼ばれます。
通常はsession.clock_interval_secを指定しon_clockの呼び出し間隔を設定します。
Args:
session: セッション情報(Botの初期化時用に渡されます)
"""
session.clock_interval_sec = 60 * 60 * 1 # 1時間ごとにon_clockを呼び出す
def on_tick(self, session, side, price, size):
"""取引所からの全ての約定イベント毎に呼び出される処理です(高頻度で呼び出されます)
Args:
session: セッション情報(市況情報の取得や注文するために利用します)
side: 売買区分です。"Buy"または"Sell"が設定されます。
price: 約定価格です。
size: 約定数量です。
"""
# on_tickは高頻度によびだされるので、1万回に1回だけ内容をプリントします。
if self.tick_count % 10_000 == 0:
print("on_tick: ", side, price, size)
self.tick_count += 1
def on_clock(self, session, clock):
"""定期的にフレームワークから呼び出される処理です。
session.clock_interval_secで指定した間隔で呼び出されます。
Args:
session: セッション情報(市況情報の取得や注文するために利用します)
clock: 現在時刻です。エポック時間からのマイクロ秒で表されます。
"""
# 現在の時刻をプリントします。
print("on_clock: ", clock)
def on_update(self, session, updated_order):
"""自分の注文状態が変化した場合に呼び出される処理です。
Args:
session: セッション情報(市況情報の取得や注文するために利用します)
updated_order: 注文状態が変化した注文情報です。
"""
# 注文状態が変化した注文情報をプリントします。オーダーを発行しない限り呼び出されません。
print("on_update", updated_order)
スケルトンの実行
以下のとおり、バックテスト、フォーワードテスト、本番を実行クラスの関数を切り替えるだけで行うことができます。
(共通準備)フレームワークの初期化とヒストリカルデータダウンロード
binance へ接続し、データをダウンロードします。その後、Agent(Bot)のインスタンスと実行用Runnerのインスタンスを作成します。
# Binanceへ接続
market = BinanceMarket(BinanceConfig.BTCUSDT)
# 過去1日分のデータを取得
market.download(ndays=1, verbose=True, archive_only=True)
agent = SkeltonAgent() # テスト対象Agent(Bot)のインスタンス化
runner = Runner() # 実行クラスRunnerのインスタンス化
去年とはDBの構造がかわっているため古いバージョンを使ったことがある場合はエラーになる可能性があります。その場合は、market.file_name
でDBの場所がわかりますので、DBを削除したのち再起動してください。
バックテスト
Runnerのインスタンスメソッドback_test
にmarketとagentを与えることでバックテストが実行できます。
session = runner.back_test(
market=market, # marketを設定
agent=agent, # テスト用Agentを指定
start_time=NOW() - DAYS(1), # 1日前からテスト
end_time=0,#最新のデータまでテスト
verbose=True # 進捗を表示
)
back_testの戻り値はsessionオブジェクトです。中にLoggerオブジェクトがあるので取り出してログを分析します。
フォーワードテスト
バックテストのrunner.back_test
部分を次のようにrunner.dry_run
へ変更してください。フォーワードテストには開始と終了の時間の概念が存在しません。そのため、execute_time
というパラメータを追加しましょう。このパラメータを指定すると、その時間だけフォーワードテストが実行されます。指定しない場合、テストは無限に続きます。
注意:フォーワードテストを始めると、データを最新の状態に更新するため、テストの開始に少々時間がかかります。しかし時間がかかりすぎる場合は、DBが別のプロセスによってロックされている可能性があります。このフレームワークでは、1つのプロセスしか書き込み系を立ち上げることができない制約があるため、他のプロセスがないか確認してください。
session = runner.dry_run(
market=market, # marketを設定
agent=agent, # テスト用Agentを指定
execute_time = 180, # 60*3=180[sec] 3分間テストします。
verbose=True # 進捗を表示
)
戻り値はバックテストと同様にsessionオブジェクトが得られます。
本番
最初にAPIキーをSECRETを環境変数へセットしておきます。
`BINANCE_API_KEY` APIキーをセットします。
`BINANCE_API_SECRET` APIシークレットをセットします。
フォーワードテストのメソッド名をreal_run
に変更すれば、本番モードで動作します。
フォーワードテストと同じようにexecute_timeを指定することは可能ですが、本来、本番は連続して動作するため戻り値を使用することはできません。その代わりにlog_fileを指定してログをファイルに出力します。
これを別のプロセスのLoggerユーティリティで読み込むことで、本番動作中でも分析が可能となります。
runner.real_run(
market=market,
agent=agent,
# execute_time = 180, # 実行確認時のみ設定。指定しないと永久動作。
verbose=True,
log_file="skelton_bot.log" #ログファイルを指定し出力させる。
)
Botサンプル実装
動くデモつくりましたので、まずは操作してその簡単さを実感してください。以下に、それぞれのサンプルBOTとAgent部のロジックを抜き出したものを表示します。WebSocketと繋がないBOT1のバックテストモードはColabでも動きます。
サンプルBOT(1):バジルさんの記事「Binanceで数か月運用していた高頻度botの紹介」を実装してみる。
約定履歴だけを使うパターンで、バックテスト・フォーワードテスト・本番が同じコードで行えるパターン。
情報公開いただきましてありがとうございます!!
ロジック概要
バジルさんのロジックを引用すると以下のとおり
- 取引頻度 約定履歴から作成した3秒足ごと
- エントリー/イグジット条件
- 常に買いから入り、終値*0.9995など決め打ちでの買い指値(数字は仮)
- イグジットも終値*1.0005など決め打ちでの売り指値(数字は仮)
- ピラミッディングやドテンの類はなし
- 手持ちのTUSD(2500TUSDくらい)分のBTCを買って売るだけ
- 4/27に大きく焼かれてからは、直近数時間の値動きが大きすぎるときはエントリーしないように対応
- 約定回数 200~300回/日
これを今回の実装用に以下のように調整・具体化しました。
- 3秒毎にエントリーするかどうかを判定。
- エントリー条件
- 前回のオーダーが残っていない(オーダー中および買いポジション(相当)がない)
- 直前の値動きが大きくない
- 30分足を4本とり、平均の値幅が閾値以下。
- オーダーキャンセル条件
- EXPIRE_TIME(600秒: 10分)以上約定しないオーダーはキャンセルする。
- 執行戦略
- 買い
- エントリー条件が揃ったら執行
- 指値: 3秒足の終値 * (1-OFFSET) // OFFSETは0.0005などの値
- 数量: 0.001 BTC
- 売り
- 買いオーダーの約定が残っていたら執行
- 指値:買いオーダー発行時の3秒足の終値 * (1+OFFSET) // OFFSETは0.0005などの値
- 数量:約定した買い注文と同数(=仮想敵なPosition)
- その他:売りオーダーがExpireしてCancelされた場合、即、market_orderで投げうる。
- 買い
class BasilAgent:
def __init__(self):
self.OFFSET = 0.00_05
self.EXPIRE_TIME = 600 # 600[sec] = 10[min]
self.ORDER_SIZE = 0.01
self.RANGE = 300
def on_init(self, session):
session.clock_interval_sec = 3 # 3秒ごとに on_clock を呼び出す
def on_clock(self, session, clock):
if session.expire_order(self.EXPIRE_TIME): # 期限切れの注文をキャンセルする.
return # 期限切れがあればリターン
if session.buy_orders or session.sell_orders: # 既に注文がある場合はリターン
return
# 1時間足のレンジを計算してログに出力する。レンジが大きい場合はトレードしない。
ohlcv1h = session.ohlcv(60*60, 4)
range = (ohlcv1h['high']-ohlcv1h['low']).mean()
session.log_indicator("range", range)
if self.RANGE < range:
return
ohlcv = session.ohlcv(3, 1) # 3秒足を1本分取得。
if len(ohlcv) < 1: # 3秒間に約定データがない場合リターン
print("NO OHLCV DATA")
return
if session.position <= 0.001: # ポジションが少ない場合は買い注文を出す。
order_price = ohlcv['close'][-1] * (1 - self.OFFSET)
print("BUY ORDER: ", order_price, self.ORDER_SIZE)
session.limit_order('Buy', order_price, self.ORDER_SIZE - session.position)
else: # ポジションがある場合は売り注文を出す。
order_price = ohlcv['close'][-1] * (1 + self.OFFSET)
print("SELL ORDER: ", order_price, session.position)
session.limit_order('Sell', order_price, session.position)
def on_update(self, session, updated_order):
# 売りオーダーが期限切れされた場合には、成り行きで売り注文を出す(ロスカット)
if updated_order.status == 'Canceled':
if updated_order.side == 'Sell':
session.market_order('Sell', updated_order.remaining_size)
実行
コードはこちら。Clabで動くのでぜひ試してみてください。
動かすと以下のようなチャートがでてきて分析ができるようになっています。今回はPlotlyの説明はしませんが、NoteBookみて適宜修正してみてください。
サンプルBOT(2): 片道切符まんさんの「取引大会で優勝したときのロジック」を実素してみる。
今度は板を見ながら動くBOTを実装してみましょう。みんな大好きMM系に分類されるものです。
片道切符まんさんのNote https://note.com/_and_go/n/na62475340756 を実装します。
情報公開いただきましてありがとうございます!!
ロジック概要
いくつかパターンがあるみたいですが、今回は以下のパターンを実装します。
- Best rate付近の大きめの板の手前に指値を出す。
- Buy, Sell両方にオーダーを出す。
おそらくMMタイプのロジックに該当すると思われます。具体的には以下のように理解して実装しました。
- 「大きめの板」の定義はどうするか?
- 板の最初は除外。2番目の板から計算。
- 定数 ignore_size 以下の板を無視
- 最初の ignore_size より大きな板を壁とする。
- 執行ロジック
- 1分に1回、もしくはオーダーが執行されたら「大きめの板」の前の価格を計算
- 売り・買いの両方にオーダーを出す(ただし以下のようにして同じ方向のオーダーは1つにする)
- 売注文残がなく、かつ、買い注文が約定していてポジションがマイナスの場合 → 売り注文
- 買注文残がなく、かつ、売り注文が約定していてポジションがプラスの場合 → 買注文
- 注:現物にはポジションの概念がありませんが、Bot起動時からのセッションで売り・買いの約定数の差分をポジションとして計算しています。
板の処理方法
板はPolarsのDataFrame形式で取得できます。to_pandas
でpandasのDataFrameへ変換もできます。
bid, ask = session.board
同一タイミングで上下の板を取得するために上記のようなコードで2つの板を取得します。それぞれ、bid[0]
, ask[0]
が最良レートになります。
Agentのコード
class MMBot:
def __init__(self):
""" MMBot クラス初期化(チューニングパラメータ設定)"""
self.ignore_size = 0.05
self.order_size = 0.01
self.price_tick = 0.01
self.expire_time = 60*10
def wall_price(self, board):
"""板の壁の値段を返す"""
wall = board[1:].filter(self.ignore_size < pl.col("size"))
if len(wall) == 0:
return None
price = wall.head(1)['price'][0]
return price
def main_logic(self, session):
""" メインロジック """
if session.expire_order(self.expire_time): # 10分以上経過した注文をキャンセル
return # キャンセルしたら終了(次のループで再度注文する)
bid, ask = session.board # 板情報を取得
if len(bid) < 10 or len(ask) <10: # 板情報がない場合は終了
return
buy_price = self.wall_price(bid) # 壁が検出できた場合
if buy_price is None:
return
buy_price = buy_price + self.price_tick # 壁の一つ前の価格を計算
sell_price = self.wall_price(ask)
if sell_price is None:
return
sell_price = sell_price - self.price_tick # 壁の一つ前の価格を計算
session.log_indicator("buy_price", buy_price) # ログに壁の一つ前の価格を記録
session.log_indicator("sell_price", sell_price) # ログに壁の一つ前の価格を記録
session.log_indicator("spread", sell_price - buy_price) # 買いと売りの壁の差を記録
if not session.buy_orders and session.position <= 0: # 買い注文がなく、ポジションがマイナス(売り注文が約定済みの場合)
session.limit_order("Buy", buy_price, self.order_size) # 壁の一つ前の価格で買い注文を出す
print("Buy order price", buy_price, "size", self.order_size)
if not session.sell_orders and 0 <= session.position: # 売り注文がなく、ポジションがプラス(買い注文が約定済みの場合)
session.limit_order("Sell", sell_price, self.order_size) # 壁の一つ前の価格で売り注文を出す
print("Sell order price", sell_price, "size", self.order_size)
def on_init(self, session):
""" フレームワークから呼び出される初期化 on_clockの呼び出し間隔を設定 """
session.clock_interval_sec = 60 # 60秒ごとに on_clock を呼び出す
def on_clock(self, session, timestamp):
""" フレームワークから呼び出される定期的な処理 """
self.main_logic(session)
def on_update(self, session, order):
""" フレームワークから呼び出される注文更新時の処理 """
# 注文が約定したかキャンセルされたら、次のオーダーを出すためにメインロジックを呼び出す
if order.status == "Filled" or order.status == "Canceled":
self.main_logic(session)
コードはこちら
WebSocketを利用してフォーワードテストするコードです(ID/キー不要)。ローカルのJupyterでためしてみてください。
両サイドにオーダだすBotって結局約定したときには、そこでとまらなくて突き抜けちゃうみたい。なかなか難しい。
まとめ
本来はフレームワークよりもロジック部分に集中し、数多く打席に立つことが本来は一番大切だと理解しているのですが、「楽するための努力」の寄り道を経てフレームワークを完成させました。
私自身は以下の点で他では見ないタイプのフレームワークになっていると自負しています。①ロジックが非常にシンプルに書ける、②そのロジックでテストから本番まで実行できる、③Tickベースでデータベースを保持していて、柔軟な足が作れる、④Tickベースでバックテストが行えるなど
Binanceにしか対応していないし、まだ安定性にも問題点もあります。例えば、2つプログラムを立ち上げるとデータベースがロックしたりしますが、冬もそろそろ終わりそうなので、ロジック作成に向けて舵を切りたいと考えています。
Discord
このフレームワークは無保証・無責任ですが、(将来のことは留保しつつも)本バージョンについては永久に無料で使用できますので、ぜひ試してみてください。内容が不明で不安な方のために、pypiにソースパッケージもアップロードしていますので、中身を確認することが可能です。
動作状況やご要望など、ぜひお聞かせください!Discordつくってみました。初の運営なので不手際があるかもしれませんが、ぜひご参加ください。
コメント