BTCFXで作成した自動売買BOTの勝率をバックテストで検証してみよう

さて、前回の章までをお読みいただいた方であれば、ひとまず、Bitflyerで自動的に稼働し続ける売買BOTを作ることができたと思います。

当然、次にあなたが疑問に思うのは、「….で、実際にこのBOTはどのくらい勝てるの?」という点でしょう(笑)。今回の章では、過去の価格データから自作した売買ロジックの勝率を検証するための方法を解説していきます。

勝率についての考え方

「すごいぞ! 勝率80%で1回のトレードあたりの平均リターンが20%の売買ロジックができた!」とはしゃいでみても、その売買シグナルが年に1回した点灯しないようだと、実用上は意味がありません。

まずは、バックテストで勝率を検証する際に、考慮すべき指標を5つだけ簡単に説明しておきます。

1.トレードの回数

過去のデータから勝率を検証する場合、統計的には、最低でも50回以上(できれば100回以上)のトレード数が必要です。

トレード回数が少なすぎると、その勝率や平均リターン率が「たまたま偶然良かっただけなのか?」、それとも「継続的に勝てる再現性のあるロジックなのか?」判別できません。

一般論としていえば、トレードの売買ロジックは、エントリー条件を厳しくすればするほど期待できる勝率やリターンは高くなります。しかしその分、エントリーできる機会が少なくなります。

またBitflyerFXの過去の価格データは無限に手に入るわけではありません。例えば、Cryptowatchから価格データを取得する場合、私の知る限り、1分足のデータは最大6000件までしか入手できません。あまりエントリー条件を厳しくすると、十分な回数のテストができなくなります。

2.勝率

勝率というのは、1回のトレードで利益が出る確率です。例えば、100回トレードをしてそのうち36回利益が出た場合、その売買ロジックの勝率は36%となります。

勝率は1つの重要な指標です。しかし勝率だけでは売買ロジックが優秀かどうかを判定することはできません。勝率36%でも、勝ったときの平均利益が9%、負けたときの平均損失が2%なら、その売買ロジックの期待リターンは+(プラス)です。

(例)1万円を賭けたときの期待値
勝ち 1万円 × 9% × 36% = 324円
負け 1万円 × -2% × 64% = -128円
期待値 196円

ただし同じ期待リターンなら、勝率が高いほうが安心感は上です。勝率が高いと(負けが連続する確率が低くなるので)損益収支グラフを見たときに綺麗に右肩上がりで利益が積み上がりやすくなります。

3.平均リターン

平均リターンというのは、1回のトレードで張った金額に対して平均して何%のリターンがあるか、の指標です。言うまでもなく最も重要な指標の1つです。

ここまで紹介した3つの指標「トレード数」「勝率」「平均リターン」はどれも同じくらい重要です。同じ勝率でも、平均リターン10%の売買ロジックで月に5回トレードするよりも、平均リターン3%で月に100回トレードする方が、利益の絶対額は大きくなります。

儲かった利益をそのまま次回のトレードに投入するスタイル(複利運用)の場合には、同じ期待利回りならトレード回数が多いほど利益も大きくなります。また前述のように、トレード回数が多いほうが期待したリターンに収束しやすくなります。

4.総利益額

テスト期間の最初と最後を比較して、結局、この自動売買BOTによって「軍資金は何倍になったのか?」という指標です。

この総利益額という数字には、前述の「トレード数」「勝率」「平均リターン」がすべて考慮されています。結局のところ、私たちが最終的に最大化したい数字はこの「総利益額」なので、この中では最も大事な指標になります。

5.最大ドローダウン

最大ドローダウンというのは、テスト期間中に最大で「何%まで軍資金を減らしたか?」、つまり「どれだけ苦しい時期を乗り越えたか?」という指標です。

過去のテスト検証の結果、最終的な平均リターンや総利益額がプラスだとしても、途中で70%も軍資金が目減りするような場面があったとしたら、その売買ロジックを実用で試すのは非常に怖いはずです。

最大ドローダウンが大きいということは、テストする期間を変えて検証したら最終リターンがマイナスに終わった可能性もあった、ということです。

実際の世界の運用では(テスト検証と違って)どこで終わり、という期間はありません。リアルタイムでBOTを運用していて、軍資金が70%も目減りしてしまったら、平常心でBOTの稼働を続けられる人はほとんどいないでしょう。そのため、最大ドローダウンは30%以内におさえるのが理想とされています。

自動売買BOTに特有の注意点

次に、Bitflyerで自動売買BOTを動かすときに特有の注意点を上げておきます。これらはバックテストでの検証結果と、実際の世界でのBOT稼働の成績が一致しなくなる原因になるため、テストの時点で考慮しておく必要があります。

注文の遅延と成行注文のスリップ

Bitflyerでは頻繁にAPI注文の遅延がおこります。注文の遅延というのは、サーバーに注文を送った後、それが認識される(板に並ぶ)までの間に数十秒~1分以上のラグ(遅延)が生じることをいいます。

また成行注文のスリップとは、リアルタイムの意図した価格で注文が約定しないことをいいます。例えば、以下の試験運用では、終値のシグナルで82万7666円のときに手仕舞いの成行注文を出していますが、実際には82万7426円で約定しています。

▽ 本当はこの価格で手仕舞いたい

▽ 実際のスリッページの例

ここにBitflyerのサーバーエラーや遅延が重なると、どんどん理想の手仕舞い価格と実際の約定価格が乖離していき、テスト通りのパフォーマンスを上げることが難しくなります。

バックテストでは、シグナルが点灯したローソク足の終値(または次の足の始値)で約定したもの、と仮定してテストしますが、実際にはその価格通りに約定することはほとんどなく、不利な方向にズレて約定することが多いです。

対策

対策はいくつかあります。

1)そもそもテスト段階でスリップによる損失を考慮する
2)実際の運用で1分足は使わない。15分足や1時間足で動かす
3)エントリー注文は指値にして、刺さらなければ諦める

解説

例えば、テスト検証のときに、全てのトレードから一律に―0.05~0.1%程度のスリップを考慮して、あらかじめ手数料として差し引いておくと、実際の運用パフォーマンスとの乖離が少なくなります。

また当然、スキャルなどの利幅の小さいトレードほど、APIの遅延やサーバーエラーによるスリップの損失を受けやすくなります。今までの章では、練習のしやすさから全て1分足で勉強してきましたが、実際のテストや運用では、なるべく時間軸は5分以上の足で考えます。

エントリーだけ指値にする

こちらは各々の戦略次第ですが、エントリー注文は指値にして「刺さらなければ諦める」という選択肢もあります。

利確や損切などの手仕舞いのための注文を諦めることはできませんが、エントリー注文に関しては「希望する価格で執行されないなら見送る」ということが、戦略上は可能です。(もちろん機会損失になる可能性はあります)

エントリー注文を指値にすれば、エントリーも手仕舞いも成行注文をする場合に比べて、テスト検証とのズレを半分に抑えることができます。

今回の章で検証する売買ロジック

さて、では実際にテスト検証の方法を解説していきましょう!

今回の章では、前回までの記事で作った自動売買BOTが「実際のところどの程度の勝率なのか?」気になる方もいる(かもしれない)ので、前回のBOTをそのまま題材にします!

・前回作成した自動売買BOTのロジック
・実際にBitflyerで動くBOTコード

バックテストの検証では、以下のpythonコードを前提の教材とし、こちらをカスタマイズしていきます。

検証に使う元のpythonコード

このコードは以前にこちらの記事でテスト用として紹介したソースコード(Bitflyerへの実際の発注処理を行わないコード)を、売り・買いの両方のシグナルに対応させただけのものです。以下のコードの意味は改めて解説しないので、わからない部分がある方は、前の章などを軽く復習しておいてください!


import requests
from datetime import datetime
import time


# 最初に1度だけCryptowatchから価格データ取得
response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc",params = { "periods" : 60 })

# 指定されたローソク足の価格データ(OHLC)を返す関数
def get_price(min,i):
	data = response.json()
	return { "close_time" : data["result"][str(min)][i][0],
		"open_price" : data["result"][str(min)][i][1],
		"high_price" : data["result"][str(min)][i][2],
		"low_price" : data["result"][str(min)][i][3],
		"close_price": data["result"][str(min)][i][4] }


# 時間と始値・終値を表示する関数
def print_price( data ):
	print( "時間: " + datetime.fromtimestamp(data["close_time"]).strftime('%Y/%m/%d %H:%M') + " 始値: " + str(data["open_price"]) + " 終値: " + str(data["close_price"]) )


# 各ローソク足が陽線・陰線の基準を満たしているか確認する関数
def check_candle( data,side ):
	realbody_rate = abs(data["close_price"] - data["open_price"]) / (data["high_price"]-data["low_price"]) 
	increase_rate = data["close_price"] / data["open_price"] - 1
	
	if side == "buy":
		if data["close_price"] < data["open_price"] : return False
		elif increase_rate < 0.0003 : return False
		elif realbody_rate < 0.5 : return False
		else : return True
		
	if side == "sell":
		if data["close_price"] > data["open_price"] : return False
		elif increase_rate > -0.0003 : return False
		elif realbody_rate < 0.5 : return False
		else : return True


# ローソク足が連続で上昇しているか確認する関数
def check_ascend( data,last_data ):
	if data["open_price"] > last_data["open_price"] and data["close_price"] > last_data["close_price"]:
		return True
	else:
		return False

# ローソク足が連続で下落しているか確認する関数
def check_descend( data,last_data ):
	if data["open_price"] < last_data["open_price"] and data["close_price"] < last_data["close_price"]:
		return True
	else:
		return False


# 買いシグナルが出たら指値で買い注文を出す関数
def buy_signal( data,last_data,flag ):
	if flag["buy_signal"] == 0 and check_candle( data,"buy" ):
		flag["buy_signal"] = 1

	elif flag["buy_signal"] == 1 and check_candle( data,"buy" )  and check_ascend( data,last_data ):
		flag["buy_signal"] = 2

	elif flag["buy_signal"] == 2 and check_candle( data,"buy" )  and check_ascend( data,last_data ):
		print("3本連続で陽線 なので" + str(data["close_price"]) + "で買い指値を入れます")
		flag["buy_signal"] = 3
		
		# ここに買い指値注文のコードを入れる
		flag["order"]["exist"] = True
		flag["order"]["side"] = "BUY"
	
	else:
		flag["buy_signal"] = 0
	return flag


# 売りシグナルが出たら指値で売り注文を出す関数
def sell_signal( data,last_data,flag ):
	if flag["sell_signal"] == 0 and check_candle( data,"sell" ):
		flag["sell_signal"] = 1

	elif flag["sell_signal"] == 1 and check_candle( data,"sell" )  and check_descend( data,last_data ):
		flag["sell_signal"] = 2

	elif flag["sell_signal"] == 2 and check_candle( data,"sell" )  and check_descend( data,last_data ):
		print("3本連続で陰線 なので" + str(data["close_price"]) + "で売り指値を入れます")
		flag["sell_signal"] = 3
		
		# ここに売り指値注文のコードを入れる
		flag["order"]["exist"] = True
		flag["order"]["side"] = "SELL"
		
	else:
		flag["sell_signal"] = 0
	return flag


# 手仕舞いのシグナルが出たら決済の成行注文を出す関数
def close_position( data,last_data,flag ):
	if flag["position"]["side"] == "BUY":
		if data["close_price"] < last_data["close_price"]:
			print("前回の終値を下回ったので" + str(data["close_price"]) + "あたりで成行で決済します")
			# 決済の成行注文のコードを入れる
			flag["position"]["exist"] = False
			
	if flag["position"]["side"] == "SELL":
		if data["close_price"] > last_data["close_price"]:
			print("前回の終値を上回ったので" + str(data["close_price"]) + "あたりで成行で決済します")
			# 決済の成行注文のコードを入れる
			flag["position"]["exist"] = False
	return flag


# サーバーに出した注文が約定したかどうかチェックする関数
def check_order( flag ):
	
	# 注文状況を確認して通っていたら以下を実行
	# 一定時間で注文が通っていなければキャンセルする
	
	flag["order"]["exist"] = False
	flag["order"]["count"] = 0
	flag["position"]["exist"] = True
	flag["position"]["side"] = flag["order"]["side"]
	return flag


# ここからメイン処理の部分
# まず初期値の取得
last_data = get_price(60,0)
print_price( last_data )


# 注文管理用のフラッグを準備
flag = {
	"buy_signal":0,
	"sell_signal":0,
	"order":{
		"exist" : False,
		"side" : "",
		"count" : 0
	},
	"position":{
		"exist" : False,
		"side" : ""
	}
}

# 500回ループ処理
i = 1
while i < 500:
	if flag["order"]["exist"]:
		flag = check_order( flag )
	
	data = get_price(60,i)
	print_price( data )
	
	if flag["position"]["exist"]:
		flag = close_position( data,last_data,flag )			
	else:
		flag = buy_signal( data,last_data,flag )
		flag = sell_signal( data,last_data,flag )
	last_data["close_time"] = data["close_time"]
	last_data["open_price"] = data["open_price"]
	last_data["close_price"] = data["close_price"]
	i+=1
	

実行結果

ちなみに、この時点で実行すると以下のようになります。
カンの良い方はわかると思いますが、もう実はテストのベースはできています。

ですが、既にこの記事を読んで勉強していただいた方なら1つ気付いたことがあるはずです。

そう!検証するためには、明らかにデータ数・トレード数ともに全然足りないですね。こちらはCryptowatchで取得できる直近500件の1分足データを参照していますが、500件のデータでは5回ほどしかトレードが成立していません。最低でも10倍程度の量の価格データが必要そうです。

次回の記事では、まず十分な量の元データ(ローソク足の価格データ)を取得する方法から解説していきます。

次回:バックテストに必要なBitflyerFXの価格データを集めよう!

条件を緩和してテスト回数を増やす方法

※ ちなみにどうしてもデータが手に入らない場合は、エントリー条件を緩和してテストする方法があります。例えば、上記のロジックだと、実体の長さやヒゲの割合などの追加条件を外すだけで、同じ500件分のデータから合計52回のトレード機会を得られます。

▽ 実体の長さ・ヒゲの割合の条件あり

▽ 実体の長さ・ヒゲの割合の条件なし

基本的に、無料でデータ(API)を提供して貰っている私たちの立場からすれば、データ数が少ないことに文句は言えません。現実的に得られるデータの範囲で有益な売買ロジックを考えるしかありません。この辺りのトレードオフの考え方も、今後説明していきますね!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です