見出し画像

Python3 ウォークフォワード分析の実装:BOT自動売買戦略の堅牢性検証とカーブフィッティング対策

こんにちは。magito(@magimagi1223)です。

今年も早いもので、2/3が終わろうとしています。その一方で、暗号通貨BOT界隈は、3月頃に盛り上がり始めてから、半年足らずでかなり発展しましたね。様々なストラテジー、ツール、コミュニティなどが登場して、目に見える範囲だけでも驚くほど多様化しており、また技術レベルも格段に上がっているように感じています。

僕も最近は裁定/MM中心にワークしているBOTのブラッシュアップをメインに行いつつ、新しいディレクショナル/テクニカル戦略策定のための基礎勉強も少しずつ続けています。

今回はそのテクニカル戦略に関するnoteです。

テクニカル戦略BOTを作成・運用するうえで最も大きな課題の一つが、「カーブフィッティング(オーバーフィッティング)」ですよね。「バックテストで良い成績を出していたBOTが、実運用では全くワークしなかった、、」という経験のある方は結構多いのではないかと思います。

そのため、「いかにしてカーブフィッティングを回避し、堅牢性の高い戦略を策定するか」は、テクニカル戦略を搭載した自動売買BOTで実際に収益を上げるために、必ずクリアすべき重要な課題の一つといえます。

この課題を解決するのに役立つ方法の一つとして「ウォークフォワード分析」とよばれる手法があることを知りました。ウォークフォワード分析は、策定したトレーディング戦略の堅牢性を検証し、カーブフィッティングによる損失を未然に防ぐための手法です。

そこで、今回はウォークフォワード分析について勉強し、実際にPython3で実装してみました。このnoteでは、僕がウォークフォワード分析について学んだことをもとに、前半で簡潔に手法の解説を行い、後半で実装のためのソースコードを紹介したいと思います。


--------------------------------------------------------

目次

1. はじめに

2. ウォークフォワード分析とは

3. ウォークフォワード効率とは

4. 実装方法とサンプルコード

5. まとめ

--------------------------------------------------------


1.  はじめに


テクニカル戦略を搭載した自動売買BOTを作成し運用しようとするとき、事前にバックテストを実施してパフォーマンスを調べますよね。これに加えて通常は、BOTの有するパラメータの組み合わせを様々に変え、最もパフォーマンスの高い組み合わせを選ぶ「最適化」という作業も併せて実施すると思います。

この最適化という作業、皆さんはどのようなプロセスで行なっているでしょうか?おそらくは、一定期間のヒストリカルデータ(OHLCVなど)を用いて、その期間内のバックテストで最もパフォーマンスの高かったパラメータの組み合わせをそのまま実運用で使用する、という方がかなり多いのではないかと思います。(実際に僕も最近まではその方法で最適化をしていました。)

しかし、この方法には一つ大きな問題点があります。仮にパラメータの組み合わせが最適化に用いたヒストリカルデータに合わせてカーブフィッティングされていた場合、実運用では使い物にならないどころか、大きな損失を出す可能性が極めて大きいということです。

例を挙げると、以下のようなバックテスト結果/実運用パフォーマンスとなるケースです。

この戦略は、最適化期間内のバックテストでは(当然ですが)素晴らしいパフォーマンスを発揮しているように見えます。しかし、いざ実運用にてパフォーマンスを測ってみると、かなり微妙な結果になっていることが分かります。

これは、最適化期間の中でパフォーマンスが高くなるように、パラメータが過剰にフィットされてしまったためです。これがカーブフィッティングです。カーブフィッティングは、最適化するパラメータの種類が多すぎたり、戦略そのものに優位性がない場合に起こりやすい現象です。

このような事態を防ぐために有効なテスト方法の一つが、「ウォークフォワード分析」です。



2.  ウォークフォワード分析とは


ウォークフォワード分析とは、一言でいうと、「ある一定期間のヒストリカルデータで最適化されたパラメータを用いて、その隣接する別期間のヒストリカルデータでパフォーマンス評価を行い、その前後2つのパフォーマンスを比較することにより、トレーディング戦略の堅牢性を評価する」という手法です。つまり、最適化した戦略を実運用に近い条件でシミュレートすることで、本当にその戦略が実運用でワークするのかどうかを見積もるということです。

ウォークフォワード分析では、最適化されたパラメータの性能を最適化に用いた期間以外のヒストリカルデータで評価するため、通常のバックテスト/最適化に比べて以下のようなメリットがあります。

(1)トレーディング戦略の堅牢性を評価することができる
(2)トレーディング戦略の実運用パフォーマンスをより高い信頼性で予測することができる
(3)トレーディング戦略の実運用における最適なパラメータを選定することができる

(1)と(2)については、まさしく1章で挙げたカーブフィッティングの検出ができることを意味しています。さらに、そのトレーディング戦略が堅牢かつ優位性の高いものであれば、(3)によって本番運用で実際に利益を上げることができるのです。

ウォークフォワード分析は、一連の「ウォークフォワードテスト」の集合から成ります。ウォークフォワードテストとは、「ある一期間のインサンプルデータを用いて最適化したパラメータについて、隣接した一期間のアウトオブサンプルデータを用いてパフォーマンス評価を行う」というテストです。

このテストにより、「最適化した戦略がどれだけ実運用で通用するか」をテストした期間の範囲内で見積もることができます。その目安となる指標が次章で説明する「ウォークフォワード効率」です。

そして、このウォークフォワードテストを、対象期間をスライドさせながら複数回実施し、その結果をもとにトレーディング戦略の堅牢性を統計的に評価するのがウォークフォワード分析というわけです。



3. ウォークフォワード効率とは


ウォークフォワード分析において、トレーディング戦略の堅牢性の指標となるのが「ウォークフォワード効率(WFE)」です。各ウォークフォワードテストにおけるWFEは以下の計算式で求められます。

WFE = P_out / P_in
P_in:インサンプルでのパフォーマンス(総損益の年率換算とする)
P_out:アウトオブサンプルでのパフォーマンス(総損益の年率換算とする)

これは、WFEは、インサンプルにて最適化された戦略のパフォーマンスに対する、アウトオブサンプルでのパフォーマンスの割合を示します。つまり、「最適化した戦略が、実運用でどれだけ通用するか」という尺度を与えます。

そして、全期間の各ウォークフォワードテストでのWFEの平均値W_avgが、ウォークフォワード分析全体としての堅牢性評価の指標となります。

例えば、WFE_avg=0.5となった場合は、「実運用ではバックテスト結果の50%のパフォーマンスとなる」ことが期待されます。この値が50~60%以上あれば、堅牢な戦略と言えるようです。

また、WFE_avgが小さすぎる場合は、カーブフィッティングが発生しているか、策定した戦略や最適化プロセスそのものに問題があることが分かります。

その他にも、市場状態変化と戦略パフォーマンスの関係性など、この分析から分かることがいろいろあるようですが、今回は堅牢性評価がメインなので、説明を割愛します。



4. 実装方法とサンプルコード


では実際にウォークフォワード分析を実際のトレーディング戦略に適用してみます。ここではサンプルとして、以下の戦略Aを定義します。

<戦略A>
・ノーポジションのとき、1時間足の終値の一の位の値がAであればロングエントリ、つぎにBとなったときエグジット。
・ノーポジションのとき、1時間足の終値の一の位の値がCであればショートエントリ、つぎにDとなったときエグジット。

見ての通り、あえてデタラメな戦略を設定しました。とても分かりやすい分析結果となるはずです。いくら最適化期間で良い結果が出たとしても、期間外では通用しないであろうことは容易に想像できますね。
(ほかにも色々な戦略での分析結果をのせられるといいのですが、今回は執筆時間の都合上省略します。すいません!)

この戦略を、以下の条件でウォークフォワード分析にかけます。

<分析条件>
マーケット       :BTC-FX@BitMEX
分析期間        :2017/08/01〜2018/08/15
最適化期間       :30日
パフォーマンス評価期間 :15日
A、Bのスキャン範囲   :0〜4(1刻み)
C、Dのスキャン範囲   :5〜9(1刻み)
スキャンパターン数   :5×5×5×5=625通り

以下にPython3で実装した際のソースコードを載せました。

コードや処理についての文章での説明は省略しますが、ところどころコメントを入れているので、それらを参考に読み解いてもらえればと思います。

#!/usr/bin/python3
# coding:utf-8

import json
import time
import requests
import datetime

# 1時間足OHLCVを読み込む
# jsonファイルの時系列が[-1]→[0]のため、[0]→[-1]に変更
with open('ohlcv_1h_170801_180815.json', mode = 'r', encoding = 'utf-8') as fh:
	json_txt = fh.read()
	json_txt = str(json_txt).replace("'", '"').replace('True', 'true').replace('False', 'false')
	ohlcv_1h = json.loads(json_txt)
	# ohlcv_1h[0]:始値、ohlcv_1h[1]:高値、ohlcv_1h[2]:安値、ohlcv_1h[3]:終値、
	# ohlcv_1h[4]:VOLUME、ohlcv_1h[5]:タイムスタンプ

#------------------------------------------------------------------------------#
# 関数

# 時系列データ計算関数(ロジックごとに作る)
def calc_ind(bgn, end, params):
	global list_price
	global list_time

	# 引数からパラメータを受け取る
	[A, B, C, D] = params

	# 関数内で使用する変数を初期化
	list_price = []
	list_time = []

	# OHLCVを読み込み時系列データを計算する
	# インジケータを使用するロジックの場合はここで計算する
	for i in range(len(ohlcv_1h)):
		# 指定期間(bgn-end)のみ実行
		if bgn < ts(ohlcv_1h[i][5]) < end:
			list_price.append(ohlcv_1h[i][3])
			time = ohlcv_1h[i][5]
			list_time.append(time)

# バックテスト関数(ロジックごとに作る)
def backtest(bgn, end, params):
	global list_price
	global list_time
	global list_asset
	global list_date
	global list_dd

	# 引数からパラメータを受け取る
	[A, B, C, D] = params

	# 関数内で使用する変数を初期化
	price = 0
	price_entry = 0
	margin = 0
	total_prof = 0
	total_loss = 0
	count_prof = 0
	count_loss = 0
	pos = 'none'

	# 損益をそのまま利率(%/100)として使用するため、初期資金は1に設定
	asset_int = asset = 1
	list_asset = []
	list_date = []
	list_dd = []

	# 時系列データを計算しておく
	calc_ind(bgn=bgn, end=end, params=params)

	# バックテスト実行
	for i in range(len(list_price)):

		# ロット計算
		lot = asset_int / list_price[i]

		# 時系列データを代入
		price = list_price[i]
		time = list_time[i]

		# 価格の一の位の数字をnに代入
		l = [int(x) for x in list(str(int(price)))]
		n = l[-1]

		# ショートエントリ
		if pos == 'none' and n == A:
			price_entry = price
			pos = 'short'

		# ロングエントリ
		elif pos == 'none' and n == B:
			price_entry = price
			pos = 'long'

		# ショートクローズ
		elif pos == 'short' and n == C:
			fee = (price_entry + price) * 0.00075 #taker手数料0.075%
			margin = (price_entry - price - fee) * lot
			pos = 'exit'

		# ロングクローズ
		elif pos == 'long' and n == D:
			fee = (price_entry + price) * 0.00075 #taker手数料0.075%
			margin = (price - price_entry - fee) * lot
			pos = 'exit'

		if pos == 'exit':

			if margin >= 0:
				total_prof += margin
				count_prof += 1
			else:
				total_loss += margin
				count_loss += 1

			pos = 'none'

			# 資産を記録
			asset += margin
			date = time.replace('.000', '')
			date = datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ")
			list_asset.append(asset)
			list_date.append(date)

			# ドローダウンを記録
			asset_max = max(list_asset)
			dd = (asset_max - asset) / asset_int
			list_dd.append(dd)

	# テスト結果(パラメータ、損益、PF、勝率、勝ち数、負け数、最大ドローダウン)を返す
	pal = float(total_prof + total_loss)
	pf = float(total_prof / (-total_loss)) if total_loss != 0 else None
	wp = float(count_prof / (count_prof + count_loss)) if count_prof + count_loss != 0 else None
	dd_max = max(list_dd)

	return {'params': [A, B, C, D], 'pal': pal, 'pf': pf, 'wp': wp, 'win': count_prof, 'lose': count_loss, 'maxdd':dd_max}

#------------------------------------------------------------------------------#

# タイムスタンプをISO8601→UNIXに変換する関数
# 読み込んだOHLCVのタイムスタンプがISO8601なので作成
def ts(date):
	date = date.split('.')
	t = datetime.datetime.strptime(date[0], '%Y-%m-%dT%H:%M:%S')
	ms = date[1].split('Z')[0] if len(date) == 2 else 0
	t = t.replace(tzinfo=datetime.timezone.utc).timestamp() + int(ms)*0.001
	return t

# パラメータ候補値を生成する関数
def make_list(bgn, end, step):
	list_param = []
	x = bgn
	while round(x,10) <= end:
		list_param.append(round(x,10))
		x += step
	return list_param

# 複数リストの要素同士の全組み合わせを生成する関数
def make_comb(*args):
	str1, str2 = str(), str()
	for i in range(len(args)):
		str1 += 'x{},'.format(i)
		str2 += ' for x{0} in args[{0}]'.format(i)
	return eval('[[{0}]{1}]'.format(str1, str2), locals())

# 最適化を実行する関数(総当たり法)
def opt_grid(bgn, end, sort=True):
	list_result = []
	print('----------------------------------------')
	# パラメータの各組み合わせごとに最適化を実行
	for i in range(len(list_comb)):
		params = []
		for j in range(len(list_comb[0])):
			params.append(list_comb[i][j])
		result = backtest(bgn=bgn, end=end, params=params)
		list_result.append(result)
		print('\r{0} {1}/{2} scan completed.'.format(datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S'), i+1, len(list_comb)), end='')
	print('')

	# 損益の大きさ順にソート
	if sort == True:
		list_result = sorted(list_result, key=lambda x:x['pal'], reverse=True)

	print('----------------------------------------')
	#for i in range(len(list_result)):
	#	print(list_result[i])

	return list_result

#------------------------------------------------------------------------------#
# メイン処理

if __name__ == "__main__":

	# WFA開始/終了時刻(timestamp)
	BGN = ts('2017-08-01T00:00:00.000Z')
	END = ts('2018-08-15T00:00:00.000Z')

	# WFT最適化期間/評価期間(sec)
	OPT = 60 * 60 * 24 * 30 # 30日間
	EVA = 60 * 60 * 24 * 15 # 15日間

	# パラメータ候補値を生成
	list_A = make_list(bgn=0, end=4, step=1)
	list_B = make_list(bgn=0, end=4, step=1)
	list_C = make_list(bgn=5, end=9, step=1)
	list_D = make_list(bgn=5, end=9, step=1)

	# パラメータ候補値の全組み合わせを生成
	list_comb = make_comb(list_A, list_B, list_C, list_D)

	# WFTに使用する変数を初期化
	tp = BGN
	list_aprate_opt = []
	list_aprate_eva = []
	list_wfe = []

	print('-------- Walk-Forward Analysis ---------')
	print('FROM       : {0}'.format(datetime.datetime.utcfromtimestamp(BGN).strftime('%Y/%m/%d %H:%M:%S')))
	print('TO         : {0}'.format(datetime.datetime.utcfromtimestamp(END).strftime('%Y/%m/%d %H:%M:%S')))
	print('OPT WINDOW : {0} DAYS'.format(OPT/(60*60*24)))
	print('EVA WINDOW : {0} DAYS'.format(EVA/(60*60*24)))

	# 各期間でWFT実行
	while tp + OPT + EVA < END:

		# インサンプル最適化実行(最良の結果を変数に格納)
		result_opt = opt_grid(bgn=tp, end=tp+OPT, sort=True)[0]

		# 最良パラメータ/換算年率を変数に格納
		params_opt = result_opt['params']
		aprate_opt = (result_opt['pal'])*(60*60*24*365/OPT)

		# アウトオブサンプル評価実行
		result_eva = backtest(bgn=tp+OPT, end=tp+OPT+EVA, params=params_opt)

		# 換算年率を変数に格納(単利運用の想定なので期間の比率をかけるだけ)
		aprate_eva = (result_eva['pal'])*(60*60*24*365/EVA)

		# WFE計算
		wfe = aprate_eva / aprate_opt
		list_wfe.append(wfe)

		# WFE平均値算出のためにリストに保存
		list_aprate_opt.append(aprate_opt)
		list_aprate_eva.append(aprate_eva)

		print('BEST PARAM : {0}'.format(params_opt))
		print('OPT-PERIOD : {0} ~ {1}'.format(datetime.datetime.utcfromtimestamp(tp).strftime('%Y/%m/%d %H:%M:%S'), datetime.datetime.utcfromtimestamp(tp+OPT).strftime('%Y/%m/%d %H:%M:%S')))
		print('OPT-RESULT : {0}'.format(result_opt))
		print('OPT-APRATE : {0}'.format(aprate_opt))
		print('EVA-PERIOD : {0} ~ {1}'.format(datetime.datetime.utcfromtimestamp(tp+OPT).strftime('%Y/%m/%d %H:%M:%S'), datetime.datetime.utcfromtimestamp(tp+OPT+EVA).strftime('%Y/%m/%d %H:%M:%S')))
		print('EVA-RESULT : {0}'.format(result_eva))
		print('EVA-APRATE : {0}'.format(aprate_eva))
		print('WFE        : {0}'.format(wfe))

		# 次の最適化期間の開始時刻を設定(OPTだけスライドさせる)
		tp += OPT

	# 実行した全WFTにおけるWFEの平均値を算出する
	wfe_avg = sum(list_aprate_eva) / sum(list_aprate_opt)
	print('----------------------------------------')
	print('OPT-APRATE TOTAL : {0}'.format(sum(list_aprate_opt)))
	print('EVA-APRATE TOTAL : {0}'.format(sum(list_aprate_eva)))
	print('WFE AVG          : {0}'.format(wfe_avg))
	print('----------------------------------------')
	print('ALL COMPLETED.')


このコードを実行すると以下の結果が得られます。

-------- Walk-Forward Analysis ---------
FROM       : 2017/08/01 00:00:00
TO         : 2018/08/15 00:00:00
OPT WINDOW : 30.0 DAYS
EVA WINDOW : 15.0 DAYS
----------------------------------------
2018/08/19 12:28:27 625/625 scan completed.
----------------------------------------
BEST PARAM : [4, 3, 9, 8]
OPT-PERIOD : 2017/08/01 00:00:00 ~ 2017/08/31 00:00:00
OPT-RESULT : {'params': [4, 3, 9, 8], 'pal': 0.5923989501710261, 'pf': 3.705935840436443, 'wp': 0.6136363636363636, 'win': 27, 'lose': 17, 'maxdd': 0.1009592247057236}
OPT-APRATE : 7.20752056041415
EVA-PERIOD : 2017/08/31 00:00:00 ~ 2017/09/15 00:00:00
EVA-RESULT : {'params': [4, 3, 9, 8], 'pal': -0.07450680571656954, 'pf': 0.7596186518509251, 'wp': 0.32142857142857145, 'win': 9, 'lose': 19, 'maxdd': 0.2235276931418756}
EVA-APRATE : -1.812998939103192
WFE        : -0.25154266628953126
----------------------------------------
2018/08/19 12:30:36 625/625 scan completed.
----------------------------------------
BEST PARAM : [4, 2, 9, 9]
OPT-PERIOD : 2017/08/31 00:00:00 ~ 2017/09/30 00:00:00
OPT-RESULT : {'params': [4, 2, 9, 9], 'pal': 0.485984965674779, 'pf': 2.48439189170807, 'wp': 0.6111111111111112, 'win': 33, 'lose': 21, 'maxdd': 0.08895951860873752}
OPT-APRATE : 5.912817082376478
EVA-PERIOD : 2017/09/30 00:00:00 ~ 2017/10/15 00:00:00
EVA-RESULT : {'params': [4, 2, 9, 9], 'pal': -0.09693687829068665, 'pf': 0.5277013648901888, 'wp': 0.45454545454545453, 'win': 10, 'lose': 12, 'maxdd': 0.15267090551251628}
EVA-APRATE : -2.3587973717400414
WFE        : -0.3989295354274674
----------------------------------------
2018/08/19 12:32:54 625/625 scan completed.
----------------------------------------
BEST PARAM : [0, 1, 5, 8]
OPT-PERIOD : 2017/09/30 00:00:00 ~ 2017/10/30 00:00:00
OPT-RESULT : {'params': [0, 1, 5, 8], 'pal': 0.3501598121354861, 'pf': 2.5664209339033692, 'wp': 0.6444444444444445, 'win': 29, 'lose': 16, 'maxdd': 0.09931697163863229}
OPT-APRATE : 4.26027771431508
EVA-PERIOD : 2017/10/30 00:00:00 ~ 2017/11/14 00:00:00
EVA-RESULT : {'params': [0, 1, 5, 8], 'pal': -0.12122585272017689, 'pf': 0.6271401283679368, 'wp': 0.4482758620689655, 'win': 13, 'lose': 16, 'maxdd': 0.2622508087105462}
EVA-APRATE : -2.9498290828576375
WFE        : -0.6924030029652369
----------------------------------------
2018/08/19 12:35:16 625/625 scan completed.
----------------------------------------
BEST PARAM : [0, 2, 6, 7]
OPT-PERIOD : 2017/10/30 00:00:00 ~ 2017/11/29 00:00:00
OPT-RESULT : {'params': [0, 2, 6, 7], 'pal': 0.4784006907728303, 'pf': 2.3663882364071407, 'wp': 0.6739130434782609, 'win': 31, 'lose': 15, 'maxdd': 0.10797365046710539}
OPT-APRATE : 5.820541737736102
EVA-PERIOD : 2017/11/29 00:00:00 ~ 2017/12/14 00:00:00
EVA-RESULT : {'params': [0, 2, 6, 7], 'pal': -0.11740241624798475, 'pf': 0.8103518580520366, 'wp': 0.42857142857142855, 'win': 9, 'lose': 12, 'maxdd': 0.5132790732430031}
EVA-APRATE : -2.8567921287009623
WFE        : -0.4908120682615551
----------------------------------------
2018/08/19 12:37:27 625/625 scan completed.
----------------------------------------
BEST PARAM : [4, 0, 8, 9]
OPT-PERIOD : 2017/11/29 00:00:00 ~ 2017/12/29 00:00:00
OPT-RESULT : {'params': [4, 0, 8, 9], 'pal': 1.0654793740262951, 'pf': 3.1900277192706366, 'wp': 0.5714285714285714, 'win': 32, 'lose': 24, 'maxdd': 0.10627879587956857}
OPT-APRATE : 12.96333238398659
EVA-PERIOD : 2017/12/29 00:00:00 ~ 2018/01/13 00:00:00
EVA-RESULT : {'params': [4, 0, 8, 9], 'pal': -0.11464202097189163, 'pf': 0.7278122471023977, 'wp': 0.5, 'win': 12, 'lose': 12, 'maxdd': 0.23939862445971893}
EVA-APRATE : -2.7896225103160295
WFE        : -0.21519331817504028
----------------------------------------
2018/08/19 12:39:22 625/625 scan completed.
----------------------------------------
BEST PARAM : [1, 4, 6, 9]
OPT-PERIOD : 2017/12/29 00:00:00 ~ 2018/01/28 00:00:00
OPT-RESULT : {'params': [1, 4, 6, 9], 'pal': 0.8162413709830967, 'pf': 2.9438592472605536, 'wp': 0.6, 'win': 27, 'lose': 18, 'maxdd': 0.08843279135579385}
OPT-APRATE : 9.930936680294343
EVA-PERIOD : 2018/01/28 00:00:00 ~ 2018/02/12 00:00:00
EVA-RESULT : {'params': [1, 4, 6, 9], 'pal': -0.21536491649553724, 'pf': 0.5963994979206049, 'wp': 0.47058823529411764, 'win': 8, 'lose': 9, 'maxdd': 0.3774115030615438}
EVA-APRATE : -5.240546301391406
WFE        : -0.5276990952716538
----------------------------------------
2018/08/19 12:41:23 625/625 scan completed.
----------------------------------------
BEST PARAM : [2, 3, 9, 8]
OPT-PERIOD : 2018/01/28 00:00:00 ~ 2018/02/27 00:00:00
OPT-RESULT : {'params': [2, 3, 9, 8], 'pal': 0.8669690498103616, 'pf': 2.992803916378701, 'wp': 0.627906976744186, 'win': 27, 'lose': 16, 'maxdd': 0.09593653673710212}
OPT-APRATE : 10.548123439359399
EVA-PERIOD : 2018/02/27 00:00:00 ~ 2018/03/14 00:00:00
EVA-RESULT : {'params': [2, 3, 9, 8], 'pal': -0.43328734833824933, 'pf': 0.27054017298961974, 'wp': 0.2916666666666667, 'win': 7, 'lose': 17, 'maxdd': 0.5488661798138286}
EVA-APRATE : -10.543325476230732
WFE        : -0.9995451358569845
----------------------------------------
2018/08/19 12:43:25 625/625 scan completed.
----------------------------------------
BEST PARAM : [3, 0, 6, 6]
OPT-PERIOD : 2018/02/27 00:00:00 ~ 2018/03/29 00:00:00
OPT-RESULT : {'params': [3, 0, 6, 6], 'pal': 0.5436858084306508, 'pf': 2.430466793206328, 'wp': 0.5952380952380952, 'win': 25, 'lose': 17, 'maxdd': 0.14524675045024615}
OPT-APRATE : 6.6148440025729185
EVA-PERIOD : 2018/03/29 00:00:00 ~ 2018/04/13 00:00:00
EVA-RESULT : {'params': [3, 0, 6, 6], 'pal': -0.022442071737666353, 'pf': 0.9014562694441773, 'wp': 0.5652173913043478, 'win': 13, 'lose': 10, 'maxdd': 0.1419561260011628}
EVA-APRATE : -0.5460904122832145
WFE        : -0.08255529715754543
----------------------------------------
2018/08/19 12:45:53 625/625 scan completed.
----------------------------------------
BEST PARAM : [1, 3, 9, 9]
OPT-PERIOD : 2018/03/29 00:00:00 ~ 2018/04/28 00:00:00
OPT-RESULT : {'params': [1, 3, 9, 9], 'pal': 0.6723258863498617, 'pf': 6.621102571084351, 'wp': 0.625, 'win': 30, 'lose': 18, 'maxdd': 0.02491220143158457}
OPT-APRATE : 8.179964950589984
EVA-PERIOD : 2018/04/28 00:00:00 ~ 2018/05/13 00:00:00
EVA-RESULT : {'params': [1, 3, 9, 9], 'pal': -0.17605217682093677, 'pf': 0.23741962307090314, 'wp': 0.4, 'win': 6, 'lose': 9, 'maxdd': 0.15120666155311657}
EVA-APRATE : -4.283936302642794
WFE        : -0.523710838435049
----------------------------------------
2018/08/19 12:48:21 625/625 scan completed.
----------------------------------------
BEST PARAM : [0, 2, 5, 9]
OPT-PERIOD : 2018/04/28 00:00:00 ~ 2018/05/28 00:00:00
OPT-RESULT : {'params': [0, 2, 5, 9], 'pal': 0.33327286945888873, 'pf': 2.7130539790359385, 'wp': 0.5681818181818182, 'win': 25, 'lose': 19, 'maxdd': 0.0726216891606577}
OPT-APRATE : 4.054819911749813
EVA-PERIOD : 2018/05/28 00:00:00 ~ 2018/06/12 00:00:00
EVA-RESULT : {'params': [0, 2, 5, 9], 'pal': -0.007304084511274345, 'pf': 0.9611157756382951, 'wp': 0.48, 'win': 12, 'lose': 13, 'maxdd': 0.10249692625093143}
EVA-APRATE : -0.1777327231076757
WFE        : -0.04383245790834077
----------------------------------------
2018/08/19 12:50:51 625/625 scan completed.
----------------------------------------
BEST PARAM : [1, 4, 9, 7]
OPT-PERIOD : 2018/05/28 00:00:00 ~ 2018/06/27 00:00:00
OPT-RESULT : {'params': [1, 4, 9, 7], 'pal': 0.41150595934397427, 'pf': 4.9730356156051805, 'wp': 0.627906976744186, 'win': 27, 'lose': 16, 'maxdd': 0.03033839020463036}
OPT-APRATE : 5.00665583868502
EVA-PERIOD : 2018/06/27 00:00:00 ~ 2018/07/12 00:00:00
EVA-RESULT : {'params': [1, 4, 9, 7], 'pal': -0.19988428608541423, 'pf': 0.13040162805613545, 'wp': 0.2857142857142857, 'win': 8, 'lose': 20, 'maxdd': 0.1954929000857445}
EVA-APRATE : -4.863850961411746
WFE        : -0.9714769934514249
----------------------------------------
2018/08/19 12:53:17 625/625 scan completed.
----------------------------------------
BEST PARAM : [1, 3, 8, 9]
OPT-PERIOD : 2018/06/27 00:00:00 ~ 2018/07/27 00:00:00
OPT-RESULT : {'params': [1, 3, 8, 9], 'pal': 0.2943844908256382, 'pf': 2.8949617018787706, 'wp': 0.5, 'win': 25, 'lose': 25, 'maxdd': 0.047266784821687446}
OPT-APRATE : 3.5816779717119314
EVA-PERIOD : 2018/07/27 00:00:00 ~ 2018/08/11 00:00:00
EVA-RESULT : {'params': [1, 3, 8, 9], 'pal': -0.0070241270923143, 'pf': 0.9459381149025223, 'wp': 0.4117647058823529, 'win': 7, 'lose': 10, 'maxdd': 0.05136755849763208}
EVA-APRATE : -0.1709204259129813
WFE        : -0.047720768662875244
----------------------------------------
OPT-APRATE TOTAL : 84.08151227379182
EVA-APRATE TOTAL : -38.594442635698414
WFE AVG          : -0.459012232201826
----------------------------------------
ALL COMPLETED.

以上の結果から、各期間のインサンプルでのバックテスト結果だけをみると、どれも年間数百%という素晴らしいリターンを出せるような気がしてきます。しかし、アウトオブサンプルでのバックテストではどれも損失を出してしまっていることから、別の期間では全く通用しない、すなわちカーブフィッティングであることがわかります。

また、全期間を通したWFE_avgは-0.459(-45.9%)です。この結果は「30日間で最適化したパラメータを使用すると、最初の15日間では、バックテスト結果の-45.9%のパフォーマンスが期待できる」ことを示しています。つまり、「100万円儲かると思ったら46万円くらい損する」ということになります。この戦略を使おうとしていた人にとっては残念すぎる結果です。しかし、最適化期間のバックテストだけを信じて飛びついていたら大損していたのですが、ウォークフォワード分析を実施したおかげで踏みとどまることができました。

ちなみに、分析対象の戦略に優位性があればWFEavgはプラスになるはずで、非常に安定した優秀なものであればWFEavg=1に近い(またはそれ以上の)結果となるはずです。

また、この分析結果がどれだけ信頼できるかは、「各WFT期間におけるトレード数」、「WFT自体のウインドウ数(実施回数)」などに左右されます。これらが大きいほど統計的により信頼できる結果を得ることができます。今回は1年分のデータしか使いませんでしたが、中長期戦略の場合は、もっとデータを用意する方が良いと思います(暗号通貨は価格データがまだ少ないので難しいかもしれませんが、、)。

一つ、WFEの計算について気をつけないといけないと思ったのは、aprate_evaとaprate_optが両方マイナスの場合もWFE自体はプラスになるという点です。例えば、「インサンプルでの最適化により100 %の損失を出すと予測された戦略が、アウトオブサンプルにて同じく100 %の損失を出すとわかった」という場合、明らかに使えない戦略にもかかわらず計算上はWFE=1となり"堅牢な戦略"と見なされてしまいます(安定して損失を出すという意味では間違っていませんがw)。

このことも踏まえ、上記コードでのWFEavgは、「各期間のWFEの平均」ではなく、「aprate_evaの合計÷aprate_optの合計」として出しています。こうすることで、仮に一部の期間で上記のケースが発生した場合も、WFE_avgへのプラスの寄与となることを回避しています。

そもそも、(当然かもしれませんが)最適化結果がマイナスとなる戦略については、採用を却下するか、最適化プロセスを見直すべきと考えるのがよさそうです。戦略の良し悪しはWFEだけでなく、その他のパフォーマンス(特にリターンのばらつきやドローダウンなどのリスクサイド!)も考慮して総合的に判断することが重要だと思います。

もう一つですが、アウトオブサンプルによるバックテスト結果が実運用に近いといっても、バックテストではシミュレートするのが難しい執行コスト(マーケットインパクトやスリッページなど)については、WFEに正確に織り込むことはできないので、大資金での運用や短期戦略を分析する場合については特に気をつけた方が良いと思います。


4.  まとめ


(1)ウォークフォワード分析では、トレーディング戦略の堅牢性を評価することができ、カーブフィッティングによる損失を未然に回避するためにも有効な手法である。
(2)ウォークフォワードテストでは、ある一定期間で最適化したパラメータを、その隣接する別期間でパフォーマンス評価する。
(3)ウォークフォワード分析は、一連のウォークフォワードテストから構成され、それぞれのテスト結果から統計的に堅牢性を評価する。
(4)トレーディング戦略の堅牢性は、ウォークフォワード効率(WFE)という指標により与えられる。
(5)WFEが小さい場合、カーブフィッティングがされている、最適化プロセスに問題がある、トレーディング戦略自体に問題がある、のいずれかである可能性が高いため、これまでのプロセスを見直し、原因特定と改善処置を行う必要がある。

最後に、一つ注意しないといけないのは、ウォークフォワード分析はあくまで「策定したトレーディング戦略が堅牢かどうか、カーブフィッティングされているかどうかを検証する」ための手法にすぎないということです。つまり、「平凡または劣悪な戦略を、堅牢性の高い良質な戦略にトランスフォームする」ことはできないということです。分析対象となる戦略自体は自分で見つける必要があるので、結局、そのあたりはシステムトレーダーの腕の見せ所というわけですね。


今回のnoteも勉強したことや実践したことを書き連ねただけに留まり、かなり雑めな解説になってしまい申し訳ないです。今後新しいことが分かったり、取りこぼしに気づいたりしたら、適宜追記していこうと思います。

また、もしnoteの内容について間違いなどありましたら、ご指摘いただけると助かります。

なお、最後になりますが、ウォークフォワード分析について、もっと詳しく知りたい方は、「アルゴリズムトレーディング入門(ロバート・パルド著、パンローリング社)」を読むことをおすすめします。今回の参考文献です。ウォークフォワード分析だけでなく、トレーディング戦略の策定から運用までの一連のプロセスが解説されています。また、バックテストや最適化についても様々な手法が紹介されており、暗号通貨BOTトレーダーにとっても気づきや見通しが得られる有益な一冊なのではないかと思います。


以上になります。

最後まで読んでくださり、ありがとうございました!