AKAGAMI式 トレードシリーズ mmbot爆速汎用バックテストプログラム「MMBT」 (まだバックテストで消耗してるの?bitFlyerFX約定履歴をCSVファイルからPandasデータフレームへと読み込み、mmbotのバックテストを8000倍の速度で実行するPython3スクリプト)
目次
はじめに
mmbotを含め、高頻度に指値を入れるトレードbotでは、約定履歴を用いた精緻なバックテストが不可欠です。高速なトレードにおいては、OHLCV情報だけでは正確なバックテストができるわけもなく、約定履歴ベースの検証は避けて通れません。ただし、bitFlyerFXの1日あたり約300万件の約定履歴を使ってのバックテストは、何も工夫をしないと数時間かかります。
本noteでは、下記の3点を駆使して合計で8000倍以上速い速度でバックテストを行うプログラムを提供しています。
これらにより、数時間単位でかかるテストが15秒〜1分へと短縮できました。あなたが行っているバックテストはどの程度、正確で高速なものですか?本プログラムMMBTを用いれば、汎用的かつ高速なバックテストを実現できます。
まだバックテストで消耗してるの???
利用上の留意点
プログラム実行方法
df = csvLoad('exec_01-30.csv')
backtest(df, RANGE=6000)
のように実行します。また、下のように1行で書いてもOKです。
backtest(csvLoad('exec_01-30.csv'), RANGE=6000)
RANGEのデフォルト値は6000に設定しています。単純にこうも書けます。
backtest(csvLoad('exec_01-30.csv'))
プログラム実行結果サンプル
CSVファイル読み込み&DataFrame変換関数(csvLoad)
import pandas as pd
import numpy as np
import warnings
warnings.filterwarnings('ignore')
PREDSEC = 10
def csvLoad(filename):
df = pd.read_csv('./'+filename, names=('exec_date', 'side', 'price', 'size', 'id') ) # CSVファイル読み込み
df = df.drop(df.index[df.exec_date == 'Connection is already closed.']).dropna() # 不要行の除去
df['change'] = -df['price'].diff(-int(PREDSEC * 20)).replace(np.nan, 0) # 値動き(未来) ※サンプル
df['priceDiff5'] = df['price'].diff(int(5 * 20)).replace(np.nan, 0) # 値動き(過去) ※サンプル
df['pos'] = 0.0 # ポジションを初期化
df['pnl'] = 0.0 # 損益を初期化
return df
CSVファイルからデータを読み込み、PandasのDataFrameへと変換しています。その後、不要な行を削除しています。
さらにここではchangeとpriceDiff5という列を定義しています。これらはサンプル的に記述したもので、後では利用しません。機械学習などで価格予測をする場合、このchangeのような情報を目的変数として設定する場合があります。逆に、priceDiff5は過去からの価格変動です。
また、次に説明するバックテスト関数(backtest)で用いるposとpnlをデータフレーム上に初期化しています。
バックテスト関数(backtest)
(具体的なPython3コードは有料パートに掲載(70行ほどのコードです))
初期化処理(注文情報辞書リストorderの定義)
発注した注文の情報を保持する辞書リストであるorderを空のリストとして定義しています。また、ポジション情報を保持するpos変数、取得価格 x サイズを保持するexec_price変数(ロングポジションなら負値、ショートポジションなら正値)を定義しています。
Tempデータフレームの作成とNumpy変換
dfからSTARTを開始時点、ENDを終了時点、INTVを間隔として抽出・間引きを行い、計算用の一時的なDataFrameであるdftを作成しています。
20〜30約定が実時間のおよそ1秒間に相当します。インターバルINTVのデフォルト値20の設定ですと、約1秒間隔でのバックテストを行うことになります。INTVを減らすほど精緻なバックテストが可能になります。ただし、バックテスト処理時間はINTVに反比例してかかるようになります。上述の実行時間はINTV=20の場合です。各々の売買ロジック・スタイルに依りますが、10〜30程度を目安にご利用いただくと良いでしょう。
さらに、計算に用いる列をPandasのSeriesからNumpyのNdarrayへと事前に変換しています。for文の中でPandas DataFrameへのアクセスを発生させないことで、100倍以上の高速化を実現しています。
# ここにご自身の売買ロジックを加えること #
こちらのコメントが記載されている部分に、ご自身の売買ロジックを加えるようお願いします。buy_priceとsell_priceを何らかの指標を用いて算出し、決定しましょう。ちなみに、デフォルトではBuy/Sellの同時両面指しとしています。また、可変ロットとする場合もここで計算を行う(もしくは関連する関数を呼び出す)のが良いでしょう。
無料パートはここまでです。以下、有料パートとなります。
バックテストで何百倍もの高速化ができれば、実質的にパラメータの組み合わせを追加で何百通りも試すことができるため、極めて直接的な形でbot最適化作業の生産性の向上に結びつきます。
本noteの内容があなたのbot最適化作業を効率化・高速化させ、少しでも多くのトレード収益の伸びを実現できれば幸いです。
ここからは有料パートです。まずはご購入いただき、どうもありがとうございます。今後、的を絞った、より良い情報を発信していくための励みになります。引き続きよろしくお願いします。
バックテスト関数(backtest)
#%%cython
import numpy as np
def backtest(df, LOT=0.01, MTE=1, START=0, END=5000000, INTV=20, RANGE=6000):
# 初期化処理
order = [] # 注文格納用の辞書リストorderを初期化
pos = 0 # ポジション情報
exec_price = 0 # 取得価格 x サイズ
# Tempデータフレームの作成とNumpy変換
dft = df.iloc[START:END:INTV,:] # バックテストの範囲・間引き間隔
exec_date_values = dft.exec_date.values # 約定時刻をPandas SeriesからNumpy Ndarrayへ変換
price_values = dft.price.values # 価格をPandas SeriesからNumpy Ndarrayへ変換
pos_values = dft.pos.values # ポジションをPandas SeriesからNumpy Ndarrayへ変換
pnl_values = dft.pnl.values # 損益をPandas SeriesからNumpy Ndarrayへ変換
# START時点からEND時点までのループ
for i in range(0, len(dft)):
exec_date = exec_date_values[i] # 最終約定時刻
last = price_values[i] # 最終価格
########## ここにご自身の売買ロジックを加えること ##########
sell_price = last + 10 # Sell価格
buy_price = last - 10 # Buy価格
order.append({'exec_date':exec_date, 'side':'BUY', 'price':buy_price, 'size':LOT, 'status':'ACTIVE', 'completed_date':''})
order.append({'exec_date':exec_date, 'side':'SELL', 'price':sell_price, 'size':LOT, 'status':'ACTIVE', 'completed_date':''})
########## ここにご自身の売買ロジックを加えること ##########
# order最終値からRANGE(の2倍*MTE/INTV)だけさかのぼった注文からループ開始
for j in range(max(int(len(order)-2*RANGE*MTE/INTV),0), len(order)):
if order[j]['status'] == 'ACTIVE': # 注文が約定も期限切れもしていない場合
if order[j]['side'] == 'BUY' and last < order[j]['price']: # Buy注文が約定した場合
pos += order[j]['size'] # ポジションを増やす
exec_price -= order[j]['price'] * order[j]['size'] # 取得価格の更新
order[j]['status'] = 'COMPLETED'
order[j]['completed_date'] = exec_date
elif order[j]['side'] == 'SELL' and last > order[j]['price']: # Sell注文が約定した場合
pos -= order[j]['size'] # ポジションを減らす
exec_price += order[j]['price'] * order[j]['size'] # 取得価格の更新
order[j]['status'] = 'COMPLETED'
order[j]['completed_date'] = exec_date
else:
now_hour = int(exec_date[11:13]) # 最終時刻の時
now_minute = int(exec_date[14:16]) # 最終時刻の分
now_second = int(exec_date[17:19]) # 最終時刻の秒
order_date = order[j]['exec_date']
order_hour = int(order_date[11:13]) # 発注時刻の時
order_minute = int(order_date[14:16]) # 発注時刻の分
order_second = int(order_date[17:19]) # 発注時刻の秒
time_diff = now_hour*60*60+now_minute*60+now_second - (order_hour*60*60+order_minute*60+order_second)
if time_diff > MTE*60 or time_diff < 0: # 発注時刻からMTE(分)だけ経過していれば注文を無効化
order[j]['status'] = 'EXPIRED'
order[j]['completed_date'] = exec_date
pnl_values[i] = exec_price + pos * last # 損益を計算し、Numpy Ndarrayに保存
if i % ((END-START)/INTV/5) == 0:
print(i*INTV+START, '/ Pos:', round(pos, 2), ', PnL:', int(pnl_values[i])) # 処理状況の表示
pos_values[i] = pos # ポジションをNumpy Ndarrayに保存
dft.pos = pos_values # ポジションをNumpy NdarrayからPandas Seriesへ変換
dft.pnl = pnl_values # 損益をNumpy NdarrayからPandas Seriesへ変換
dft['profitMax'] = dft['pnl'].rolling(window=5000000, min_periods=0).max().replace(np.nan, 0) # 損益の最大値
dft['DD'] = dft['pnl'] - dft['profitMax'] # ドローダウン
mdd = int(dft['DD'].min()) # 最大ドローダウン
maxpos = round(dft.pos.max(), 2) # 最大Buyポジション
minpos = round(dft.pos.min(), 2) # 最大Sellポジション
bigpos = max(maxpos, -minpos) # 最大ポジション絶対値
print(exec_date, '/ LOT:', LOT, '/ MTE:', MTE, '/ PnL:', int(dft.pnl.values[-1]), ', MDD:', mdd, ', nPnL:', int(dft.pnl.values[-1] / bigpos), ', nMDD:', int(mdd / bigpos), ', MaxPos:', maxpos, ', MinPos:', minpos)
dft.pnl.plot() # 損益グラフをプロット
return dft, order
コメントをできるだけ丁寧に記述したため、コメントとコードを追っていただければ、挙動は理解していただけると思いますが、解説を続けます。
RANGEだけさかのぼった注文からループ開始
rangeの最終値はorderの長さ(len(order))ですが、初期値をそこから少し離した値(RANGE(の2倍*MTE/INTV)だけさかのぼった値)に設定しています。
order辞書リスト全体をfor文の対象とするのではなく、既に無効(Minute to Expireによる期限切れ)となったであろう注文をfor文の対象から除外しています。これにより40倍以上の高速化を実現しています。
注文が約定も期限切れもしていない場合
orderのBUY/SELLごとに、最終価格と指値価格を比較し、約定処理を行っています。
掲載のコードでは簡略化のため数量・部分約定までは考慮していません。より精緻に作るのであれば、このif文部分を改良ください。
さらに、posとexec_priceの更新を行い、statusをCOMPLETEDへと更新しています。
発注時刻からMTE(分)だけ経過していれば注文を無効化
now_hour/now_minute/now_secondとorder_hour/order_minute/order_secondを比較し、minute_to_expireによる無効化(期限切れ化)を定数MTEを用いて再現しています。期限の切れた注文のstatusはEXPIREDへと更新されます。
終了処理(損益グラフプロット含む)
バックテスト対象の最終時刻、用いたパラメータ、損益、最大ドローダウン、それらを最大ポジション絶対値で正規化(Normalized)したもの、最大Buy/Sellポジションを計算し、表示しています。最後に損益グラフをプロットしています。
高速化パラメータRANGEについて
for文を回すためのjの初期値を決定するRANGEは、1分間あたり最大何個の約定が含まれているかを与えるための値です。
実際の約定履歴を見るに、およそ2000程度かとは思います。ただし、デフォルトでは余裕を持たせて6000を設定しています。
小さな値に設定すると実行速度は上がりますが、取りこぼしが増える可能性が高まります。実行しながら調整してお使いください。
冒頭の%%cython(Cython有効化)
こちらのコメントアウトを外していただくことで、Cythonが有効になります。これによりコードの改変無しで、2倍の高速化を実現しています。Cython有効の状態・無効の状態で速度を比べてみてください。
Cythonでは変数の型指定を行うことでさらなる性能改善が可能のようですが、本プログラムで試したところ、特に2倍以上の改善は見られませんでした。
ちなみに、重要なことですが、Jupyter上で事前にマジックコマンド「%load_ext Cython」を別セル上にて実行しておくようにしてください。
カスタマイズ(2019/02/01 追記)
方法1
backtest関数のカスタマイズについて簡単に触れておきます。例えば、DEPTHというパラメータ(定数)を加えることを想定します。
def backtest(df, DEPTH=10, LOT=0.01, MTE=1, START=0, END=5000000, INTV=20, RANGE=6000):
デフォルトでは10円上下に指値を入れるところを、DEPTHで置き換えます。
########## ここにご自身の売買ロジックを加えること ##########
sell_price = last + DEPTH # Sell価格
buy_price = last - DEPTH # Buy価格
order.append({'exec_date':exec_date, 'side':'BUY', 'price':buy_price, 'size':LOT, 'status':'ACTIVE', 'completed_date':''})
order.append({'exec_date':exec_date, 'side':'SELL', 'price':sell_price, 'size':LOT, 'status':'ACTIVE', 'completed_date':''})
########## ここにご自身の売買ロジックを加えること ##########
DEPTHパラメータを変えてバックテストを行う場合は次のようにします。
for DEPTH in [10, 50, 100]: # パラメータを3通り試す
backtest(csvLoad('exec_01-30.csv'), DEPTH=DEPTH)
指定の範囲を試したい場合はrange(start, end, interval)を使います。
for DEPTH in range(10, 100, 10): # パラメータを10から10間隔で100未満まで試す
backtest(csvLoad('exec_01-30.csv'), DEPTH=DEPTH)
方法2
パラメータを[]で囲んだリスト形式にすることもできます。
def backtest(df, DEPTH=[10], LOT=0.01, MTE=1, START=0, END=5000000, INTV=20, RANGE=6000):
方法2では、例えばfor D in DEPTH:を追加しインデントを下げ、方法1ではDEPTHで置き換えたところをDで置き換えます。
def backtest(df, DEPTH=[10], LOT=0.01, MTE=1, START=0, END=5000000, INTV=20, RANGE=6000):
for D in DEPTH:
# 初期化処理
order = [] # 注文格納用の辞書リストorderを初期化
(省略)
########## ここにご自身の売買ロジックを加えること ##########
sell_price = last + D # Sell価格
buy_price = last - D # Buy価格
order.append({'exec_date':exec_date, 'side':'BUY', 'price':buy_price, 'size':LOT, 'status':'ACTIVE', 'completed_date':''})
order.append({'exec_date':exec_date, 'side':'SELL', 'price':sell_price, 'size':LOT, 'status':'ACTIVE', 'completed_date':''})
########## ここにご自身の売買ロジックを加えること ##########
(省略)
dft.pnl.plot() # 損益グラフをプロット
return dft, order
DEPTHパラメータを変えてバックテストを行う場合は次のようにします。
backtest(csvLoad('exec_01-30.csv'), DEPTH=[10, 50, 100])
方法1の場合、最適化するパラメータが増えるごとに、
for A in [10, 50]:
for B in [1, 2]:
for C in [100, 200]:
backtest(csvLoad('exec_01-30.csv'), A=A, B=B, C=C)
のようにfor文が多段になっていったのですが、方法2ですとスッキリと書けます。
backtest(csvLoad('exec_01-30.csv'), A=[10, 50], B=[1, 2], C=[100, 200])
以上となります。では、良いトレードbot作成ライフをお送りください!
【宣伝・告知】
AKAGAMI3年半ぶりの新トレード教材。
年間6000万円を稼いだ自動売買手法とは?
「シン・アービトラージ」公開中。
最後まで読んでいただき、どうもありがとうございます。頂いたサポートは、良質な情報を発信している方のnote購入・サポートに充てさせていただきます。