この記事には最新版があります。なお、この記事に書いた開発コンセプトについては最新版でも有効です。
仮想通貨Botterr AdventCalendar2022
本記事はQiitaのAdventCalendar向けにまとめた記事です。仮想通貨取引BotのBackTesterを公開しますのでぜひ使ってみてください。BackTester本体だけではなくて、開発プロセスや設計指針なども説明しました。
来年からアウトプットを積極的にやっていこうと思っています。発表の機会をあたえてくださった@hohetoさんに感謝
チュートリアルにたどり着くまで話が長くなってしまったので、とりあえず触ってみたい人は、いきなり下のリンクからからどうぞ(Open in Colabで開くとぐりぐり動くチャートでバックテストの結果がみれます)
冬の時代の過ごし方
冬です。寒いです。FTX破綻からさらに寒さが増しました。でも振り返ると中国が仮想通貨禁止したときよりも暴落率では小さいし、額でいえばイーロンマスクのTweetよりもダメージが少ない。もしかしたら暖冬?なんて思っています。
これまでBotを完成させることなくHODLerとしてお祈り中心だったのですが、それでは「Bitcoinでヨットを買う」という目標達成の前に歳をとってヨットを操船できる体力がなくなりそうです。そのために次の春にむけて積極的にBOTを作ってみようと考えました。
本記事のゴール
・取引BOTのバックテスト用のフレームワークを作成しましたので公開します。
・フレームワークを利用すると簡単にバックテストができることがわかるチュートリアルを提供します。
・有償化やアフィリエイトで「坑夫ではなくてリーバイス」も目指してみたいのですが、日本人が利用できなくなったBinance用のspot用に作ってしまったので本バージョンにおいては無料/無保証で提供します(Binanceの前はFTX向けに開発していた。トホホ)。
なおバックテスト用にはPublicAPIのみ利用しているのでBinanceのID登録は不要で日本からも試すことができます。ぜひぜひ使ってみてください&ご意見お聞かせください。開発フレームワークの便利さが実感できると思います。
著作権は保持しますが、クリスマスなので勢いでソースコードまで公開しちゃうぞ(Nothing to hide !!)。
ソースみたらパクりたくなるよりも、多分こんな面倒なのは自分でつくりたくなーいと実感すると思います。
#BackTesterは面倒すぎるのでつくらないほうがいい
私の開発能力がしょぼいのもあるけど、ここまで来るのに2ヶ月+αかかりました。それでもBinanceのSPOTのBTCBUSDのMakerしか対応できていないし、大量のデータをデータを扱うためテストが特別に面倒くさいし、まったく自力開発はお勧めしません(心の奥底に眠るリーバイスへの憧れ)。
チュートリアルのお題
なかなか公開されたBotのロジックは少ないのですが、UKIさんの2018年のTweetをできるだけそのまま実装して今回作成したフレームワーク有効性を検証してみようと思います(ロジックを公開してくださったUKIさんに感謝)。
チュートリアルのゴール
最後には以下のTweetしたチャート表示ができるところまでのチュートリアルを行います。フレームワークがほとんど処理してくれるので実際に書くコードは100行程度で完成します。
またJupyter notebook形式で動くチュートリアルを提供するので、ライブラリをpipでインストールしたあとは各セルを順番に実行するだけで試すことができます。開発はMac Miniでおこなっていますが、Mac/Linux/Win用コンパイル済みバイナリも提供します(Git hub Actionsありがとー。とても便利。Winは環境そろえてなくてテストしてないのでまずはColabでためしてみてください)
グラフのProfitはバックテスト期間によってプラスになったりマイナスになったり安定しませんが、今回は2018年に有効だったロジックの検証が目的ではなく、Botの開発プロセスとフレームワークがBot開発を加速するということを実感することが目的となりますので、安定的にプラスになるまでのロジックの追求は今回は行いません。是非各自やってみてください。
ぱっと見た目ではインジケーターとしてはかなり優秀で、いい感じのところでトレンド転換検出しているけれども注文が刺さらない(Expireしてしまう)あたりに改善のポイントがありそうです。この「刺さらない」ことの検証ができるのが今回のBackTesterのポイントになります。
チャートライブラリとしては界隈ではPlotlyが流行中みたいですが、うっかり先に出会ってしまったBokehを使っています。Plotlyは触っていないのでどっちがいいとか比較はできませんが、Bokehはシンプルで使いやすい印象です。グリグリ動くあたりmplfinanceにはもう戻れないです。さらに横に長くなる時系列に限っていえばスクロールが出来ないmatplotlibにも戻りたくない感じです。
(ボケってなんとBokehだったって知ってました???古い日本語かと思っていました私)
僕の考える最強のBot開発プロセス
まずは開発プロセスから検討しました。
Botを最後まで完成させて実践投入したことがない私が、「僕の考える最強のBot開発プロセス」を図にしました(なんかどこかでみたことがあるやつに似ているぞ[パクりました])
「アイディア」→「バックテスト」→「フォーワードテスト」→「本番投入」の学習サイクルをいかに早くまわすかが勝負になるのだろうと思います。そのためには各ステップでBotのロジック以外のところに時間を取られない工夫が必要になってくると考えました。
バックテストから本番までできるだけ同じコードで、Botのロジック以外は書かなくてよいフレームワークが有効と考え、冬の間に整備することとしました。夏にはBotのロジックだけに集中できる環境を整備したいと考えたのです。
本当に重要なのはProductionをどれだけやれるか一点なので、BackTester書いている時間があるならば実践投入から始めるべきなのですが、こういう回り道も冬ならば許されると考えました。
僕の考える最強のBotフレームワーク
いまのところバックテストまでしか完成していませんが「僕の考える最強のBotフレームワーク」を設計しました。
BaseAgent
クラスを継承してYour Agent(任意名)
を作成し、Session
クラスの提供するAPIを使ってロジックを記述していきます。
Agent
クラスがSession
クラスとしかインタラクトしない設計にすることで、Agent
クラスには手をいれることなくRunner
クラスがバックテスト・フォーワードテスト・本番と接続先を切り替えることが可能となります(予定)。
工夫1: Tick単位のイベントベースバックテストを採用
「Pythonからはじめるアルゴリズムトレード ―自動売買の基礎と機械学習の本格導入に向けたPythonプログラミングhttps://amzn.to/3HBrl88」によれば、バックテストは、ベクトル化バックテストとイベントベースバックテストに分類されます。
一言でまとめると、約定履歴等をpandasなどの配列(ベクトル)に入れて一括処理するのがベクトル化バックテストで、約定イベント等を逐次処理していくのがイベントベースバックテスト。
ベクトル化バックテストはpd.DataFrameにインジケーターのカラムを追加して、インジケーターから売買判断するだけで完成します。場合によっては数行で完成するし、とても高速です。
しかし本番時には当然データは揃っていないのでベクトル化で処理することは難しく、本番とテストのロジックコードを共通化する目標に対してはイベントベースバックテストの方が良い。さらにイベントベースバックテストには、一度に大きなオーダーをすると約定しない、あるいは時間がかかるという執行戦略もテストすることができるメリットがあります(今回約定判定は、板の最後に並びつづけた前提で一つ遠い板がオーダーサイズ分消費されたら約定としています。ちょっと辛口判定です)
イベントベースのBackTesterであるBacktesting.py
など多くのバックテスターはOHLCVデータを使っていることが多いようですが、今回Tickベースでデータでバックテストする方式を採用しました(生データを全部使う方針)。
1日分で数百万行のイベント処理が必要になるため、どうしても遅くなってしまうデメリットがあるのですが、ohlcvをTickから作成する方式が取れるので秒足から時間足ぐらいまでを柔軟に作成することができます。一方月足などは、生成する元データが不足たり、生成時間が必要だったりして実用的にはなりません。数分から数時間で注文を行う中頻度に一番適しているBackTesterになったと思います。
性能が出ない問題に対してはBotのロジック部は簡単に書けるPython、 バックテストで取引所の動きをシュミレートする部分はRustで書くという荒技でそれなりに実用レベルの速度に仕上げました。Pythonもちゃんと書けば早いはずなのでRustによる性能に対する効果の大きさはわかりませんが、数ヶ月開発続けるための言語としてはコンパイル時にチェックが入る型付言語のほうが快適に感じました。
工夫2: ハリウッドプリンシプル(Don’t call us, we’ll call you)に基づくAgent設計
main
をどこに置くか、というのがもう一つの設計ポイントになります。
mainをAgent側に置く方法例としては、たとえば以下のような記述になります。ここでのポイントは永久ループとsleepを使うところ。mainが自分の手元にあることで自由度が高いがsleepの間コントロール外になってしまう点が問題になりそうです。
main をBot(Agent)側に置くパターン
while True:
ohlcv = get_ohlcv()
long_singal = detect_long_signal(ohlcv)
if long_signal:
make_long()
else:
make_short()
sleep(10) # wait for next tick
そこで、逆にAgentからはmain
を排除し、受動的に呼ばれる形にしました(ハリウッドプリンシプル https://en.wiktionary.org/wiki/Hollywood_principle)。
mainをフレームワーク側に置くパターン
class Agent:
def on_clock(self, time):
pass
def on_tick(self, tick_info):
pass
def on_xxxxx(self, event_info):
pass
いつ呼ばれるか(どんなon_xxxxを作ったらいいか)は、定期実行(on_clock)に加え、最後の本番を想定するとWebSocketのイベントが来る時でモデリングできるはずなのでAPIドキュメントを眺めました。
Agentにmainを書かない選択は自由度がなくなりますが、そもそもBotは取引所APIからのイベントに対応すればほとんどのことができるので問題ないし、パターンが決まっている方が品質・開発効率において有利と考えました。
定期実行(Agent.on_clock
で処理)
定期的に呼ばれる処理。今回のフレームワークではAgent.clock_interval()
が返す時間(秒)毎によびだされるようにした。
WSイベント実行
だいたいどこの取引所も似ていると思うけれど、BinanceのWSのイベントを確認する(https://binance-docs.github.io/apidocs/futures/en/#websocket-market-streams)
Publicチャネルについて、KlineなどはTrade(tick)情報で生成できるので、イベントしては、Aggregate Trade Streamのイベントにだけ対応すれば良さそう。板情報はRealMarket作成時に対応することにする。
Privateチャネルについては、とりあえずOrderUpdateでほとんどこなせると思う。マージンコールは本番時に考えよう。
チャネル | 対応 | ||
Public | Aggregate Trade Streams | Trade基本情報 | ◯Agent.on_tickで呼び出し |
Mark Price Stream | |||
Mark Price Stream for All market | |||
Kline/Candlestick Streams | |||
Continuous Contract Kline/Candlestick Streams | |||
Individual Symbol Mini Ticker Stream | |||
All Market Mini Tickers Stream | |||
Individual Symbol Ticker Streams | |||
All Market Tickers Streams | |||
Individual Symbol Book Ticker Streams | |||
All Book Tickers Stream | |||
Liquidation Order Streams | |||
All Market Liquidation Order Streams | |||
Partial Book Depth Streams | 板情報の生データ | (RealMarketで実装したい) | |
Composite Index Symbol Information Streams | |||
Private | Event: Margin Call | (これは受信したくない) | |
Event: Balance and Position Update | (今後のエンハンス対応) | ||
Event: Order Update | オーダー処理状況 | ◯Agent.on_updateで呼び出し | |
Event: Account Configuration Update previous Leverage Update |
工夫3: Jupyter Notebook上で動作させる。
バックテスターはJupyter Notebookで動作するようにしました。コードの記述、グラフ表示が一画面でできて便利。私はVisualSudio CodeのJupyterを利用しているけれど、通常のJupyuter note, Jupyter-labsでももちろんOK.
グラフ化はbokehライブラリを使っています。グリグリ動かすことができて、ポイントを拡大して確認ができて、かなり快適。なお、100日間のバックテストのように長期のテストを行う時はコマンドラインで動かした方が安定します。デバックログなどが膨大になったときにjupyter本体がうまく動かなくなることがあるみたいです。
そのため適宜Jupyterとコマンドラインを併用して開発を進めるといいと思います。バックテスト結果はpandasに入っているので、コマンドラインで動かした結果をファイルにセーブして、Jupyterからロードしてグラフ化・分析ということもできます。
工夫4: モジュールの独立性
Market
クラス、Chart
クラスは独立して利用可能です。
Market
クラスを使うと、任意の足幅のOHLCV(pd.DataFrame
形式)の作成が可能です(これだけでもかなり便利だと思います。取引所や通貨ペアの拡張要望お聞かせください)。
また保存先はSQLITE3のテーブルなので、SQLITEのDBは別のプログラムから直接読み込むこともできます(書き込みするとDBロックがかかるので調子悪い)。取引所+通貨ペア毎に以下のDBが作られてトランザクションが保存されています。このテーブルを好きな言語のsqlite3のライブラリで読み取れば全Tickデータにアクセスすることができます。
CREATE TABLE IF NOT EXISTS trades (
time_stamp INTEGER,
action TEXT,
price NUMBER,
size NUMBER,
id TEXT primary key
)
CREATE index if not exists time_index on trades(time_stamp)
Chart
クラスも独立していて、DataFrame形式でカラム名が一致していれば描画することができます。ソースもついているのでbokehライブラリの使い方として参考にして自分のプロジェクトにいれてみるのもいいと思います。bokehはネイティブでOHLCVを書くAPIは準備されておらず、髭と箱をそれぞれ別に描画するという工夫がいるけれどmplfinanceよりも絶対便利なのでmplfinance使っている人は是非みてみてください。
チュートリアルの実行方法
環境準備
Python+jyupyterの環境を準備する(ここは適宜ググってください。良い記事がたくさんあると思います)
依存ライブラリをインストールする。必要ライブラリは以下のとおり。pipでインストール可能。
- numpy 数値計算ライブラリ
- pandas データ解析ライブラリ
- bokeh インタラクティブなデータ表示ライブラリ(要Ver3以上)
! pip install --upgrade pip
! pip install numpy
! pip install pandas
! pip uninstall -y panel #Google Colabの場合 bokeh2に依存するpanelが入っているのでアンインストールする
! pip install --upgrade bokeh >= 3
バックテスト用ライブラリのダウンロード&インストール
rustで書いてあることもあってrusty-botと名前をつけました。パッケージ名はrbot
各環境用のバイナリ&ソースがありますので、ファイルを選んでpipしてください(Windowsのテストをまだやっていませんで、動いた!!動かない!!というのがわかったら教えてほしいです)
Google Colabの場合こんな感じ。
! pip install https://github.com/yasstake/rusty-bot/releases/download/release-0.2.0a/rbot-0.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
適宜updateするたびにurl変わっていくのでpipで404エラーがでたらリリース一覧からURL再度確認ください。
チュートリアルファイルのダウンロード
あとはnotebookをセルごとに動かせばOK.
チュートリアル解説
jupyter noteのほうが詳しいけれど、わざわざダウンロードして動かすのも面倒な人のためにハイライトを簡単に説明します。
もう一度作るロジックを確認
日本語による読み解き
日本語でステップ毎に記述すると以下のようなロジックになる。
Agent
クラスはBaseAgent
クラスを継承する(__init__()
中にsuper().__init__()
をわすれないように)Agent.on_clock
を10分毎に呼び出すように設定する(Agent.clock_interval()
)Agent.on_clock
内で以下の処理を行う。- 前処理
- 前回のon_clock中でのオーダーが処理中の場合はなにもしない(リターン)
- Long/Short判定
- 現在時刻から2時間足を6本取得すし
ohlcv
へ格納する。APIsession.ohlcv(本数、秒間隔)
を呼ぶ。(6本目の最後の足は未確定足。10分毎に呼ばれるたびにupdateされる) - 1-5本目の足を取り出して
ohlcv5
へ代入する(ohlcv5 = ohlcv[:-2]
) - 1-5本目の足のレンジ幅(高値ー安値)の平均値を計算しパラメータKをかけたもの計算すし、
range_width
とする。計算式:(ohlcv5['high]-ohlcv5['low']).mean() * self.paramK)
- 最後の足を取り出す(
ohlcv[-2:-1]
) - 最後の足の始値から最安値の差を計算し、range_widthを超えていたらshort
- 最後の足の始値から最高値の差を計算する、range_widthを超えていたらLong
- 現在時刻から2時間足を6本取得すし
- 注文執行
- Short判定ですでにShortポジションが無い場合
- Longポジションがあればドテン。なければ通常オーダー
- Long判定で、すでにLongポジションがない場合
- Shortポジションがあればドテン。なければ通常新規オーダー
- Short判定ですでにShortポジションが無い場合
- 前処理
Agentクラス実装
日本語で書き下した内容をほぼ1対1でコードを書くと以下のようになります。最後のオーダーエントリーが「シグナル点灯した次の足の始値」ではなく、シグナル点灯直後の最良値でエントリーした点のみオリジナルと変更しましたが、今回のBackTestを利用することで、ストレートかつシンプルにBOTが記述できるようになったことが理解できると思います。
class BreakOutAgent(BaseAgent):
"""
Agentのクラス名は任意。
BaseAgentを継承し、clock_interval, on_clockを実装する。
"""
def __init__(self, param_K=1.6):
""" super().__init()__ で上位クラスの初期化を必ずすること """
super().__init__()
self.param_K = param_K # パラメターKを設定する。
def clock_interval(self):
""" on_clockが呼び出される間隔を秒で返す。今回は10分毎にする"""
return 60 * 10
def on_clock(self, time_us, session):
""" Botのメインロジック。on_clockで設定した秒数毎に呼ばれる """
# 前処理/ 前回のon_clock中でのオーダーが処理中の場合はなにもしない(リターン)
if session.short_order_len or session.long_order_len:
return
############ メインロジック ######################
ohlcv_df = session.ohlcv(60*60*2, 6) # 2時間足(60*60*2sec)を6本取得。 最新は6番目。ただし未確定足
if len(ohlcv_df.index) < 6: # データが過去6本分そろっていない場合はなにもせずリターン
return
ohlcv5 = ohlcv_df[:-2] # 過去5本足(確定)
range_width = (ohlcv5['high'] - ohlcv5['low']).mean() * self.param_K # 価格変動レンジの平均を計算 * K
# Long/Short判定
ohlcv_latest = ohlcv_df[-2:-1] # 最新足1本(未確定)
diff_low = (ohlcv_latest['open'][0] - ohlcv_latest['low'][0])
detect_short = range_width < diff_low
diff_high = - (ohlcv_latest['open'][0] - ohlcv_latest['high'][0])
detect_long = range_width < diff_high
########## メインロジック中に利用したindicatorのロギング(あとでグラフ化するため保存) ##############
self.log_indicator('diff_low', time_us, diff_low)
self.log_indicator('diff_high', time_us, diff_high)
self.log_indicator('range_width', time_us, range_width)
########## 執行戦略(順方向のポジションがあったら保留。逆方向のポジションがのこっていたらドテン)#########
ORDER_SIZE = 0.01 # 標準オーダーサイズ(ドテンの場合はx2)
ORDER_LIFE = 60*10 # オーダーの有効期間(60x10秒=10分)
if detect_long and (not session.long_position_size):
if session.short_position_size: # Shortポジションがあった場合はドテン
session.place_order( #オーダーを発行する
'Buy', # 'Buy', 'Sell'を選択
session.best_buy_price, # 最後にbuyがtakeされた価格。sell側にはbest_sell_priceを提供。
# 任意の価格が設定できるがtakeになる価格でもmakeの処理・手数料で処理している。
ORDER_SIZE * 2, # オーダーサイズ BTCBUSDの場合BTC建で指定。ドテンなので倍サイズでオーダー
ORDER_LIFE, # オーダーの有効期限(秒)。この秒数をこえるとExpireする。
'doten Long' # あとでログで識別できるように任意の文字列が設定できる。
)
else:
session.place_order('Buy', session.best_buy_price, ORDER_SIZE, ORDER_LIFE, 'Open Long')
if detect_short and (not session.short_position_size): # short判定のとき
if session.long_position_size: # Longポジションがあった場合はドテン
session.place_order('Sell', session.best_sell_price, ORDER_SIZE * 2, ORDER_LIFE, 'Doten Short')
else:
session.place_order('Sell', session.best_sell_price, ORDER_SIZE, ORDER_LIFE, 'Open Short')
# 全Tick受け取りたい時は on_tick を実装する。
#def on_tick(self, time, session, side, price, size):
# pass
## 約定イベントを受け取りたい時は on_updateを実装する。
#def on_update(self, time, session, result):
# print(str(result))
# pass
Agentクラスさえ実装できれば、あとは定型操作で使い回しなので、Bot開発はAgentのコードだけに注力すればいいことになります。
SessionAPIのリファレンスは以下にあります。これをみながらプログラミングすることになります。
バックテストの実行
データのダウンロード
現在は、Spot取引のBinance(BN)、BTCBUSDのペアーしか対応していません(通貨単位はやってみたら動いちゃうかもしれません)。以下のようにしてローカルDBに履歴をダウンロードします。差分ダウンロードがディフォルトで再度ダウンロードするには、force
フラグをTrue
にしてください。
binance = Market.open('BN', 'BTCBUSD') # binance marketはあとで利用するので変数binanceに保存しておく
Market.download(10) # 10日前より最新のログデータをダウンロード(差分)
#Market.download(10, True) # 再ダウンロード (1日あたり10秒+アルファかかります。)
バックテスト実施
agentのインスタンスを指定してBackRunnerを作り、開始時刻・終了時刻を指定してrunするとバックテストが始まります。開始終了時刻に0を指定するとDBにあるだけのテストになります。n日前を指定するためのヘルパー関数rbot.DAYS_BEFORE
が提供されていますのでこれを利用します。
back_runner = BackRunner(
'BN', # Binance は BNと省略します。
'BTCBUSD', # 通貨ペアーを選択します。
False # 注文時に指定するサイズが通貨ペアーの右側通貨の場合True。BinanceのBTCBUSDはBTCでサイズを指定するのでFalse
)
back_runner.maker_fee_rate = 0.1 * 0.01 # maker_feeを指定(0.1%). takerは未実装。現在は相手板にぶつける注文をしてもmaker_feeが適用される。
agent = BreakOutAgent() # Agentのインスタンスを作ります(あとで利用するので変数に保存しておきます)。
# 10日前から最新までバックテストする例(最新データは2日ぐらい前のため8日間のバックテスト)。手元のM1 MacMiniで1日あたり数秒かかります。
# まれにメモリ不足でエラーがでます。その場合は複数立ち上がっていないか確認しPythonカーネルから再起動してみてください。
back_runner.run(
agent, # backtest するagentインスタンスを指定します。
rbot.DAYS_BEFORE(10), # 開始時刻を指定します(us)。0だとDBにある最初のデータから処理。DAYS_BEFOREはN日まえのtimestampを返すユーティリティ関数です。
0 # 終了時刻を指定します(us). 0だとDBにある最後のデータまで処理。
)
back_runner # back testの結果概要が表示されます
バックテストの実行結果
BackRunner
をJupyterで表示すると以下のようなバックテスト結果概要が表示される。
バックテストの売買履歴は、BackRunner.resultに格納されているので取り出す。
pd.options.display.max_rows=30
df = back_runner.result
df
pd.DataFrame
で表示される。詳細はみればわかると思うけれどnotebookにカラムの説明を記載してあります。
グラフ化
OHLCVのデータをAgentと同じ10分幅の足を10日前から作成します。
ohlcv = binance.ohlcv(DAYS_BEFORE(10), 0, 60*10)
Agent内部でログをとったインジケータのデータを取得します。
diff_low = agent.indicator('diff_low')
diff_high = agent.indicator('diff_high')
range_width = agent.indicator('range_width')
インジケータの中身はタイムスタンプとValueのカラムがあるDataFrameになっています。
ohlcv
BackRunner.result
indicator
の3つをChartクラスを使って描画したら完了。
chart = Chart(900, 400, ohlcv)
chart.draw_result(back_runner.result)
# indicatorの表示
## 新しい描画パネルを作成
chart.new_figure('indicator_panel', height=150, title='indicator')
## indicatorパネルにDataFrameを指定して折線グラフを表示
chart.line('indicator_panel', diff_low, x_key='timestamp', y_key='value', color='#ff0000', legend_label='diff_low')
chart.line('indicator_panel', diff_high, x_key='timestamp', y_key='value', color='#00ff00', legend_label='diff_high')
chart.line('indicator_panel', range_width, x_key='timestamp', y_key='value', color='#0030ff', legend_label='range_width')
chart.show()
こんなチャートが完成します。
まとめ
まだまだやるべきことがありますが、この段階でもかなり便利な雰囲気が伝わったら嬉しいです。ぜひダウンロードしてつかってみてください。
&要望があればTwitterでもBlogでもGithubのIssueでもなんでもいいのでコメントくださいませ。
コメント