バックテストと実際の成績乖離の原因2~過去足の情報の不足~

ある同じ期間についての1時間足でバックテスト結果と実際のトレード成績が綺麗に一致しない場合があります。

この1つの理由として、バックテストでは時間中の値動きの順序を把握できないという問題があります。例えば、ポジションの積み増しやトレイリングストップなど、損切りの位置を値動きに応じて動かすロジックを有効にしているとき、この問題が生じます。

バックテストの1時間足では、高値と安値のみが記録されており、その順番までは記録されていません。そのため、ポジションの積み増しやトレイリングストップを有効にしている場合、この順番によって「バックテストでは損切りにかかっているのに実際はポジションを保持している状況」が生じたり、またはその逆の状況が発生することがあります。

具体例

例)ある1時間足

始値 870000円
高値 880000円
安値 863000円
終値 875000円

この1時間足の情報からは以下の2つのストーリーが考えられますが、バックテストではそのどちらが起きたのかを判定できません。

(ケース1:ポジションを清算)

元の損切り位置 860000円
始値 870000円

まず高値をつける880000円 ⇒ トレイリングストップで損切りの位置を引き上げ865000円 ⇒ 反発して(安値が)損切りラインにかかる863000円 ⇒ 終値 875000円

(ケース2:ポジションを保持したまま)

元の損切り位置 860000円
始値 870000円

まず安値をつける 863000円 ⇒ 反発して高値をつける 880000円 ⇒ トレイリングストップで損切りの位置を引き上げ 865000円 ⇒ 終値 875000円

実際のログ

以下は実際のログです。なぜかバックテストの結果と一致しなかったので調べてみたところ、バックテストでは損切りにかかっていました。実際には安値をつけたあとに高値をつけたので、ポジションの清算は発生しませんでした。

▽ ログ

トレイリングストップの発動:ストップ位置を449037円に動かして、加速係数を0.2に更新します
時間: 2019/04/02 10:00 高値: 459100 安値: 454605 終値: 456148
トレイリングストップの発動:ストップ位置を451050円に動かして、加速係数を0.2に更新します
時間: 2019/04/02 11:00 高値: 458350 安値: 455300 終値: 457694
時間: 2019/04/02 12:00 高値: 460800 安値: 457644 終値: 459137
トレイリングストップの発動:ストップ位置を453000円に動かして、加速係数を0.2に更新します
時間: 2019/04/02 13:00 高値: 460244 安値: 458725 終値: 459376
時間: 2019/04/02 14:00 高値: 552401 安値: 458950 終値: 527176
トレイリングストップの発動:ストップ位置を472880円に動かして、加速係数を0.2に更新します
472880円の損切ラインに引っかかりました。
時間: 2019/04/02 15:00 高値: 587332 安値: 524240 終値: 531158
時間: 2019/04/02 16:00 高値: 534800 安値: 520500 終値: 529824

▽ 実際のBOTログ

11:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 11:00 高値: 458350 安値: 455300 終値: 457694
12:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 12:00 高値: 460800 安値: 457644 終値: 459137
12:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] トレイリングストップの発動:ストップ位置を453547円に動かして、加速係数を0.2に更新します
13:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 13:00 高値: 460244 安値: 458725 終値: 459376
14:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 14:00 高値: 552401 安値: 458950 終値: 527176
14:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] トレイリングストップの発動:ストップ位置を473318円に動かして、加速係数を0.2に更新します
15:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 15:00 高値: 587332 安値: 524240 終値: 531158
15:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] トレイリングストップの発動:ストップ位置を496121円に動かして、加速係数を0.2に更新します
16:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 16:00 高値: 534800 安値: 520500 終値: 529824
17:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 17:00 高値: 530710 安値: 524575 終値: 527774
18:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 18:00 高値: 530795 安値: 527233 終値: 527941
19:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 19:00 高値: 543977 安値: 527603 終値: 539533
20:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 20:00 高値: 540639 安値: 534243 終値: 538552
21:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 21:00 高値: 540688 安値: 536553 終値: 537095
22:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 22:00 高値: 543150 安値: 536240 終値: 540238
23:00	LINE Notify	[自動売買BOT(チャネルブレイクアウト)] 時間: 2019/04/02 23:00 高値: 540718 安値: 537450 終値: 539587

 
他にも同様の現象として、高値の更新によるポジションの積み増し(と、それに伴う損切ラインの引き上げ)があります。この場合も、バックテストで直近の安値が更新後の損切ラインにかかっていた場合に、実際には損切りにかかったのかどうかを判断できません。

解決策

この問題は1時間足の情報量が足りないことに原因があるので、バックテストプログラムの改良では解決できません。根本的にこの問題を回避するには、1時間足のバックテストに1分足を使うか、あるいは実際の売買でリアルタイムの価格を一切使わないロジックに変更するしかありません。

妥協策として「高値をつけたあとに安値をつけてまた高値で終わる可能性はやや低い」と考えることはできます。つまり「始値を2回またぐ可能性は低い」と考えるわけです。このとき、終値が値上がりなら安値 ⇒ 高値の順番に動いたと仮定し、終値が値下がりなら高値 ⇒ 安値の順番に動いたと仮定します。

この場合は単に以下の順番でロジックを適用します。

(例)直近の1時間足が「始値<終値」の場合

時間中には「始値 ⇒ 安値 ⇒ 高値 ⇒ 終値」の順番で動いたと仮定します。

1.買いポジションを保有している場合

1)損切りの関数を適用
2)手仕舞い(逆シグナル)の関数を適用
3)ポジションの積み増しの関数を適用
4)トレイリングストップの関数を適用

2.売りポジションを保有している場合

1)ポジションの積み増しの関数を適用
2)トレイリングストップの関数を適用
3)損切りの関数を適用
4)手仕舞い(逆シグナル)の関数を適用

 
もちろん「始値>終値」の場合は、それぞれが逆になります。

バックテストコード


while i < len(price):
	# ポジションがある場合
	if flag["position"]["exist"]:
		
		# 終値がポジションと同じ方向に動いた場合
		# 買いなら(安値⇒高値)の順、売りなら(高値⇒安値)の順に適用
		
		if ( flag["position"]["side"]=="BUY" and data["open_price"] < data["close_price"] ) \
		or ( flag["position"]["side"]=="SELL" and data["open_price"] > data["close_price"] ):
				
			flag = stop_position( data,flag )
			flag = close_position( data,last_data,flag )
			flag = add_position( data,flag )
			flag = trail_stop( data,flag )
		
		# 終値がポジションと逆の方向に動いた場合
		# 買いなら(高値⇒安値)の順、売りなら(安値⇒高値)の順に適用
		
		else:
			
			flag = add_position( data,flag )
			flag = trail_stop( data,flag )
			flag = stop_position( data,flag )
			flag = close_position( data,last_data,flag )

 
もちろんこの方法も完璧ではありません。始値と終値がほとんど同じ水準の場合などは、始値の水準を何度もまたぐことは珍しくありません。とくに揉みあいの場面では、バックテストの1時間足でどちらの順番に動いたかを判断することは不可能です。

しかし大きな値動きを伴う場面では、この順番で判定した方が(バックテスト上で)致命的な順番間違いを犯す可能性を減らすことができると思います。

モンテカルロ法で固定額トレードと定率(複利)トレードの運用成績を比較しよう

前回の記事では、実際に得られたトレード結果からランダムにリターンを復元抽出し、疑似的なトレード結果を生成することで最終資金(運用成績)のバラつきをシミュレーションするブートストラップ法について解説しました。

今回はその続きで、定額トレードで運用した場合と定率トレードで運用した場合の最終資金をモンテカルロシミュレーションで比較し、どのくらい結果のバラつきに違いがあるかを調べてみようと思います。

準備

前回の記事に引き続き、2018/5/19 ~2019/5/19(執筆時)の1年間のデータを使って検証します。手法は1時間足のチャネルブレイクアウトでトレンドフィルターのみ有効にしたものを利用します。

(例)
期間:2018/5/19~2019/5/19
手法:1時間足n期間チャネルブレイクアウト
損切り、トレンドフィルターあり
初期資金 50万円
レバレッジ3倍まで

1.固定額トレード

固定額トレードでは、1トレードあたり100万円分のBTCを売買します。 資金量の変動に関わらずレバレッジをかけて一定額(100万円)でトレードし続けます。ただし原資が33万円を割って100万円の売買ができなくなったら終了です。

初期資金 50万円
レバレッジ 3倍
1トレードの売買額 100万円

▽ 結果

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  148回
勝率               :  25.0%
平均リターン       :  0.75%
標準偏差           :  5.58%
平均利益率         :  6.99%
平均損失率         :  -1.33%
平均保有期間       :  34.2足分
損切りの回数       :  102回

最大の勝ちトレード :  363183円
最大の負けトレード :  -52674円
最大連敗回数       :  14回
最大ドローダウン   :  -211127円 / -16.4%
利益合計           :  2575531円
損失合計           :  -1473109円
最終損益           :  1102422円
スリッページ合計   :  -147674円
ProfitFactor       :  1.748

-----------------------------------
 証拠金1に対する賭額の割合
-----------------------------------
平均の賭け率             :  1.18
勝ちトレードの賭け率     :  1.231
負けトレードの賭け率     :  1.162
証拠金に対する平均利回り :  0.97%
平均利益率               :  8.49%
平均損失率               :  -1.53%
賭け率とリターンの相関   :  0.04
帰無仮説の確率p値       :  0.62
-----------------------------------
運用パフォーマンス
-----------------------------------
初期資金           :  500000円
最終資金           :  1602422円
運用成績           :  320.0%
手数料合計         :  -147674円

最大レバレッジは3倍ですが、資金が増えるにつれてレバレッジをかける必要がなくなるので、後半で資金効率が落ちてます。最終的な資金効率としては、1回のトレードで平均して1.18倍のレバレッジをかけ、証拠金に対して0.97%の利回りを得ていたことがわかります。利益合計は110万2422円、運用パフォーマンスは320%です。

2.定率トレード

定率トレードでは、常に口座残高の2%のリスクを取ってトレードします。この具体的な意味は、口座残高の2%を損切り幅で割って取得するポジションサイズ(枚数)を決定する、という意味です。詳しくはこちらの記事で解説しています。

資金が増えれば増えるほど取得できるポジションサイズが大きくなるため、いわゆる複利運用にあたります。

初期資金 50万円
レバレッジ 3倍
口座のリスク率 2%

▽ 結果

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  148回
勝率               :  25.0%
平均リターン       :  0.75%
標準偏差           :  5.58%
平均利益率         :  6.99%
平均損失率         :  -1.33%
平均保有期間       :  34.2足分
損切りの回数       :  102回

最大の勝ちトレード :  824328円
最大の負けトレード :  -49245円
最大連敗回数       :  14回
最大ドローダウン   :  -323097円 / -22.8%
利益合計           :  4107175円
損失合計           :  -2274386円
最終損益           :  1832789円
スリッページ合計   :  -284594円
ProfitFactor       :  1.806

-----------------------------------
 証拠金1に対する賭額の割合
-----------------------------------
平均の賭け率             :  1.879
勝ちトレードの賭け率     :  1.778
負けトレードの賭け率     :  1.912
証拠金に対する平均利回り :  1.38%
平均利益率               :  11.37%
平均損失率               :  -1.95%
賭け率とリターンの相関   :  -0.01
帰無仮説の確率p値       :  0.93
-----------------------------------
運用パフォーマンス
-----------------------------------
初期資金           :  500000円
最終資金           :  2332789円
運用成績           :  467.0%
手数料合計         :  -284594円

 
当然ですが、固定額トレードと定率トレードは全く同じ場面でエントリーして損切りや手仕舞いをしています。そのため、トレード回数や勝率、賭け金に対する平均リターンなどの数字は全て同じです。

一方、定率トレードは複利運用をしているので、同じリターンでも証拠金に対する利回り(資金効率)が違います。上の図を見てわかるように、定率トレードは資金が増えたあとも資金効率(レバレッジの比率)が変わっておらず、平均して1回のトレードで1.879倍のレバレッジをかけています。この差が最終成績の違いとして現れています。最終利益は183万2789円、運用成績は467%です。

この違いは、以下の「10%を超えるリターン」の利回りを比較するとわかりやすいです。

▽ 定額トレード 10%を超えるリターン

------------------------------------------
+10%を超えるトレードの回数  :  10回
------------------------------------------
2018-05-27 07:00:00  |  11.16% (証拠金利回り  22.88%) |  SELL
2018-06-15 03:00:00  |  12.18% (証拠金利回り  24.97%) |  SELL
2018-07-21 03:00:00  |  13.97% (証拠金利回り  27.66%) |  BUY
2018-08-07 23:00:00  |  11.54% (証拠金利回り  17.77%) |  SELL
2018-11-18 12:00:00  |  12.83% (証拠金利回り  17.45%) |  SELL
2018-11-28 13:00:00  |  30.90% (証拠金利回り  35.54%) |  SELL
2018-12-10 00:00:00  |  10.84% (証拠金利回り  10.29%) |  SELL
2018-12-22 05:00:00  |  11.55% (証拠金利回り  09.82%) |  BUY
2019-04-05 06:00:00  |  25.02% (証拠金利回り  22.02%) |  BUY
2019-05-16 20:00:00  |  36.51% (証拠金利回り  28.11%) |  BUY
------------------------------------------

▽ 定率トレード 10%を超えるリターン

------------------------------------------
+10%を超えるトレードの回数  :  10回
------------------------------------------
2018-05-27 07:00:00  |  11.16% (証拠金利回り  17.97%) |  SELL
2018-06-15 03:00:00  |  12.18% (証拠金利回り  29.48%) |  SELL
2018-07-21 03:00:00  |  13.97% (証拠金利回り  41.90%) |  BUY
2018-08-07 23:00:00  |  11.54% (証拠金利回り  07.50%) |  SELL
2018-11-18 12:00:00  |  12.83% (証拠金利回り  22.58%) |  SELL
2018-11-28 13:00:00  |  30.90% (証拠金利回り  40.17%) |  SELL
2018-12-10 00:00:00  |  10.84% (証拠金利回り  06.07%) |  SELL
2018-12-22 05:00:00  |  11.55% (証拠金利回り  17.55%) |  BUY
2019-04-05 06:00:00  |  25.02% (証拠金利回り  43.54%) |  BUY
2019-05-16 20:00:00  |  36.51% (証拠金利回り  52.93%) |  BUY
------------------------------------------

どちらも同じ場面で(賭け金に対しては)同じリターンを得ていますが、証拠金に対する利回り(資金効率)がかなり違うのがわかると思います。

疑問に思うこと

良かった結果だけを見れば「複利で運用した方がいい」という結論になるのは明白です。 最初から絶対に勝てるとわかっているなら、複利で運用しない理由はありません。

しかし最終的な運用成績は、ある時点からある時点を切り取ったものにすぎないので、切り取る場所を変えれば結果もかわります。どの時点の成果をもって「結果が良かった」と判断するかは難しい問題です。実際にレンジ相場に突入したときにトレンドフォローBOTを止めたくなる理由もここにあります。

定率トレードの一番の問題は、今回のスイングトレンドフォローBOTのように、勝率が低くかつ売買頻度もそれほど多くない売買ロジックを使用した場合、複利で運用すると「大半の人は平均(期待値)通りのパフォーマンスを上げられず、最頻値や中央値はそれよりも低い方向に偏る」という点です。

これは最終資金が対数正規分布に近似するからで、詳しくは前回の記事(最終資金のモンテカルロシミュレーション)でも説明しました。定率トレード全般にいえることですが、BOTの勝率が低い場合により顕著にその影響が出ます。

簡単にいえば、一般的には複利の方がドローダウンのリスクが大きいということです。今回の結果でも最大ドローダウンは定率トレードの方が大きくなっています。そこで疑問なのは、ある場面で定額トレードと定率トレードのどちらの方が最善なのかを、トレード結果を元に何らかの指標で定量的に判断できるのだろうか?ということです。

モンテカルロシミュレーションで試すこと

ブートストラップ法(復元抽出)で最終資金をシミュレーションした場合に、定率トレードでどのくらい最終資金にバラつきが見られるかは、前回の記事で確認しました。なので今回は、同じく定額トレードのモンテカルロシミュレーションで最終資金の分布を確認して、その違いを比較してみたいと思います。

1.扱うサンプルの違い

前回の記事でも簡単に説明しましたが、定率トレードと定額トレードでは、復元抽出に使うリターンの種類が違います。

定率トレードでは、全てのリターンを初期資金に掛け合わせた結果が最終資金と一致しなければなりません。そのため、リターンの数字には「証拠金に対する利回り」を使います。一方、定額トレードでは、全てのリターンを固定額(100万円)に掛けて利益額を計算し、それを初期資金に足し合わせてシミュレーションします。そのため、リターンの数字には単に「賭け金に対するリターン」を使います。

 
▽ 定率トレードのリターンサンプル (148個)

-2.218,17.971,-2.265,-2.073,-2.095,-2.225,0.002,-2.203,-2.181,0.511,-2.169,29.482,-2.116,-2.169,-1.235,7.912,-2.137,-0.834,-2.243,-2.193,-2.239,-2.336,2.212,-2.296,-2.189,41.902,-2.243,-2.192,3.038,-2.177,7.502,5.834,-2.101,-1.265,-2.134,-2.099,-2.153,-2.184,-2.208,-2.19,9.222,2.963,6.776,-2.118,-2.172,-0.252,-2.23,2.162,-2.168,1.201,-2.236,-2.26,-2.18,1.385,-1.286,-1.889,-1.506,15.184,-2.224,-2.148,-2.14,-1.648,-1.133,-1.516,-1.373,-1.769,-1.596,-1.838,-1.379,-1.191,-2.069,-1.172,2.542,-1.082,-1.791,-1.022,22.584,40.172,-2.098,-2.055,-2.078,6.068,-2.141,4.268,17.553,-2.123,-2.063,-2.163,-2.272,-2.077,-2.225,-2.153,-2.432,-2.244,13.738,-2.228,-2.277,-2.09,-0.575,-2.136,-2.223,-2.312,8.391,-2.216,-2.348,-1.961,-1.374,-1.956,-1.642,-1.202,13.038,-2.27,0.825,-2.265,22.731,-2.189,0.167,-2.148,-2.271,0.616,-2.32,-2.075,-2.247,-2.206,2.374,-2.323,-0.613,-2.279,43.537,0.823,-0.199,-2.386,0.522,-2.265,-2.257,-2.103,-2.215,-2.21,-0.425,5.079,-2.262,-2.224,-2.278,7.367,-2.133,-2.185,52.935,-2.092

▽ 定額トレードのリターンサンプル (148個)

-1.155,11.162,-1.03,-3.291,-2.653,-1.264,0.001,-1.231,-1.464,0.549,-1.595,12.182,-2.405,-1.583,-1.817,4.976,-1.71,-1.667,-1.089,-1.329,-1.037,-0.797,0.945,-0.863,-1.53,13.967,-1.068,-1.282,4.747,-1.979,11.542,6.341,-2.693,-1.643,-2.667,-2.257,-1.619,-1.456,-1.636,-1.611,6.54,3.406,9.156,-1.78,-1.478,-0.153,-1.343,2.637,-1.436,0.765,-1.278,-0.991,-1.306,0.647,-0.429,-0.63,-0.502,5.061,-1.101,-2.237,-0.713,-0.551,-0.378,-0.505,-0.459,-0.592,-0.534,-0.613,-0.461,-0.397,-0.692,-0.392,0.85,-0.604,-0.597,-0.341,12.832,30.902,-3.179,-5.269,-3.406,10.836,-1.878,3.361,11.548,-2.498,-3.438,-1.545,-1.177,-2.967,-1.19,-1.538,-0.973,-1.24,4.61,-1.148,-0.994,-0.697,-0.756,-1.682,-1.101,-0.9,2.797,-1.185,-0.839,-0.654,-0.458,-0.652,-0.547,-0.401,6.179,-0.974,0.275,-0.913,9.316,-1.377,0.067,-0.716,-0.919,0.29,-0.951,-2.695,-0.994,-1.24,1.29,-0.821,-0.204,-0.904,25.021,0.799,-0.184,-0.907,0.45,-1.007,-1.016,-2.842,-1.231,-1.227,-0.196,2.04,-1.001,-1.917,-1.022,4.116,-1.839,-1.583,36.507,-4.981

2.定率トレードの最終資金のシミュレーション

定率トレードの場合の最終資金のモンテカルロシミュレーション(ブートストラップ法)については、すでに前回の記事で解説しているので、先に結果を見てしまいましょう。※注 定額トレードと比較の条件を揃えるために少しパラメータを変えたので、前回と結果は少し違っています。

トレード回数 148回
シミュレーション回数 50,000回

▽ 資産カーブ

▽ 最終資金ヒストグラム(ビン数=1000)

5%下位 53万6673円 (運用成績 107.3%)
最頻値 94万8324円 (運用成績 189.6%)
中央値 223万6135円 (運用成績 467.2%)
実際の成績 2332789円 (運用成績 466.5%)

 
ここで現実離れした右側の数値に意味があるわけではありません。正のリターンはサンプル数が少なくバラつきも大きすぎるからです。そのため、平均値が過大になっていますが、759%という数字が実際の市場で期待できる平均値という意味ではありません。ここでは1トレードあたりの勝率が低いことが原因で、最頻値が中央値よりかなり下方に偏っているという情報が重要です。

3.定額トレードの最終資金のシミュレーション

次に定額トレードの最終資金を、同じくブートストラップ法(サンプルの復元抽出)でシミュレーションします。

先ほども説明したように、定額トレードの場合は「賭け金に対するリターン」の数字からランダムにリサンプリングし、それを固定額(例:100万円)に掛け合わせた結果を初期資金に順番に足し合わせるプログラムを書きます。

▽ pythonコード


from matplotlib import pyplot as plt
import numpy as np
import scipy.stats as stats
import random

#------------------------------------
import matplotlib as mpl
font = {"family":"Noto Sans CJK JP"}
mpl.rc("font",**font)
#-------------------------------------

#----------------------------------------------------
# 準備
#----------------------------------------------------
data = np.genfromtxt("./return_fixed.csv",delimiter=",")
start_funds = 500000
trade_N = data.size
N = 50000
BET = 1000000

random_return = np.zeros((N,trade_N))
av_av_return =  np.zeros(N)

# 資産推移の行列
asset_simulation = np.zeros((N+1,trade_N+1))
asset_simulation[:,0] = start_funds
for i in range(trade_N) : asset_simulation[0][1+i] = round(asset_simulation[0][i] + ((data[i]/100) * BET))

#----------------------------------------------------
# 使う関数
#----------------------------------------------------
# 勝率の計算
def winrate( data ):
	r = np.sum( data>0 ) / data.size
	r = round(r*100,1)
	return r

# 平均リターンの計算
def av_return( data ):
	r = data.mean()
	r = round(r,2)
	return r

# 勝ちの平均利益と負けの平均利益を計算
def win_lose_rate( data ):
	win_r  = np.where( data>0, data, 0 )
	win_r  = round( win_r.sum()  / np.sum(data>0) ,2 )
	lose_r = np.where( data<0, data, 0 )
	lose_r = round( lose_r.sum() / np.sum(data<0) ,2 )
	return win_r,lose_r


#----------------------------------------------------
# モンテカルロシミュレーション
#----------------------------------------------------
for i in range(N):
	print("----------------------")
	random_return[i] = np.array( random.choices( data, k=trade_N ) )
	for k in range(trade_N) : asset_simulation[i+1][k+1] = round(asset_simulation[i+1][k] + ((random_return[i][k]/100)*BET) )
	print("勝率          :  {}% ".format( winrate(random_return[i]) ))
	print("平均リターン  :  {}% ".format( av_return(random_return[i]) ))
	win_r, lose_r = win_lose_rate( random_return[i] )
	print("平均利益率   :   {}% ".format( win_r  ))
	print("平均損失率   :   {}% ".format( lose_r ))
	av_av_return[i] = av_return(random_return[i]) 


#----------------------------------------------------
# リサンプリングで得られた平均リターンのヒスグラム
#----------------------------------------------------
n1,n2 = np.histogram( av_av_return, bins=50 )
average = round( av_av_return.mean(),2)
percentile_5  =  np.percentile( av_av_return,5  )
percentile_95 =  np.percentile( av_av_return,95 )

y  =  n1/float(n1.sum())
x  = (n2[1:]+n2[:-1])/2
x_width = n2[1] - n2[0]

plt.bar( x, y, width=x_width )
plt.axvline( average, color="red", label="平均リターンの平均値 {}%".format(average) )
plt.axvspan( percentile_5, percentile_95, color="blue",alpha=0.1, label="95パーセンタイル区間")

plt.grid()
plt.xlabel("復元抽出した平均リターンの分布")
plt.ylabel("頻度")
plt.legend()
plt.show()



#----------------------------------------------------
# 資産カーブのグラフ化
#----------------------------------------------------

plt.grid()
for i in range(N): plt.plot( asset_simulation[i+1] )
plt.ylim(0,5000000)
plt.axhline( start_funds, color="#2e8b57", label="初期資金 {}円".format(start_funds) )
plt.plot( asset_simulation[0], color="black", lw=3, label="最初の資産カーブ" )
plt.legend()
plt.show()

#----------------------------------------------------
# 最終資金のヒストグラム
#----------------------------------------------------

last_funds = asset_simulation[:,-1]
median = np.median( last_funds )
average = round(last_funds.mean())
n1,n2 = np.histogram( last_funds, bins=1000 )

percentile_5  =  np.percentile( last_funds,5  )
percentile_95 =  np.percentile( last_funds,95 )

y  =  n1/float(n1.sum())
x  = (n2[1:]+n2[:-1])/2
x_width = n2[1] - n2[0]
mode = round(x[np.argmax(y)])

plt.bar( x, y, width=x_width )
plt.axvline( mode, color="red", label="最終資金の最頻値 {}円".format(mode) )
plt.axvspan( percentile_5, percentile_95, color="blue",alpha=0.2, label="95パーセンタイル区間" )
plt.axvline( percentile_5, color="blue", label="5パーセンタイル値  {}円".format( round(percentile_5)) )
plt.axvline( median, color="black", linestyle="--",label="中央値 {}円".format(round(median)) )
plt.axvline( average, color="green", label="平均値 {}円".format(round(average)) )

plt.grid()
plt.xlabel("ブートストラップ法による最終資金の分布(単位:円)")
plt.ylabel("確率")
plt.xlim(0,last_funds[0]*4)
plt.xticks(rotation=90)
plt.legend()
plt.show()

実行結果

トレード回数 148回
シミュレーション回数 50,000回

▽ 資産カーブ

▽ 最終資金のヒストグラム(ビン数=1000)

定率トレードの結果に比べると歴然ですが、全く同じトレード機会にもかかわらず、定額トレードでは最頻値・中央値・平均値の3つがほとんど一致しています。いずれも150万円~160万円で、運に結果が左右される可能性が定率トレードに比べて低いことを意味しています。多数の人が、期待値とおりの成績を上げられる可能性が高いということです。

5%下位 55万7220円
最頻値 148万9661円
中央値 157万1390円
平均値 160万4651円
実際の成績 1602422円(320.0%)

 
ただし5%以下の運の悪さに遭遇したときの成績はほとんど同じです。0円まわり(破産確率)は定額トレードの方が高いくらいです。これは当たり前ですが、口座残高のn%を賭ける定率トレードは、数字上は破産しないからですね。定額トレードは、負けても賭け額を減らしたりしないので、正の期待値がなければ破産します。

どう判断するかを考える

定率トレードの最終資金の確率分布は対数正規分布に近づき、一方、定額トレードの最終資金の確率分布は正規分布に近づくことが理解できました。しかしこのグラフを眺めるだけだと、単に「定率トレードはリスクも大きいけどリターンも大きいよね」という、よく本に書かれている結論しか出てこず、結局どう意思決定すればいいのかわかりません。

今回のケースの場合、中央値は定率トレードの方が高いものの、最頻値は定額トレードの方が高いというヒントになりそうな有益な情報が得られました。ということは、最初の疑問に答えるためには、「最頻値」と「中央値」のどちらを信じてトレードするべきか?という問題が鍵になりそうです。

「最頻値と中央値のどちらを信じて意思決定するべきか?」という質問に、教科書的な答えはあるのでしょうか?

1.統計的な意思決定

私も詳しくありませんが、統計学の世界ではこのように「確率分布の中からどれか1つだけ代表値を選ばなければならない場面での意思決定の方法」についての学問分野があり、それを統計的意思決定というそうです。

結局のところ、「目的に応じて最適な数字を選ぶしかない」というありきたりな結論になるのですが、せめてそれを関数にして最適解を求めようということです。このような関数を損失関数というそうです。よくファイナンスの用語で損失関数という言葉を聞きますが、そういう意味なんですね。(参考:「pythonで体験するベイズ推論」5章より)

例えば、以下のような指標を作って定量的に判断します。

(漠然とした気持ち)

「定率トレードの方が大きく儲かる可能性が高いなら、多少のリスクを背負っても定率トレードにしたいなぁ。でも固定額トレードの平均を下回るのは悔しいよな...。その可能性の方が高いならやめておこうかなぁ...。」

(統計的意思決定)

原則として定率トレードを選択する。ただし定率トレードの最終資金が定額トレードの期待値を下回る確率が50%を超えるなら定率トレードはやめる。

2.損失関数をつくろう

モンテカルロシミュレーションの素晴らしいところは、数十万回のトレード結果の事後分布を得られるので、上記の確率を実際に計算できることです。さっそく「最終資金が定額トレードの平均を下回る確率が50%を超える」かどうかを判定する損失関数を作りましょう。


#----------------------------------------------------
# 意思決定するための損失関数
#----------------------------------------------------

def judgment( last_funds ):
	
	probability = np.array( last_funds<1600000 ).mean()

	print("--------------------------")
	print("推測される確率  : " ,probability)
	print("--------------------------")
	if probability > 0.5:
		print("定額トレードを選択すべき")
	else:
		print("定率トレードを選択すべき")


これだけでOKです。めちゃくちゃ簡単です。

変数 last_funds には、モンテカルロシミュレーションで得られた10万回分の最終資金の結果がリスト形式で保存されています。それを以下のコードによって、定額トレードの平均(160万円)を下回っているかどうかで True/false の二値の配列に変換します。

np.array( last_funds < 定額トレードの平均値 ) 

真偽値のTrueは1、Falseは0なので、そのリストの平均を取れば、Trueの確率(全ての最終資金のうち160万円を下回った結果の割合)を計算できます。

このようにモンテカルロシミュレーションで得られた大量のサンプルから、ある条件に当てはまるかどうかを True/False の二値変数に変換して、Trueの割合から確率を推測する方法は、ベイズ推計(事後確率)の基本的な考え方のようです。さきほど紹介した書籍のほか、本「基礎からのベイズ統計学」6章などでも紹介されています。

実行結果

定率トレードの結果が、定額トレードの平均的な結果を下回る確率は約36%であり、最初に立てた意思決定の基準に照らし合わせると定率トレードを選択することが合理的である、とわかります。

注意点

このようにモンテカルロシミュレーションの優れた点は、実際に意思決定するときに必要となる基準点の確率をちゃんと計算できる点にあります。以下のグラフでいうと、最終資金が160万円を下回る確率が35%だとわかる、ということです。

ただしこのような意思決定を行う場合には、結果を見る前に意思決定の基準を決めて、それを守ることが大事です。売買ロジックの評価と全く同様で、損失関数のパラメータ(ここでは160万円と50%という数字)も自由度の1つなので、ずるずると弄るといくらでも望むような結果を出せてしまいます。

例えば、最初から「本当は定率トレードでやりたい..」という思い込みがあり、上記の結果を見たあとに、もし悪かったら「じゃあ100万円を下回る確率ならどうだろう..」「じゃあ40%以上ならどうだろう?」と結果を見ながら数字を変更しはじめると、損失関数を使うことに何の意味もないということです。

ブートストラップ法(復元抽出)でBOTの運用成績をシミュレーションする

ある勝率と期待値をもつBOTが、実際にある一定期間でどのくらいの運用成績を得られるか?を判断するのは非常に難しい問題です。とくに定額ではなく定率(複利)でトレードをする場合、なおさら予測は困難になります。

例えば、ある売買ロジックでBOTを運用した結果、1年間で以下のような結果が得られたとします。実成績でもバックテストの結果でも構いません。

(例)
期間:2018/5/19~2019/5/19
全トレード数 161回
勝率 20.5%
平均利回り 1.06%
平均利益率 10.2%
平均損失率 -1.3%

———————————–
運用パフォーマンス
———————————–
初期資金 : 500000円
最終資金 : 1948155円
運用成績 : 390.0%
手数料合計 : -273098円

これは実際にあるパラメータで1時間足のチャネルブレイクアウトBOTを(口座のリスク率2%)で動かした場合の運用成績の例です。

疑問

この結果だけを見ると最終成績は良好に見えます。1年間で3倍以上になっていますから。しかし問題なのは、『この最終成績390%という数字はどの程度、運が良かった結果なのか?』という情報が、最終成績の数字を見てもよくわからない点にあります。

仮に勝率20.5%、期待値1.06%(平均利益10%、平均損失-1.3%)という結果の元となる確率分布が、今後も続くと信じることを前提にしても、161回の試行で390%という運用成績が果たしてどのくらい「ありうる」ことなのか? かなり運が良かったのか、それとも期待値通りの平凡な結果だったのか、潜在的にどのくらいドローダウンリスクがあったのか? この数字だけではわかりません。

今回の記事では、判断の目安として、得られたリターンのサンプルから何度も復元抽出(リサンプリング)をして、ありえた最終成績のシミュレーションをするブートストラップ法を紹介します。

ブートストラップ法(復元抽出)

ブートストラップ法とは、1度だけしか抽出できないサンプルを再利用して大量のトレード結果を疑似的に生成するモンテカルロシミュレーションです。数学が得意でないので、厳密な定義はわかりませんが、多分そんな感じです。

 

 
この方法の優れたところは、リターンがどんな確率分布に従うのかわからない(正規分布などでモデル化できない)場合でも、実際のサンプルから好きなだけ疑似的なトレード結果を生成できることです。

復元抽出(=1度選んだ玉をバケツに戻す方式)なので、実際にありえる値の中から実際にありえる頻度(確率)に近い形でリターンを選んで、それを使って異なる結果をシミュレーションすることができます。

もちろん同じサンプルを何度も使ってるだけなので、何万回も試行を繰り返せば同じ分布になります。ですが、1年間の成績(161回のトレード)という少ない試行回数の制限の中では、どの程度、結果がバラつく可能性があったかをシミュレーションできます。

pythonコード

ブートストラップ法のイメージを掴むために、疑似的なトレード結果のサンプルを合計25セットほど作成してその結果をグラフにしてみましょう。

▽ 実際のトレードで得られたリターンのサンプル161個

▽ csvファイルに集計

-2.135,15.292,-1.133,-1.02,-1.843,-2.156,-0.794,-1.107,-1.083,-0.031,-1.068,28.578,-1.034,-1.547,-0.599,7.268,-1.419,-0.417,-2.124,-1.076,-1.109,-1.172,1.587,-1.981,-1.087,41.402,-1.122,-1.09,2.864,-1.089,7.148,3.553,-2.005,-1.972,-2.023,-1.865,-1.085,-1.092,-1.096,-1.079,0.169,7.34,4.487,6.202,-1.068,-1.079,-0.852,-1.234,1.611,-2.103,0.805,-1.13,-1.084,0.782,-1.286,-1.215,-1.285,14.745,-1.112,-1.074,-1.17,-1.251,-1.133,-1.258,-1.3,-1.219,-1.54,-1.213,-1.296,-0.761,-1.169,-1.176,-1.507,-0.509,-0.538,-1.212,-0.798,22.072,40.899,-1.802,-1.051,-2.005,-1.056,-1.574,-1.059,2.483,-1.657,3.692,14.52,-1.598,-1.039,-2.046,-1.062,-1.394,-1.955,-1.13,-1.038,-1.106,-1.077,-1.216,-2.013,13.119,-1.114,-1.134,-1.178,-0.287,-1.059,-1.112,-1.151,-1.501,0.696,-2.151,-1.174,-1.236,-1.319,-1.206,-1.496,-1.202,12.396,-1.14,0.541,-1.133,22.143,-1.593,-2.306,-1.196,-1.131,0.01,-1.16,-2.036,-2.2,-1.103,1.774,-0.634,-1.143,-0.973,-1.93,13.559,0.187,-1.796,-1.816,-2.214,-0.033,-2.169,-2.207,-1.051,-1.095,-2.146,-0.44,4.444,-1.131,-1.112,-1.134,5.13,-1.066,-1.092,35.116,-2.012,-1.027,-2.018,-0.996

まずは上記のように、実際のトレードまたはバックテストで得られたリターンの結果(161個)を羅列して1つのcsvに保存します。ここでは仮に return.csv で保存したとします。

次に以下のようなコードをpythonファイルで保存して、読み込みます。

▽ pythonコード


from matplotlib import pyplot as plt
import numpy as np
import random

#------------------------------------
import matplotlib as mpl
font = {"family":"Noto Sans CJK JP"}
mpl.rc("font",**font)
#-------------------------------------

#----------------------------------------------------
# 準備
#----------------------------------------------------
data = np.genfromtxt("./return.csv",delimiter=",")
trade_N = 161
N = 25

random_return = np.zeros((N,trade_N))
av_av_return =  np.zeros(N)

# 資産推移の行列
asset_simulation = np.zeros((N+1,trade_N+1))
start_funds = 500000
asset_simulation[:,0] = start_funds
for i in range(trade_N) : asset_simulation[0][1+i] = round(asset_simulation[0][i] * (data[i]/100+1))

#----------------------------------------------------
# 使う関数
#----------------------------------------------------
# 勝率の計算
def winrate( data ):
	r = np.sum( data>0 ) / data.size
	r = round(r*100,1)
	return r

# 平均リターンの計算
def av_return( data ):
	r = data.mean()
	r = round(r,2)
	return r

# 勝ちの平均利益と負けの平均利益を計算
def win_lose_rate( data ):
	win_r  = np.where( data>0, data, 0 )
	win_r  = round( win_r.sum()  / np.sum(data>0) ,2 )
	lose_r = np.where( data<0, data, 0 )
	lose_r = round( lose_r.sum() / np.sum(data<0) ,2 )
	return win_r,lose_r

# ブートストラップのグラフを表示する関数
def hist_return( data,i ):

	plt.subplot(5,5,i)
	n1,n2 = np.histogram( data, bins=200 )
	average = round(data.mean(),2)
	percentile_5  =  np.percentile( data,5  )
	percentile_95 =  np.percentile( data,95 )

	y  =  n1/float(n1.sum())
	x  = (n2[1:]+n2[:-1])/2
	x_width = n2[1] - n2[0]

	plt.bar( x, y, width=x_width )
	plt.axvline( average, color="red", label="平均リターン {}%".format(average) )
	plt.axvspan( percentile_5, percentile_95, color="blue",alpha=0.1, label="95パーセンタイル区間")

	plt.grid()
	plt.legend()


#----------------------------------------------------
# リサンプリングの結果を表示してグラフ化
#----------------------------------------------------
for i in range(N):
	print("----------------------")
	random_return[i] = np.array( random.choices( data, k=trade_N ))  # 復元抽出
	for k in range(trade_N) : asset_simulation[i+1][k+1] = round(asset_simulation[i+1][k] * (random_return[i][k]/100+1))  # 資産推移の計算
	print("勝率          :  {}% ".format( winrate(random_return[i]) ))
	print("平均リターン  :  {}% ".format( av_return(random_return[i]) ))
	win_r, lose_r = win_lose_rate( random_return[i] )
	print("平均利益率   :   {}% ".format( win_r  ))
	print("平均損失率   :   {}% ".format( lose_r ))
	av_av_return[i] = av_return(random_return[i]) 
	hist_return(random_return[i],i+1)

plt.title("リターンの分布と頻度")
plt.show()

#----------------------------------------------------
# リサンプリングで得られた平均リターンのヒスグラム
#----------------------------------------------------
n1,n2 = np.histogram( av_av_return, bins=200 )
average = round( av_av_return.mean(),2)
percentile_5  =  np.percentile( av_av_return,5  )
percentile_95 =  np.percentile( av_av_return,95 )

y  =  n1/float(n1.sum())
x  = (n2[1:]+n2[:-1])/2
x_width = n2[1] - n2[0]

plt.bar( x, y, width=x_width )
plt.axvline( average, color="red", label="平均リターンの平均値 {}%".format(average) )
plt.axvspan( percentile_5, percentile_95, color="blue",alpha=0.1, label="95パーセンタイル区間")

plt.grid()
plt.xlabel("復元抽出した平均リターンの分布")
plt.ylabel("頻度")
plt.legend()
plt.show()

 
上記のコードは、ブートストラップ法によって生成した疑似トレード161回の結果を合計25セット生成し、その勝率、平均リターン、ヒストグラムを図にするコードです。

trade_N が1セットあたりのトレード回数(=実際のトレードやバックテストで集計されたトレード回数)です。それを1セットとします。 N はブートストラップ法で何セット疑似サンプルを生成するかを指定する数字です。

ここでは5×5で綺麗に図にしたかったので25セットにしましたが、何セットでも構いません。欲しいだけいくらでもデータを生成できます。実際、あとで10万セット生成して運用パフォーマンスをシミュレーションする例を紹介します。

実行結果

このように25セットの疑似データを生成することができました。同じサンプル(実際のトレード結果)から再抽出してるので、どれもパッと見では似たようなリターン分布になっています。左上の4個を拡大したものが以下です。

しかし実際には、以下の出力を見るとわかるように、勝率には13.7%~27.3%まで大きなバラつきがあり、結果として1トレード当たりの平均リターンも 0.48%~1.94%まで大きなバラつきがあります。

 

 
以下は 疑似生成した25セットのトレード結果の平均リターンをヒストグラムにしたものです。赤い線は、161回のトレードの平均リターンを25回採取したものの平均値(=つまり平均リターンの平均)です。

このように25回ほどシミュレーションを繰り返してみた結果、大体どの場合でも平均リターンは一応プラスになりそうだということ、年間161回という少ない試行の中では、運が悪ければ勝率が13%程度ということもありえた、ということ、などが想像できます。

25回のシミュレーションでは、1番低い平均リターンは +0.35%でした。

資産カーブをグラフ化する

それでは次に、いよいよ知りたかった「最終的な運用パフォーマンスにどのくらいのバラつきがありえたのか?」を検証してみましょう。ブートストラップ法で疑似的に生成したトレード結果を最初の資金に適用して、最終資金のシミュレーションをします。

なお注意点として、ここで用いる「リターン」のサンプルが、証拠金利回りの数字になっていることを確認してください。この数字を用いないと、初期資金とリターンを掛け合わせた結果が最終的な資金と一致しません。

単なるリターンと証拠金利回りの違いはこちらの記事で解説しています。

▽ pythonコード


#----------------------------------------------------
# 資産カーブのグラフ化
#----------------------------------------------------

plt.grid()
for i in range(N): plt.plot( asset_simulation[i+1] )
plt.ylim(ymin=0)
plt.axhline( start_funds, color="#2e8b57", label="初期資金 {}円".format(start_funds) )
plt.plot( asset_simulation[0], color="black", lw=5, label="最初の資産カーブ" )
plt.legend()
plt.show()

 
これを実行すると以下のようになります。

「太い黒線」が実際のトレード(バックテスト)で得られた資産カーブです。たった25回のシミュレーション結果を見るだけでも、最終資金は、50万円~300万円以上まで幅広くバラついているのがわかります。運が悪ければ、ほとんど資産が増えなかったケースもあり得たかもしれません。

ちなみに1000回ほどシミュレーションすると以下のようになります。

10万回シミュレーションする

上記のグラフを見ても、「どのくらいの最終資金になる可能性が高いのか?」「最終資金が50万円(初期資金)を下回る可能性はどのくらいあるのか?」いまいちわかりません。なので、ブートストラップ法で10万回ほど疑似トレード結果をシミュレーションして最終資金のヒストグラムを作りましょう。

▽ pythonコード


#----------------------------------------------------
# 最終資金のヒストグラム
#----------------------------------------------------

last_funds = asset_simulation[:,-1]
median = np.median( last_funds )
n1,n2 = np.histogram( last_funds, bins=1000 )

percentile_5  =  np.percentile( last_funds,5  )
percentile_95 =  np.percentile( last_funds,95 )

y  =  n1/float(n1.sum())
x  = (n2[1:]+n2[:-1])/2
x_width = n2[1] - n2[0]
mode = round(x[np.argmax(y)])

plt.bar( x, y, width=x_width )
plt.axvline( mode, color="red", label="最終資金の最頻値 {}円".format(mode) )
plt.axvspan( percentile_5, percentile_95, color="blue",alpha=0.2, label="95パーセンタイル区間" )
plt.axvline( percentile_5, color="blue", label="5パーセンタイル値  {}円".format( round(percentile_5)) )
plt.axvline( median, color="black", linestyle="--",label="中央値 {}円".format(round(median)) )

plt.grid()
plt.xlabel("ブートストラップ法による最終資金の分布(単位:円)")
plt.ylabel("確率")
plt.xlim(0,last_funds[0]*3)
plt.xticks(rotation=90)
plt.legend()
plt.show()

5パーセンタイル値 : 57万1531円(運用成績 114.3%)
最終資金の最頻値 : 120万0000円(運用成績 240.0%)
中央値 : 188万0817円(運用成績 376.1%)

 
実際の成績は194万円(390)%でしたが、これだけ勝率が低くかつ利益のバラつきが大きい売買ロジックでは、最終資金が120万円(最頻値)~190万円(中央値)くらいに終わってもおかしくなかったことがわかります。ただし相当運が悪くても初期資金50万円を割る可能性は5%以下とかなり低いようです。

▽ ありえた「平均リターン」の分布

 
トレンドフォロー戦略のBOTの場合、そもそも1トレードあたりの勝率が低く、かつ利益が出た場合のバラつきが極端に大きいので、「実際の成績よりも利益が増えた場合」のシミュレーション結果には、ほとんど意味がありません。

中には最終資金が1000万円を超えるようなシミュレーションもありますが、それは単に勝ちトレードのサンプル数が少ないことが原因です。復元抽出なので、10万回もテストすれば極端に高い利益を何度も選びなおす可能性もあります。 当然ですが、実際に市場でそのような利益機会が生じた可能性を意味するわけではありません。

一方、「実際の成績よりも悪かった場合」のシミュレーションはそれなりに信憑性があると思います。

もちろん市場の過去データは1通りしかないので、何度サンプルを取り直したところで本当の意味で複数のシミュレーションをすることはできません。しかし負けトレードの結果はサンプル数も多く分散も小さいので、現実にありえない値を取る可能性は低く、本当の勝率が想定より低かった場合をうまくシミュレーションできます。

また同時に、極端に1回だけ高い利益が出たことで全体の成績が実際より良く見えている場合(外れ値の影響)は、ブートストラップ法で何度もリターンを復元抽出することで、その影響を弱めることができます。

補足:数学的にわかること

上記のような最終資金のモンテカルロシミュレーションの意味を、もう少し一般的に理解するために、少し数学的に考えてみましょう。トレードの成績をもっとシンプルに、(1)勝率、(2)勝ったときの平均利益、(3)負けたときの平均損失、の3つのパラメーターだけで決まる確率変数と仮定します。

すると最終資金は以下のように決まります。

 
つまり最終資金は、勝ちトレードの回数によって決まる関数です。この「勝ちトレードの回数」は、勝率pによって決まる確率変数なので、数学的には以下のような二項分布でモデル化できます。

 
二項分布という確率分布は、試行回数(N)が十分に大きいとき(勝率が極端に小さくなければ)、正規分布に近似できることが知られています。(参考wikipedia

ということは、最初の式に戻ってみると、「勝ち回数」を表す指数の部分(x乗)は正規分布で近似できることがわかります。 指数のところが正規分布に従うような確率変数を、対数正規分布といいます。

対数正規分布というのは、トレードをしている方の多くが聞いたことがあると思いますが、0を閾値として右側にファットテールになる、以下のような形の確率分布です。

▽ 適当な対数正規分布


import scipy.stats as stats
from matplotlib import pyplot as plt
import numpy as np

s = 0.7
plt.grid()
x = np.linspace( stats.lognorm.ppf(0.01,s), stats.lognorm.ppf(0.99,s), 100 )
plt.plot(x, stats.lognorm.pdf(x,s))
plt.show()

さきほどの最終資金のヒストグラムに似ていますね。

このような確率分布の特徴として、最頻値や中央値が平均値よりも小さくなる(左側に偏る)という性質があります。いわゆる「数少ない一部の人が大勝ちし大半の人は平均より低い成績に偏る」という分布で、年収の分布などと同じです。

 
つまり、運用するBOTがどのような勝率・期待利益・期待損失を持ってるかに関わらず、基本的には最終資金のグラフの形はこのような感じになる、ということです。なのでグラフの形状自体に意味はありません。どのような売買ルールを採用しても多分こうなります。

有益な情報

ではこのブートストラップ法による最終資金のモンテカルロシミュレーションから、どのような情報を得ればいいのでしょうか。1つは「最終資金のバラつきがどのくらい大きいか?」です。

例えば、同じモンテカルロシミュレーションで、勝率50%、期待利益2%、期待損失1%のBOTの運用をシミュレーションしてみましょう。すると最終資金は以下のようになります。

▽ 初期資金50万円 トレード回数161回

たしかに僅かに右側に偏った分布になっており、最頻値<中央値<平均値のかたちになっていますが、その差はほとんどありません。このような分布であれば、最終的な資金(運用成績)についてある程度の精度で確信を持つことができそうです。

最頻値 89万8447円
中央値 90万2913円
期待値 91万8696円

次にもっと極端な 勝率10%、期待利益14%、期待損失1%のBOTを考えてみます。こちらのBOTも期待リターンは同じ(+0.5%)ですが、最終資金のヒストグラムはもっと歪んだ形になることが予想できます。

▽ 初期資金50万円 トレード回数161回

 
先程のBOTと1トレード当たりの平均リターンは全く同じにもかかわらず、最終資金の分布は大きくバラつき、ハッキリと勝ち組と負け組に分かれてしまっています。中央値はあまり変わりませんが、最頻値はさきほどより遥かに低くなっています。一方で期待値はかなり大きくなっています。

またさきほどの分布とは異なり、5パーセンタイル値が40万6381円で原資割れしています。これは運が悪ければ、初期資金を下回る可能性もあったことを示しています。

最頻値 67万0016円
中央値 94万7437円
期待値 112万0384円

最後のシミュレーションには以下のコードを作りました。


#---------------------------
# トレード結果ジェネレーター
#---------------------------
p = 0.1
av_win  = 10
av_lose = -1
trade_N = 161

def generate_data( p,N,av_win,av_lose ):
	data = stats.bernoulli.rvs( p, size=N )
	data = [ av_lose if i==0 else av_win for i in data ]
	return data

BOTの一番成績のいい最適なパラメーターをpythonで総当たりで探索しよう!

さて、前回までの記事でバックテストをして自動売買BOTのパフォーマンスを評価する方法は、大体マスターできたと思います!

今回は、バックテスト編の総仕上げとして最適なパラメーターを探索する方法を紹介しておきましょう。このパラメーター探索ができることこそ、ある意味、自分でpythonのプログラミングができることの優位性といっても過言ではないかもしれません。

手動だと何百通り、何千通りも試す必要のある膨大な組み合わせの中から、もっとも勝率や期待リターンが高く、最もリスクやドローダウンの小さいパラメーターは何なのか? それを調べる方法を解説します。

パラメーター最適化とは

パラメーターとは、ある自動売買BOTのロジックの中で自由に動かして変更できる値のことをいいます。例えば、前回までの記事で学習した一番シンプルなn期間ドンチャン・ブレイクアウトBOTには、以下の2つのパラメーターがありました。

1)X分足の時間軸を使う
2)n期間の最高値(最安値)ブレイクアウトで買う

同じドンチャンブレイクアウトでも、15分足で10期間のドンチャン・ブレイクアウト戦略を使うのと、1時間足で30期間のドンチャン・ブレイクアウト戦略を使うのとでは、全く成績が異なります。

上記の2つの組み合わせだけなら、前回までの記事の方法で1つ1つを手作業で検証することも可能です。しかし組み合わせが何百通り、何千通りと増えてくると、すべてのパターンを手作業で検証して比較するのは難しくなります。

そこでpythonの出番です。pythonのようなプログラムなら、自動的に何百通りものパラメーターの組み合わせでバックテストを行い、最も利益を最大化できるようなパラメーターを探索することができます。今回はその方法を解説していきます!

今回の練習で試すパラメーター

まずは準備として前回までのドンチャン・ブレイクアウトのコードを改良し、以下の4つのパラメーターを設定できるようにしてみましょう。

変更可能なパラメーター

1)X分足の時間軸を使う
2)n期間の最高値のブレイクアウトで買う
3)m期間の最安値のブレイクアウトで売る
4)ブレイクアウトの判定基準に、高値/安値 or 終値 を使う

より実践的なドンチャン・ブレイクアウト戦略では、上値のブレイクアウトと下値のブレイクアウトで異なるパラメーターを使用することがよくあります。そのため、今までのコードを改良し、買いエントリと売りエントリで、それぞれ違う期間のパラメーターを設定できるようにしておきます。

また今までのコードでは、ブレイクアウトの判定基準を「過去n期間の最高値(最安値)を直近の高値が上回ったとき」としていましたが、高値ではなく終値のパターンも検証しておきましょう。

上記の修正済みのコードは、以下の別記事にまとめておきます。
時間のある方は自分でも挑戦してみてください!

パラメーターを設定できる改良版コード

パラメーターの組み合わせを総当たりでテストする方法

パラメーター探索と聞くと、最近流行りの機械学習やニューラルネットワークなど、難しい数学やプログラミングスキルが必要だと誤解している方もいるかもしれません。

しかし基本的には、数千通りくらいのパターンであれば、ただの「総当たり」で全パターンを計算すれば十分です。全パターンを試しても数十秒~1分もあれば終わりますので、複雑な機械学習や最適化アルゴリズムは必要ありません。

例えば、以下のようなパターンの組み合わせをすべて試したいとしましょう。


# 4種類のパラメーター
paramA = [ 10,20,30,40,50 ]
paramB = [ 10,20,30,40,50 ]
paramC = [ "A" , "B" ]
paramD = [ 1,2,3,4,5,6,7,8,9,10 ]

この場合、以下のようにfor文を書くことで全パターンの組み合わせを試すことができます。


# for文で総当たりのパターンを記述
for a in paramA:
	for b in paramB:
		for c in paramC:
			for d in paramD:
				
				# 各組合せで実行したい処理


パラメーターAのfor文処理をするなかで、1つの要素についてさらにパラメーターBのfor文処理をし、パラメーターBのfor文処理のなかの1つの要素に対してさらにパラメーターCのfor文処理をし….という入れ子構造にしていくわけですね。

これを実行することで、5 × 5 × 2 × 10 = 500通りの組み合わせについて、全てのパターンでバックテストを実行することができます。

シンプルな記述方法

なお、pythonでは上記のコードをもっとシンプルにして以下のように書くことができます。


combinations = [(a, b, c, d)
	for a in paramA
	for b in paramB
	for c in paramC
	for d in paramD]

for a,b,c,d in combinations:
	# 各組合せで実行したい処理

試すパラメーターの組み合わせ

なお、今回のドンチャンブレイクアウトのパラメーター最適化では、以下の組み合わせを総当たりで全て試してみることにします。

1)使用する時間軸
⇒ 30分足/1時間足/2時間足/6時間足/12時間足/1日足

2)上値ブレイクアウトの判定期間
⇒ 10/15/20/25/30/35/40/45

3)下値ブレイクアウトの判定期間
⇒  10/15/20/25/30/35/40/45

4)判定に使用する価格
⇒  高値・安値/終値・終値

時間足が6通り、判定期間が上値ブレイクアウト・下値ブレイクアウトでそれぞれ8通りずつ、判定に使用する価格が2通りで、合計 768通りのパターンをテストしてみましょう。

これをfor文の総当たりでテストするためには、以下のように書けばOKです。


# バックテストのパラメーター設定

chart_sec_list  = [ 1800, 3600, 7200, 21600, 43200, 86400 ] # 時間足
buy_term_list   = [ 10,15,20,25,30,35,40,45 ] # 上値ブレイクの判断期間
sell_term_list  = [ 10,15,20,25,30,35,40,45 ] # 下値ブレイクの判断期間
judge_price_list = [
	{"BUY":"close_price","SELL":"close_price"}, # 終値/終値 と 高値/安値
	{"BUY":"high_price","SELL":"low_price"}
]

# for文の記述方法

combinations = [(chart_sec, buy_term, sell_term, judge_price)
	for chart_sec in chart_sec_list
	for buy_term  in buy_term_list
	for sell_term in sell_term_list
	for judge_price in judge_price_list]

for chart_sec, buy_term, sell_term,judge_price in combinations:
	# 各回のバックテスト処理を記述
	# (今までの記事で作成したもの)

これで総当たりのfor文の準備は完了です!

最大化したい指標を決める

何のためにパラメーター最適化を実施するのかといえば、リターンを極限まで高くしたいからですよね。なので「総利益が最大になるようなパラメーターを探す」というのも1つの方法です。

しかし自動売買BOTの評価を最終損益だけで判断するのは不十分です。より厳密にいえば、私たちが探している理想の売買ロジックは、「できるだけリスクを抑えながら、できるだけ大きなリターンを得ること」にあるはずです。そのため、リスクとリターンの比率を1つの数字で表せるような指標を「最大化したい数字」に設定すべきです。

このような指標には、シャープレシオ、MARレシオなど、いくつかの指標がありますが、ここでは一番オーソドックスな「プロフィットファクター」を使うことにします。

プロフィットファクター(PF)とは

同じ最終利益100万円でも、「利益110万円 / 損失10万円」の売買システムと、「利益200万円 / 損失100万円」の売買システムとでは、かなり意味が違ってきます。

前者は、損失が利益のおよそ1/10で済んでいるのに対し、後者は、なんと利益の半分もの損失を出してしまっています。当然、前者の方が安定した売買システムで、後者のほうがより不安定な(リスクの高い)売買システムということになります。

このような、「 総利益 / 総損失 」で表される指標のことをプロフィットファクター(PF)といいます。PFが大きければ大きいほど、そのシステムは安定していてリスクが小さいと評価できます。

プロフィットファクターの計算コード

今まで作成してきたドンチャン・ブレイクアウトのバックテスト検証コードに、プロフィットファクターを計算するコードを追加しておきましょう。

前回のpandasの記事を読んで練習した方なら書けると思います!

▽ プロフィットファクターの計算式 (総利益 ÷ 総損失)

PF = round( -1 * (records[records.Profit>0].Profit.sum() / records[records.Profit<0].Profit.sum()) ,2)

さて、それでは実際のコードを作っていきましょう!

2.pythonコード

いつものように、まず最初に全コードの内容と実行結果を示します。
その後コードの書き方を解説していきます。


import requests
from datetime import datetime
import time
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
import csv

#-----設定項目

wait = 0            # ループの待機時間
lot = 1             # BTCの注文枚数
slippage = 0.001    # 手数料・スリッページ


# バックテストのパラメーター設定
#---------------------------------------------------------------------------------------------
chart_sec_list  = [ 1800, 3600, 7200, 21600, 43200, 86400 ] # テストに使う時間軸
buy_term_list   = [ 10,15,20,25,30,35,40,45 ] # テストに使う上値ブレイクアウトの期間
sell_term_list  = [ 10,15,20,25,30,35,40,45 ] # テストに使う下値ブレイクアウトの期間
judge_price_list = [
	{"BUY":"close_price","SELL":"close_price"}, # ブレイクアウト判定に終値を使用
	{"BUY":"high_price","SELL":"low_price"}     # ブレイクアウト判定に高値・安値を使用
]
#---------------------------------------------------------------------------------------------



# CryptowatchのAPIを使用する関数
def get_price(min, before=0, after=0):
	price = []
	params = {"periods" : min }
	if before != 0:
		params["before"] = before
	if after != 0:
		params["after"] = after

	response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc",params)
	data = response.json()
	
	if data["result"][str(min)] is not None:
		for i in data["result"][str(min)]:
			if i[1] != 0 and i[2] != 0 and i[3] != 0 and i[4] != 0:
				price.append({ "close_time" : i[0],
					"close_time_dt" : datetime.fromtimestamp(i[0]).strftime('%Y/%m/%d %H:%M'),
					"open_price" : i[1],
					"high_price" : i[2],
					"low_price" : i[3],
					"close_price": i[4] })
		return price
		
	else:
		print("データが存在しません")
		return None


# ドンチャンブレイクを判定する関数
def donchian( data,last_data ):
	
	highest = max(i["high_price"] for i in last_data[ (-1* buy_term): ])
	if data[ judge_price["BUY"] ] > highest:
		return {"side":"BUY","price":highest}
	
	lowest = min(i["low_price"] for i in last_data[ (-1* sell_term): ])
	if data[ judge_price["SELL"] ] < lowest:
		return {"side":"SELL","price":lowest}
	
	return {"side" : None , "price":0}


# ドンチャンブレイクを判定してエントリー注文を出す関数
def entry_signal( data,last_data,flag ):
	signal = donchian( data,last_data )
	if signal["side"] == "BUY":

		# ここに買い注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "BUY"
		flag["order"]["price"] = round(data["close_price"] * lot)

	if signal["side"] == "SELL":

		# ここに売り注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "SELL"
		flag["order"]["price"] = round(data["close_price"] * lot)

	return flag



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


# 手仕舞いのシグナルが出たら決済の成行注文 + ドテン注文 を出す関数
def close_position( data,last_data,flag ):
	
	flag["position"]["count"] += 1
	signal = donchian( data,last_data )
	
	if flag["position"]["side"] == "BUY":
		if signal["side"] == "SELL":
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			# ここに売り注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"
			flag["order"]["price"] = round(data["close_price"] * lot)
			

	if flag["position"]["side"] == "SELL":
		if signal["side"] == "BUY":
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			# ここに買い注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "BUY"
			flag["order"]["price"] = round(data["close_price"] * lot)
			
	return flag


# 各トレードのパフォーマンスを記録する関数
def records(flag,data):
	
	# 取引手数料等の計算
	entry_price = flag["position"]["price"]
	exit_price = round(data["close_price"] * lot)
	trade_cost = round( exit_price * slippage )
	flag["records"]["slippage"].append(trade_cost)
	
	# 手仕舞った日時と保有期間を記録
	flag["records"]["date"].append(data["close_time_dt"])
	flag["records"]["holding-periods"].append( flag["position"]["count"] )
	
	# 値幅の計算
	buy_profit = exit_price - entry_price - trade_cost
	sell_profit = entry_price - exit_price - trade_cost
	
	# 利益が出てるかの計算
	if flag["position"]["side"] == "BUY":
		flag["records"]["side"].append( "BUY" )
		flag["records"]["profit"].append( buy_profit )
		flag["records"]["return"].append( round( buy_profit / entry_price * 100, 4 ))
	
	if flag["position"]["side"] == "SELL":
		flag["records"]["side"].append( "SELL" )
		flag["records"]["profit"].append( sell_profit )
		flag["records"]["return"].append( round( sell_profit / entry_price * 100, 4 ))
	
	return flag

# バックテストの集計用の関数
def backtest(flag):
	
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Gross.cummax().subtract(records.Gross)
	records["DrawdownRate"] = round(records.Drawdown / records.Gross.cummax() * 100,1)

	print("バックテストの結果")
	print("-----------------------------------")
	print("総合の成績")
	print("-----------------------------------")
	print("全トレード数       :  {}回".format(len(records) ))
	print("勝率               :  {}%".format(round(len(records[records.Profit>0]) / len(records) * 100,1)))
	print("平均リターン       :  {}%".format(round(records.Rate.mean(),2)))
	print("平均保有期間       :  {}足分".format( round(records.Periods.mean(),1) ))
	print("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	print("最大ドローダウン   :  {0}円 / {1}%".format(-1 * records.Drawdown.max(), -1 * records.DrawdownRate.loc[records.Drawdown.idxmax()]  ))
	print("利益合計           :  {}円".format( records[records.Profit>0].Profit.sum() ))
	print("損失合計           :  {}円".format( records[records.Profit<0].Profit.sum() ))
	print("")
	print("最終損益           :  {}円".format( records.Profit.sum() ))
	print("手数料合計         :  {}円".format( -1 * records.Slippage.sum() ))
	
	# バックテストの計算結果を返す
	result = {
		"トレード回数"     : len(records),
		"勝率"             : round(len(records[records.Profit>0]) / len(records) * 100,1),
		"平均リターン"     : round(records.Rate.mean(),2),
		"最大ドローダウン" : -1 * records.Drawdown.max(),
		"最終損益"         : records.Profit.sum(),
		"プロフィットファクタ―" : round( -1 * (records[records.Profit>0].Profit.sum() / records[records.Profit<0].Profit.sum()) ,2)
	}
	
	return result
	


# ここからメイン処理

# バックテストに必要な時間軸のチャートをすべて取得
price_list = {}
for chart_sec in chart_sec_list:
	price_list[ chart_sec ] = get_price(chart_sec,after=1451606400)
	print("-----{}分軸の価格データをCryptowatchから取得中-----".format( int(chart_sec/60) ))
	time.sleep(10)

# テストごとの各パラメーターの組み合わせと結果を記録する配列を準備
param_buy_term  = []
param_sell_term = []
param_chart_sec = []
param_judge_price = []

result_count = []
result_winRate = []
result_returnRate = []
result_drawdown = []
result_profitFactor = []
result_gross = []

# 総当たりのためのfor文の準備
combinations = [(chart_sec, buy_term, sell_term, judge_price)
	for chart_sec in chart_sec_list
	for buy_term  in buy_term_list
	for sell_term in sell_term_list
	for judge_price in judge_price_list]

for chart_sec, buy_term, sell_term,judge_price in combinations:
	
	price = price_list[ chart_sec ]
	last_data = []
	i = 0
	
	# フラッグ変数の初期化
	flag = {
		"order":{
			"exist" : False,
			"side" : "",
			"price" : 0,
			"count" : 0
		},
		"position":{
			"exist" : False,
			"side" : "",
			"price": 0,
			"count":0
		},
		"records":{
			"date":[],
			"profit":[],
			"return":[],
			"side":[],
			"holding-periods":[],
			"slippage":[]
		}
	}
	
	while i < len(price):
		
		# ドンチャンの判定に使う期間分の安値・高値データを準備する
		if len(last_data) < buy_term or len(last_data) < sell_term:
			last_data.append(price[i])
			time.sleep(wait)
			i += 1
			continue
		
		data = price[i]
		
		if flag["order"]["exist"]:
			flag = check_order( flag )
		elif flag["position"]["exist"]:
			flag = close_position( data,last_data,flag )
		else:
			flag = entry_signal( data,last_data,flag )
		
		last_data.append( data )
		i += 1
		time.sleep(wait)


	print("--------------------------")
	print("テスト期間   :")
	print("開始時点     : " + str(price[0]["close_time_dt"]))
	print("終了時点     : " + str(price[-1]["close_time_dt"]))
	print("時間軸       : " + str(int(chart_sec/60)) + "分足で検証")
	print("パラメータ1 : " + str(buy_term)  + "期間 / 買い" )
	print("パラメータ2 : " + str(sell_term) + "期間 / 売り" )
	print(str(len(price)) + "件のローソク足データで検証")
	print("--------------------------")

	
	result = backtest( flag )
	
	
	# 今回のループで使ったパラメータの組み合わせを配列に記録する
	param_buy_term.append( buy_term )
	param_sell_term.append( sell_term )
	param_chart_sec.append( chart_sec )
	if judge_price["BUY"] == "high_price":
		param_judge_price.append( "高値/安値" )
	else:
		param_judge_price.append( "終値/終値" )
	
	
	# 今回のループのバックテスト結果を配列に記録する
	result_count.append( result["トレード回数"] )
	result_winRate.append( result["勝率"] )
	result_returnRate.append( result["平均リターン"] )
	result_drawdown.append( result["最大ドローダウン"] )
	result_profitFactor.append( result["プロフィットファクタ―"] )
	result_gross.append( result["最終損益"] )
	
	

# 全てのパラメータによるバックテスト結果をPandasで1つの表にする
df = pd.DataFrame({
	"時間軸"        :  param_chart_sec,
	"買い期間"      :  param_buy_term,
	"売り期間"      :  param_sell_term,
	"判定基準"      :  param_judge_price,
	"トレード回数"  :  result_count,
	"勝率"          :  result_winRate,
	"平均リターン"  :  result_returnRate,
	"ドローダウン"  :  result_drawdown,
	"PF"            :  result_profitFactor,
	"最終損益"      :  result_gross
})

# 列の順番を固定する
df = df[[ "時間軸","買い期間","売り期間","判定基準","トレード回数","勝率","平均リターン","ドローダウン","PF","最終損益"  ]]

# トレード回数が100に満たない記録は消す
df.drop( df[ df["トレード回数"] < 100].index, inplace=True )

# 最終結果をcsvファイルに出力
df.to_csv("result-{}.csv".format(datetime.now().strftime("%Y-%m-%d-%H-%M")) )

実行結果

これを実行すると以下のようなCSVファイルが出力されます。

プロフィットファクター(PF)のJ列を「降順」で並び変えてみると、ドンチャン・ブレイクアウトの有望なパラメーターの組み合わせがわかります。

▽ PFで並び替え後の成績ベスト30

上記のコードではトレード回数が100回に満たなかった結果データを全て捨てているので、どのパラメーターの組み合わせも最低限のサンプル数を確保できています。上位30の成績のパラメーターをみると、ほとんどが2時間足以上の時間軸なのがわかります。

▽ PFで並び替え後の成績ワースト30

逆に最もプロフィットファクターの低かった(悪かった成績)30コをみると、短い時間軸(30分足)に集中しているのがわかります。ドンチアンブレイクアウトは、あまり短い時間軸には適さない戦略の可能性がありそうです。

(ただし時間軸によってテストに使用しているローソク足の期間が違うので、厳密には比較できない点に注意してください。)

実行結果2

次はさらに範囲を絞ってテストをしてみましょう!

今度は時間軸を1時間足・2時間足だけに絞り、上値ブレイクアウトの期間・下値ブレイクアウトの期間を10~55期間の範囲にして1期間刻みでテストしています。以下のようにパラメーターを設定すればOKですね。

# バックテストのパラメーター設定

chart_sec_list  = [ 3600,7200 ]
buy_term_list   = np.arange(10,56) # 10~55までの連番の配列を作る
sell_term_list  = np.arange(10,56)

この条件でテストしてみると以下のようなCSVファイルが出力されます。

・CSV結果出力ファイル1
・CSV結果出力ファイル2

ファイル1は先ほどと同じくトレード回数が100回に満たなかったテスト結果を削除したもの、ファイル2は削除せずに全ての結果を残したものです。今回はファイル2を使ってみましょう。

▽ PFで並び替え後の成績ベスト30(1時間足)

1時間足の上位の成績を見ると、明らかに上値ブレイクアウト(買い)の期間は40前後、下値ブレイクアウト(売り)の期間は20前後が良さそう、という傾向が見えます。また判定基準は「終値」が上位を独占していますね。

▽ PFで並び替え後の成績ベスト30(2時間足)

2時間足の上位の成績を見ると、上値ブレイクアウト(買い)の期間は50以上、下値ブレイクアウト(売り)の期間は20~30期間がいいようですね。またやはり「終値」でブレイクアウトの判定をした方が、トレード回数は減るものの成績が良くなる傾向にありそうです。

より詳しく分析したい方は、上位のパラメーターを前回までの記事で学習した「月別集計」「資産曲線」などを使って調べてみるといいでしょう。

Pythonコードの解説

さて、それでは新しく変更した部分のpythonコードを解説しておきましょう!

基本的なコードは前回まで勉強した内容とほとんど変わっていないことに気付いたと思います。要するに、今まで作成してきた「While文でローソク足を回してバックテストし、最終成績をbacktest() で集計するコード」を、まるまる上記で説明した「総当たりのfor文」の中に入れるだけですね。

図にすると以下のような感じです。

⇒ 各パラメーターの組み合わせでループする ⇒ ローソク足のデータを1本ずつループする ⇒ 全てのトレード成績を配列に記録する ⇒ トレード結果の配列を集計してpandasで表にする ⇒ 各バックテストの結果を計算して配列に記録する⇒ バックテスト結果の配列を集計してpandasで表にする ⇒ 最終結果の表をCSVに出力する

こののような入れ子構造になっている点だけ混乱しなければ、上記のコードで難しい箇所はなかったと思います。

今までの記事では、バックテスト集計用の関数 backtest() は、単に集計・計算した結果を表示(print)するだけでしたが、今回のパラメーター最適化のコードでは、各バックテストの結果を返すように変更する必要があります。

そのためバックテスト集計用の関数に以下の部分を足しています。

# バックテストの計算結果を返す
result = {
	"トレード回数"     : len(records),
	"勝率"             : round(len(records[records.Profit>0]) / len(records) * 100,1),
	"平均リターン"     : round(records.Rate.mean(),2),
	"最大ドローダウン" : -1 * records.Drawdown.max(),
	"最終損益"         : records.Profit.sum(),
	"プロフィットファクタ―" : round( -1 * (records[records.Profit>0].Profit.sum() / records[records.Profit<0].Profit.sum()) ,2)
}

return result

各バックテストの結果を配列に記録して、それを最後にpandasで1つの表にする方法は、前回までの記事の「各トレード結果を配列に記録して最後に集計する方法」と全く同じなので、説明は省きます。

drop() で不要な行を削除する

唯一新しく登場したのは、最後のdrop()の箇所でしょう。

今回のコードでは、トレード回数の少ないパラメーターの組み合わせを除外できるようにしています。それが以下の部分のコードです。

# トレード回数が100に満たない記録は消す
df.drop( df[ df["トレード回数"] < 100].index, inplace=True )

表名.drop()は、行のindexを指定することで、その行を表から削除することのできる関数です。これを使って以下のような仕組みで、トレード回数の少なすぎるバックテスト結果を削除しています。


# トレード回数が100未満のデータを抽出
df[df["トレード回数"] < 100]

# トレード回数が100未満のデータの行のindexを取得
df[df["トレード回数"] < 100].index

# トレード回数が100未満のデータを行ごと削除
df.drop( df[df["トレード回数"] < 100].index )

なお、inplce=Trueは、現在の表データに結果をそのまま上書きする、という意味です。以上でコードの解説はおしまいです!

まとめ

この章の前の方の記事でも解説したように、このようなパラメーター調整には常にカーブフィッティングの問題がつきまといます。以下のような点に注意しておきましょう。

(1)パラメーターの数を増やし過ぎないようにする
(2)おおまかな傾向を捉えることを目的にする
(3)トレード数(サンプル)が少なくならないようにする

一般論としていえば、今回のドンチャン・チャネル・ブレイクアウトのように広範なパラメーターのどの値を使ってもプラスの期待値が出る、という場合で、おおまかな傾向を捉える目的(例えば、20期間あたりが一番良さそう)でパラメーターを最適化するのは問題ありません。

一番問題があるのは、例えば、「6」という数字を使えば利益が出る、「8」という数字を使うとマイナスに転落する、「13」という数字を使えばプラスになる、といった全く連続性も傾向もないパラメーターを恣意的に調整することです。これをすると、過去データでしか利益の出せないパラメーター調整になります。

次回

今回の記事で、いったんバックテスト編はおしまいです。

ドンチャン・チャネルブレイクアウトで買い/売りの期間を別々に設定できるようにする

次回の記事「pythonで最適なパラメーターを自動的に探索する」での練習にあたって、まず先に以下のようなパラメーターを自由に設定できるようにドンチャン・ブレイクアウトのコードを改良しておきましょう。

設定可能なパラメーター

1)X分足の時間軸を使う
2)n期間の最高値のブレイクアウトで買う
3)m期間の最安値のブレイクアウトで売る
4)ブレイクアウトの判定に、高値/安値 or 終値/終値 を使う

今回の記事では、これらのパラメーターを設定できるようにコードを修正する方法を解説し、最後に1時間足で買い(30期間)、売り(20期間)、判定基準(終値)でのバックテスト結果を見てみましょう。

Pythonコード

先に改良版のコード全体を記します。


import requests
from datetime import datetime
import time
import matplotlib.pyplot as plt
import pandas as pd


#--------設定項目--------

chart_sec = 3600           #  1時間足を使用
buy_term =  30             #  買いエントリーのブレイク期間の設定
sell_term = 30             #  売りエントリーのブレイク期間の設定
judge_price={
  "BUY" : "high_price",    #  ブレイク判断 高値(high_price)か終値(close_price)を使用
  "SELL": "low_price"      #  ブレイク判断 安値 (low_price)か終値(close_price)を使用
}
wait = 0                   #  ループの待機時間
lot = 1                    #  BTCの注文枚数
slippage = 0.001           #  手数料・スリッページ

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


# CryptowatchのAPIを使用する関数
def get_price(min, before=0, after=0):
	price = []
	params = {"periods" : min }
	if before != 0:
		params["before"] = before
	if after != 0:
		params["after"] = after

	response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc",params)
	data = response.json()
	
	if data["result"][str(min)] is not None:
		for i in data["result"][str(min)]:
			if i[1] != 0 and i[2] != 0 and i[3] != 0 and i[4] != 0:
				price.append({ "close_time" : i[0],
					"close_time_dt" : datetime.fromtimestamp(i[0]).strftime('%Y/%m/%d %H:%M'),
					"open_price" : i[1],
					"high_price" : i[2],
					"low_price" : i[3],
					"close_price": i[4] })
		return price
		
	else:
		print("データが存在しません")
		return None


# 時間と高値・安値をログに記録する関数
def log_price( data,flag ):
	log =  "時間: " + datetime.fromtimestamp(data["close_time"]).strftime('%Y/%m/%d %H:%M') + " 高値: " + str(data["high_price"]) + " 安値: " + str(data["low_price"]) + " 終値: " + str(data["close_price"]) + "\n"
	flag["records"]["log"].append(log)
	return flag


# ドンチャンブレイクを判定する関数
def donchian( data,last_data ):
	
	highest = max(i["high_price"] for i in last_data[ (-1* buy_term): ])
	if data[ judge_price["BUY"] ] > highest:
		return {"side":"BUY","price":highest}
	
	lowest = min(i["low_price"] for i in last_data[ (-1* sell_term): ])
	if data[ judge_price["SELL"] ] < lowest:
		return {"side":"SELL","price":lowest}
	
	return {"side" : None , "price":0}


# ドンチャンブレイクを判定してエントリー注文を出す関数
def entry_signal( data,last_data,flag ):
	signal = donchian( data,last_data )
	if signal["side"] == "BUY":
		flag["records"]["log"].append("過去{0}足の最高値{1}円を、直近の価格が{2}円でブレイクしました\n".format(buy_term,signal["price"],data[judge_price["BUY"]]))
		flag["records"]["log"].append(str(data["close_price"]) + "円で買いの指値注文を出します\n")

		# ここに買い注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "BUY"
		flag["order"]["price"] = round(data["close_price"] * lot)

	if signal["side"] == "SELL":
		flag["records"]["log"].append("過去{0}足の最安値{1}円を、直近の価格が{2}円でブレイクしました\n".format(sell_term,signal["price"],data[judge_price["SELL"]]))
		flag["records"]["log"].append(str(data["close_price"]) + "円で売りの指値注文を出します\n")

		# ここに売り注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "SELL"
		flag["order"]["price"] = round(data["close_price"] * lot)

	return flag



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


# 手仕舞いのシグナルが出たら決済の成行注文 + ドテン注文 を出す関数
def close_position( data,last_data,flag ):
	
	flag["position"]["count"] += 1
	signal = donchian( data,last_data )
	
	if flag["position"]["side"] == "BUY":
		if signal["side"] == "SELL":
			flag["records"]["log"].append("過去{0}足の最安値{1}円を、直近の価格が{2}円でブレイクしました\n".format(sell_term,signal["price"],data[judge_price["SELL"]]))
			flag["records"]["log"].append(str(data["close_price"]) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			flag["records"]["log"].append("さらに" + str(data["close_price"]) + "円で売りの指値注文を入れてドテンします\n")
			
			# ここに売り注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"
			flag["order"]["price"] = round(data["close_price"] * lot)
			

	if flag["position"]["side"] == "SELL":
		if signal["side"] == "BUY":
			flag["records"]["log"].append("過去{0}足の最高値{1}円を、直近の価格が{2}円でブレイクしました\n".format(buy_term,signal["price"],data[judge_price["BUY"]]))
			flag["records"]["log"].append(str(data["close_price"]) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			flag["records"]["log"].append("さらに" + str(data["close_price"]) + "円で買いの指値注文を入れてドテンします\n")
			
			# ここに買い注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "BUY"
			flag["order"]["price"] = round(data["close_price"] * lot)
			
	return flag


# 各トレードのパフォーマンスを記録する関数
def records(flag,data):
	
	# 取引手数料等の計算
	entry_price = flag["position"]["price"]
	exit_price = round(data["close_price"] * lot)
	trade_cost = round( exit_price * slippage )
	
	log = "スリッページ・手数料として " + str(trade_cost) + "円を考慮します\n"
	flag["records"]["log"].append(log)
	flag["records"]["slippage"].append(trade_cost)
	
	# 手仕舞った日時と保有期間を記録
	flag["records"]["date"].append(data["close_time_dt"])
	flag["records"]["holding-periods"].append( flag["position"]["count"] )
	
	# 値幅の計算
	buy_profit = exit_price - entry_price - trade_cost
	sell_profit = entry_price - exit_price - trade_cost
	
	# 利益が出てるかの計算
	if flag["position"]["side"] == "BUY":
		flag["records"]["side"].append( "BUY" )
		flag["records"]["profit"].append( buy_profit )
		flag["records"]["return"].append( round( buy_profit / entry_price * 100, 4 ))
		if buy_profit  > 0:
			log = str(buy_profit) + "円の利益です\n"
			flag["records"]["log"].append(log)
		else:
			log = str(buy_profit) + "円の損失です\n"
			flag["records"]["log"].append(log)
	
	if flag["position"]["side"] == "SELL":
		flag["records"]["side"].append( "SELL" )
		flag["records"]["profit"].append( sell_profit )
		flag["records"]["return"].append( round( sell_profit / entry_price * 100, 4 ))
		if sell_profit > 0:
			log = str(sell_profit) + "円の利益です\n"
			flag["records"]["log"].append(log)
		else:
			log = str(sell_profit) + "円の損失です\n"
			flag["records"]["log"].append(log)
	
	return flag

# バックテストの集計用の関数
def backtest(flag):
	
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Gross.cummax().subtract(records.Gross)
	records["DrawdownRate"] = round(records.Drawdown / records.Gross.cummax() * 100,1)	

	# 買いエントリーと売りエントリーだけをそれぞれ抽出する
	buy_records = records[records.Side.isin(["BUY"])]
	sell_records = records[records.Side.isin(["SELL"])]
	
	# 月別のデータを集計する
	records["月別集計"] = pd.to_datetime( records.Date.apply(lambda x: x.strftime('%Y/%m')))
	grouped = records.groupby("月別集計")
	
	month_records = pd.DataFrame({
		"Number"   :  grouped.Profit.count(),
		"Gross"    :  grouped.Profit.sum(),
		"Rate"     :  round(grouped.Rate.mean(),2),
		"Drawdown" :  grouped.Drawdown.max(),
		"Periods"  :  grouped.Periods.mean()
		})
	
	print("バックテストの結果")
	print("-----------------------------------")
	print("買いエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(buy_records) ))
	print("勝率               :  {}%".format(round(len(buy_records[buy_records.Profit>0]) / len(buy_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(buy_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( buy_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(buy_records.Periods.mean(),1) ))
	
	print("-----------------------------------")
	print("売りエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(sell_records) ))
	print("勝率               :  {}%".format(round(len(sell_records[sell_records.Profit>0]) / len(sell_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(sell_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( sell_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(sell_records.Periods.mean(),1) ))
	
	print("-----------------------------------")
	print("総合の成績")
	print("-----------------------------------")
	print("全トレード数       :  {}回".format(len(records) ))
	print("勝率               :  {}%".format(round(len(records[records.Profit>0]) / len(records) * 100,1)))
	print("平均リターン       :  {}%".format(round(records.Rate.mean(),2)))
	print("平均保有期間       :  {}足分".format( round(records.Periods.mean(),1) ))
	print("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	print("最大ドローダウン   :  {0}円 / {1}%".format(-1 * records.Drawdown.max(), -1 * records.DrawdownRate.loc[records.Drawdown.idxmax()]  ))
	print("利益合計           :  {}円".format( records[records.Profit>0].Profit.sum() ))
	print("損失合計           :  {}円".format( records[records.Profit<0].Profit.sum() ))
	print("")
	print("最終損益           :  {}円".format( records.Profit.sum() ))
	print("手数料合計         :  {}円".format( -1 * records.Slippage.sum() ))
	
	print("-----------------------------------")
	print("月別の成績")
	
	for index , row in month_records.iterrows():
		print("-----------------------------------")
		print( "{0}年{1}月の成績".format( index.year, index.month ) )
		print("-----------------------------------")
		print("トレード数         :  {}回".format( row.Number.astype(int) ))
		print("月間損益           :  {}円".format( row.Gross.astype(int) ))
		print("平均リターン       :  {}%".format( row.Rate ))
		print("月間ドローダウン   :  {}円".format( -1 * row.Drawdown.astype(int) ))

	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])

	# 損益曲線をプロット
	plt.plot( records.Date, records.Gross )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	plt.show()
	


# ここからメイン処理

# 価格チャートを取得
price = get_price(chart_sec,after=1451606400)

flag = {
	"order":{
		"exist" : False,
		"side" : "",
		"price" : 0,
		"count" : 0
	},
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"count":0
	},
	"records":{
		"date":[],
		"profit":[],
		"return":[],
		"side":[],
		"holding-periods":[],
		"slippage":[],
		"log":[]
	}
}


last_data = []
i = 0
while i < len(price):

	# ドンチャンの判定に使う期間分の安値・高値データを準備する
	if len(last_data) < buy_term or len(last_data) < sell_term:
		last_data.append(price[i])
		flag = log_price(price[i],flag)
		time.sleep(wait)
		i += 1
		continue
	
	data = price[i]
	flag = log_price(data,flag)
	
	
	if flag["order"]["exist"]:
		flag = check_order( flag )
	elif flag["position"]["exist"]:
		flag = close_position( data,last_data,flag )
	else:
		flag = entry_signal( data,last_data,flag )
	
	last_data.append( data )
	i += 1
	time.sleep(wait)


print("--------------------------")
print("テスト期間:")
print("開始時点 : " + str(price[0]["close_time_dt"]))
print("終了時点 : " + str(price[-1]["close_time_dt"]))
print(str(len(price)) + "件のローソク足データで検証")
print("--------------------------")

backtest(flag)

これでドンチャン・ブレイクアウトの自動売買ロジックに、上値のブレイクアウトと下値のブレイクアウトとで異なる期間を設定できるようになりました。

またブレイクアウトの判定基準について、高値・安値を使うことも、終値を使うこともできるようになりました。

コードの解説

今までの「最もシンプルなドンチャン・ブレイクアウト」では、買い・売りともに同じ期間(例えば30期間)を使っていました。そのため、ブレイクアウトを判定する際に、比較に使うための過去のローソク足データは、配列に30個ぴったり用意しておけばOKでした。

しかし買い・売りで別々の期間を設定する場合は、そうはいきません。そこで、ドンチャンブレイクアウトを判定するための関数を以下のように改良します。

# ドンチャンブレイクを判定する関数
def donchian( data,last_data ):
	
	highest = max(i["high_price"] for i in last_data[ (-1* buy_term): ])
	if data[ judge_price["BUY"] ] > highest:
		return {"side":"BUY","price":highest}
	
	lowest = min(i["low_price"] for i in last_data[ (-1* sell_term): ])
	if data[ judge_price["SELL"] ] < lowest:
		return {"side":"SELL","price":lowest}
	
	return {"side" : None , "price":0}

ここでは、過去のローソク足データ(last_data)を30個丁度にキープするのではなく、どんどん溜めていって、後ろから欲しい数だけを取り出すようにしています。

例えば、上値ブレイクアウトの基準が30期間、下値ブレイクアウトの基準が20期間であれば、買いエントリーの判定では last_data の後ろから30コを取り出し、売りエントリーの判定では last_data の後ろから20コを取り出すようにしています。

ここで新しく使っているのが、スライス(:)という記述方法です。

スライスとは

スライスは、配列データの範囲を指定したり、pandasの表データで行や列の範囲を指定するときに、非常に便利な記述方法です。以下のように書くことで、配列や表(行・列)の範囲を指定することができます。

[2:5] 3番目~5番目まで
[:5]   先頭~5番目まで
[3:]   4番目~最後まで
[-3:] 後ろから3番目~最後まで

プログラミングの世界では、先頭は0番目から数えるので、指定した数字から1つズレる点に注意してください。最後はズレません。

number = [0,1,2,3,4,5,6,7]

print( number[2:5] )
[2, 3, 4]

print( number[:5] )
[0, 1, 2, 3, 4]

print( number[3:] )
[3, 4, 5, 6, 7]

print( number[-3:] )
[5, 6, 7]

そのため、上値ブレイクアウトの判定に必要な期間分のローソク足データは、以下のように指定することができます。

last_data[ (-1* buy_term): ]

この知識を使うと、ブレイクアウトの判定に必要な期間のデータを取り出しその中から最大値を抽出する、というコードを以下の1行で書くことができます。

highest = max(i["high_price"] for i in last_data[ (-1* buy_term): ])

2.ブレイクアウトの判定基準に「終値」を使えるようにする

これは簡単ですね。
変更前のコードで、data["high_price"] と指定していた部分を変数にすればいいだけです。

# 変更前
if data["high_price"] > highest:
	return {"side":"BUY","price":highest}

# 変更後
#--------設定項目--------
judge_price={
  "BUY" : "high_price", # high_price か close_price を指定
  "SELL": "low_price"   # low_price  か close_price を指定
}
#-----------------------

if data[ judge_price["BUY"] ] > highest:
	return {"side":"BUY","price":highest}

3.メイン処理のループ文の変更

最後にメイン処理のWhile文ループの中身を修正します。

# 変更前
last_data = []
i = 0
while i < len(price):

	# ドンチャンの判定に使う期間分の安値・高値データを準備する
	if len(last_data) < term:
		(略)
		continue

# 変更後
	# ドンチャンの判定に使う期間分の安値・高値データを準備する
	if len(last_data) < buy_term or len(last_data) < sell_term:
		(略)
		continue

売り・買いエントリーで別々の期間を使用することになるので、最初に buy_term と sell_term の両方の期間分を超えるまでデータを蓄積します。

# 変更前
	# 過去データを30個に保つために先頭を削除
	del last_data[0]

# 変更後
	(削除)

またスライスを使うことで、過去のローソク足データをぴったりn個にキープしておく必要がなくなったので、上記の行は削除しておきます。これで完成です!

あとはログファイルの出力が「直近の安値が~」「直近の高値が~」になっているので、そこは各自で修正しておいてください。(上記のコードでは修正済)

実行結果

試しに1時間足で、買いエントリーは30期間の最高値ブレイクアウト、売りエントリーは20期間の最安値ブレイクアウト、各ブレイクアウトの判定には終値を使う、という場合の成績を見てみましょう。

# パラメーター設定
chart_sec = 3600
buy_term =  30
sell_term = 20
judge_price={
  "BUY" : "close_price",
  "SELL": "close_price"
}

すると以下のようになります。

損益曲線

成績

--------------------------
テスト期間:
開始時点 : 2017/08/15 01:00
終了時点 : 2018/04/22 08:00
6000件のローソク足データで検証
--------------------------
バックテストの結果
-----------------------------------
買いエントリの成績
-----------------------------------
トレード回数       :  40回
勝率               :  52.5%
平均リターン       :  4.39%
総損益             :  1628677円
平均保有期間       :  70.1足分
-----------------------------------
売りエントリの成績
-----------------------------------
トレード回数       :  40回
勝率               :  52.5%
平均リターン       :  1.77%
総損益             :  1223424円
平均保有期間       :  74.7足分
-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  80回
勝率               :  52.5%
平均リターン       :  3.08%
平均保有期間       :  72.4足分

最大の勝ちトレード :  545284円
最大の負けトレード :  -220610円
最大ドローダウン   :  -381395円 / -13.2%
利益合計           :  4905987円
損失合計           :  -2053886円

最終損益           :  2852101円
手数料合計         :  -84811円
-----------------------------------
月別の成績
-----------------------------------
2017年8月の成績
-----------------------------------
トレード数         :  4回
月間損益           :  -27411円
平均リターン       :  -1.34%
月間ドローダウン   :  -13500円
-----------------------------------
2017年9月の成績
-----------------------------------
トレード数         :  10回
月間損益           :  88980円
平均リターン       :  1.96%
月間ドローダウン   :  -20785円
-----------------------------------
2017年10月の成績
-----------------------------------
トレード数         :  10回
月間損益           :  86498円
平均リターン       :  2.21%
月間ドローダウン   :  -92319円
-----------------------------------
2017年11月の成績
-----------------------------------
トレード数         :  9回
月間損益           :  375451円
平均リターン       :  5.46%
月間ドローダウン   :  -114141円
-----------------------------------
2017年12月の成績
-----------------------------------
トレード数         :  6回
月間損益           :  988615円
平均リターン       :  8.9%
月間ドローダウン   :  -220610円
-----------------------------------
2018年1月の成績
-----------------------------------
トレード数         :  14回
月間損益           :  425674円
平均リターン       :  1.03%
月間ドローダウン   :  -381395円
-----------------------------------
2018年2月の成績
-----------------------------------
トレード数         :  11回
月間損益           :  374125円
平均リターン       :  3.46%
月間ドローダウン   :  -316547円
-----------------------------------
2018年3月の成績
-----------------------------------
トレード数         :  9回
月間損益           :  305010円
平均リターン       :  3.1%
月間ドローダウン   :  -327540円
-----------------------------------
2018年4月の成績
-----------------------------------
トレード数         :  7回
月間損益           :  235159円
平均リターン       :  3.93%
月間ドローダウン   :  -38818円

※ ここでの月間ドローダウンは、その月に経験している継続中の最大ドローダウンのことです。その月だけのドローダウンを計算しているわけではありません。

このように、同じドンチャン・ブレイクアウトのロジックでも、買い判定の期間、売り判定の期間、ブレイクアウトの判定を高値(安値)でするか、終値でするか、などの設定によってかなり異なる結果になります。

しかしどのようなパラメーター設定が最適なのかを手作業で探すのは困難です。以下のようなパラメーター設定だけでも、その組み合わせの数は膨大だからです。

最適なパラメーターの組み合わせ

1)使用する時間軸
⇒ 15分足/30分足/1時間足/2時間足/6時間足

2)上値ブレイクアウトの判定期間
⇒ 10/15/20/25/30/35/40/45

3)下値ブレイクアウトの判定期間
⇒  10/15/20/25/30/35/40/45

4)判定に使用する価格
⇒  高値・安値/終値・終値

上記の組み合わせを試すだけでも、そのパターンは640通りにもなります。ちょっと手作業で1つ1つ結果をテストする気にはなりませんよね(笑)

こういうときこそpythonの出番です! 次回の記事では、pythonで自動的に最適なパラメーターを探索する方法を解説します!

次回記事:一番成績のいい最適なパラメーターを自動で探索する

Pandasを使ってBTCFXの自動売買BOTの月別の成績を集計しよう!

前回の記事では、ドンチャンブレイクアウトの自動売買BOTの成績を、matplotlibを使って視覚的なグラフにする方法や、最大ドローダウンを計算する方法を説明しました。

しかし自動売買BOTの安定性を評価するためには、最終的な損益だけでなく途中の経過、つまり月別の成績を把握しておくことも必須です。

今回の記事では、これらの成績(平均リターン・ドローダウン・勝率など)を月別に集計する方法を解説します!

Pandasを使ってデータを集計しよう!

前回までのやり方の問題点

前回までの記事では、なるべくpythonのプログラミング初心者でもわかりやすいように、すべてのトレード成績を配列に記録していました。

flag変数に以下のような配列を用意して、ポジションを手仕舞うたびに各トレードの成績や指標を計算し、各配列に append していましたよね。

flag = {
	"records":{
		"buy-count": 0,
		"buy-winning" : 0,
		"buy-return":[],
		"buy-profit": [],
		"buy-holding-periods":[],
		
		"sell-count": 0,
		"sell-winning" : 0,
		"sell-return":[],
		"sell-profit":[],
		"sell-holding-periods":[],
		
		"drawdown": 0,
		"date":[],
		"gross-profit":[0],
		"slippage":[],
		"log":[]
	}
}

しかしこのやり方だと、買いエントリーと売りエントリーに分けて成績を記録しているため、違う切り口でデータを集計したいときに凄く不便です。

例えば、「売り・買いで分けずに、月別の平均リターンや勝率を検証したい」と思った場合、売り・買いそれぞれのデータを(順番がおかしくならないように)くっつけて、それを日付で分割する、というややこしい処理をしなければなりません。

新しく月別の勝率を記録する変数を用意する方法もありますが、そうするとflag変数がどんどん増えていってしまい、pythonコードがどんどん長くややこしくなります。

もっとシンプルにデータを記録しておいて、あとから欲しい数字だけを上手に集計する方法はないでしょうか?

表のような形式で成績を記録する

もし以下のような表形式で各トレードの成績を記録しておけば、どうでしょうか? Excelのシートのようなイメージですね。

日付 損益 方向 保有期間
2018-02-04 20143円 買いエントリ 6足分
2018-02-04 22121円 売りエントリ 14足分
2018-02-05 33412円 売りエントリ 13足分
2018-03-06 -2312円 買いエントリ 15足分
2018-03-08 -16125円 売りエントリ 9足分

もし、このような表データを1つの変数として保持することができ、後から以下のような計算ができれば便利です。

————————————————————–
例)
・B列の「損益」のうちC列が「買い」のものだけを合計する
・A列の日付が2018-02のものだけで、B列の「損益」の平均値を求める
・後からE列を追加してそこに左列の損益の合計値(累積和)を追加する
・B列の「損益」のうち値がプラスのもの(=勝ち数)だけをカウントする
————————————————————–

このようなことは、Excelシートだと簡単にできますよね。同じことがpythonでも出来ないでしょうか?

実は、全く同じようなことがpythonでできるライブラリがあります。それがPandasデータフレームです。

Pandasは表計算ソフトのようなもの

Pythonを勉強しはじめた初心者の方でも、「Pandas」という言葉を聞いたことがあるかもしれません。「なんだか上級者向きの難しい奴でしょ?」というイメージをお持ちの方も多いかもしれません。

しかしPandasは全く難しくありません。要するに「Excelのような表計算ソフトと同じようなものだ」と思えば、文系の方でも少し親近感が湧きますよね。

この記事では、バックテストのデータ集計に必要なpandasの使い方を、1回でほぼ全て習得することを目指します! ぜひ今回の記事でpandasの使い方をマスターしてしまいましょう!

Pandasの使い方をマスターする

1)まずは表型のデータを作ってみよう!

pandasでは、複数行・複数列にわたる表形式のデータのことを「DataFrame」といいます。まずは練習としてDataFrame型の変数を1つ作ってみましょう!

今回は、以下のような5回分のトレード成績を記録した配列を用意しました。これは先ほど表の例と全く同じものです。今回はこちらを例に説明していきます。


# 今回の練習で使う5回分のトレード成績のサンプル

Date = ["2018-02-04","2018-02-04","2018-02-05","2018-03-06","2018-03-08"]
Profit = [20143,22121,33412,-2312,-16125]
Side = ["BUY","SELL","SELL","BUY","SELL"]
Rate = [0.024,0.012,0.014,-0.022,-0.019]
Periods = [6,14,13,15,9]

まずは上記のように、「日付」「各トレードの損益」「エントリーの方向」「保有期間」など、表を作るために必要な最低限の情報を記録した配列を用意します。

「トレード回数」「ドローダウン」「総損益」「買いエントリーの勝率」など、後から表をもとに計算できる数字は、この時点で準備する必要はありません。つまりトレードするたびに記録する数字は、上記の5種類だけでOKということです。

すべてのトレードが終わったら、上記の配列をくっつけて1つのDataFrame型の表データに変換します。それが以下のコードです。

import pandas as pd

# 上の配列データをくっつけて1つの表データにする

records = pd.DataFrame({
	"Date":pd.to_datetime(Date), # 文字を日付型に変換
	"Profit":Profit,
	"Side":Side,
	"Rate":Rate,
	"Periods":Periods
})

各配列を縦向きの「列」として結合し、1つのDataFrame型の表にして records という名前の変数に入れています。

これを公式にすると、以下のような感じですね。

表型の変数名 = pd.DataFrame({
	"列名" : データ配列 ,
	"列名" : データ配列 ,
	"列名" : データ配列 ,
	"列名" : データ配列
})

日付データだけは、pd.to_datetime()を使って、テキスト型から日付型に変換しておきます。その理由は、前回の記事で解説しています。

さて、これで先ほどイメージした表データをpythonで作ることができました! 念のためにprintしてみましょう!

print( records )

以下のようになります。

▽ 上記のコードで作った配列データ

▽ 今回の練習で使う表

欲しい数字だけを集計して取り出そう!

では、次にこの表データ(records)から「欲しい数字」を取り出していきましょう。 以下、よく使うパターンをまとめて列挙していきます。

1.損益データだけを取り出す

print( records.Profit )

表名.列名で取り出す

列名が英文字の場合は、records.profit のように「表名.列名」指定できます。 列名が日本語の場合は、records[“損益”] のように指定します。

2.平均リターンを計算する

print( records.Profit.mean() )

表名.列名.mean()で列の平均値を計算する

3.損益の合計を計算する

print( records.Profit.sum() )

表名.列名.sum()で列の合計値を計算する

3.特定の行のデータを取り出す

print( records.iloc[2] )

表名.iloc[2]で2行目だけを取り出す

locは、loc[ 行名, 列名 ] のかたちで表データの範囲を指定する記述方法です。一方、ilocは、iloc[ 行番号, 列番号 ]のかたちで表データ範囲を指定する記述方法です。

これらは以下のサイトに詳しい説明があります。

参考:at/iat/loc/ilocの使い方

4.買いエントリーのデータを取り出す

print( records[records.Side.isin(["BUY"])] )

表名[ 表名.列名.isin([値]) ]でSide列が”BUY”の行だけ取り出す

5.買いエントリーの損益だけを取り出す

print( records[records.Side.isin(["BUY"])].Profit )

表名[ 表名.列名.isin([値]) ].列名でSide列が”BUY”の行のProfit列を取り出す

6.買いエントリーの平均リターンを計算する

print( records[records.Side.isin(["BUY"])].Profit.mean() )

表名[ 表名.列名.isin([値]) ].mean() でSide列が BUYの行のProfit列の平均値を計算する

7.トレード回数をカウントする

# 全トレード数をカウントする
print( len(records) )

# 買いエントリーのトレード数をカウントする
print( len( records[records.Side.isin(["BUY"])] ))

len(表名) で行数を数える / len(表名[ 表名.列名.isin([値]) ])でSide列が BUYの行の行数を数える

len( 表名 )のかわりに、表名.列名.count() と書いても構いません。

8.勝率を計算する

# 全体の勝ちトレードの数をカウントする
print( len(records[records.Profit > 0]) )

# 全体の勝率を計算する
print( len(records[records.Profit > 0]) / len(records) * 100 )

Profit列がプラスの行だけ選択する / len(表名)で勝利トレードの行数を数えて全体の行数で割る

勝率は、勝ちトレード数 / 全体のトレード数 で計算できます。そのため、Profit列が0以上の行の数を数えて、それを全体の行数で割れば、全体の勝率が計算できます。

# 買いエントリーの勝ちトレードだけを取り出す
print( records[records.Side.isin(["BUY"]) & records.Profit>0] )

# 買いエントリーの勝ちトレード数をカウントする
print( len(records[records.Side.isin(["BUY"]) & records.Profit>0]) )

# 買いエントリーの勝率を計算する
print( len(records[records.Side.isin(["BUY"]) & records.Profit>0]) / len(records.Side.isin(["BUY"])) * 100 )

同じことを、Side列が BUY の行だけに限定して行えば、買いトレードの勝率を計算できます。

9.各日付時点での総損益を新しい列に追加する

records["Gross"] = records.Profit.cumsum()

表名.列名.cumsum() でProfit列の累積和を計算する

後から新しい列を追加したいときは、records[“新しい列名”] = 式 と書くことで新しい列を作成できます。また総損益とは、要するに利益の累積和のことなので、Profit列のcumsum()を計算します。

10.各日付時点のドローダウンを新しい列に追加する

records["Drawdown"] = records.Gross.cummax().subtract( records.Gross )

cummax()で累積最大値を調べることができます。累積最大値とはその行までの最大値のことです。例えば、Gross列の2行目までの累積最大値は42264、3行目までの累積最大値は75676になります。

最大ドローダウンとは、要するに「 n行目までの総利益の累積最大値から n行目の総利益を差し引いたもの」なので、上記の式で各行の最大ドローダウンを計算できます。引き算には、subtract()を使います。

終了

はい!
ここまでで、今までの記事で勉強してきたバックテストの成績の指標は、すべてpandasに置き換えることができました! 何となくpandasの便利さを実感できたのではないでしょうか?

月別にバックテスト結果を集計しよう!

pandasではさらにグルーピングという便利な機能があります。上記で作成した表データを、月ごとにグルーピングしてみましょう!

以下のように書くだけです。


records["月別集計"] = records.Date.apply(lambda x: x.strftime('%Y/%m'))
grouped = records.groupby("月別集計")

month_records = pd.DataFrame({
	"Gross" : grouped.Profit.sum(),
	"Rate" : round(grouped.Rate.mean()*100,1),
	"Drawdown" : grouped.Drawdown.max(),
	"Periods" : grouped.Periods.mean()
	})
print("---------------------------------------------------")
print( month_records )

groupby()でグループ分けできる!

groupby( 列名 )で特定の列を指定すると、その列の値が同じもの同士をグループ化することができます。例えば、groupby(“Side”)でグループ分けすれば、買いエントリーと売りエントリーとでデータをグループ分けできます。

groupby(列名)で列の値が同じもの同士をグループ化する

ここでは「月別」にデータをグループ分けします。ただし2017年3月と2018年3月が、同じ「3月」でグルーピングされると困りますよね。「〇年〇月」までをセットでグルーピングしなければなりません。

そのため、最初に以下の1行を書いています。

records["月別集計"] = records.Date.apply(lambda x: x.strftime('%Y/%m'))

ここでは、グループ分けをするために専用の新しい列を作っています。

もともと存在したDate列のデータを「年/月」のかたちに変形し、それを新しく作った「月別集計」という列に入れる、という処理をしています。

補足)lambda と apply() で全行のデータを変形する

ここには、apply()とlambdaという新しい記述方法が登場しています。

lambda は「 lambda 変数 : 式 」と書いて、変数を式のかたちに変形する記述方法です。表データに対して、apply( lambda 引数 : 式 ) と記述することで、表データのすべての行に対して、まとめて同じ式の処理を実行することができます。

以下の例をみるとわかりやすいでしょう。

(例)

number = pd.DataFrame({"data":[10,20,30,40,50]})
number = number.apply(lambda x : x+5)

# これを実行するとデータは{ "data":[15,25,35,45,55] } になります。

for文のような処理を、表(DataFrame型)に適用するための記述方法ですね。もっと興味がある方は、apply()やlambdaを調べて勉強してみてください。

さて、これで「年/月」という新しい列ができたので、この列を基準にグループ分けをします。それが以下の行です。

# グループ化する処理
grouped = records.groupby("月別集計")

この1行だけで、「月別集計」の列が同じもの同士をグループ化することができます。

グループ化したデータの使い方

グループ化したデータの結果は、以下のようなかたちで取り出すことができます。

# グループ化したデータから値を取り出す

grouped.Profit.sum()  # グループごとのProfit列の合計値の配列を返す
grouped.Profit.mean() # グループごとのProfit列の平均値の配列を返す
grouped.Profit.max()  # グループごとのProfit列の最大値の配列を返す

上記の結果を実行すれば、例えば、「2018/2」「2018/3」「2018/4」などのグループの、それぞれの利益の合計値、平均値、最大値を取り出すことができます。

月別のデータ集計といっても、欲しい数字は項目によって異なります。例えば、総損益なら合計値(sum)が必要ですし、平均リターンなら平均値(mean)が必要です。最大ドローダウンであれば最大値(max)が必要でしょう。

さて、それぞれ必要なデータを取り出したら、それを「列」として結合して新しい表(DataFrame)を作ります。配列データをくっつけて1つの表データにする方法は、1番最初に勉強しました。覚えていますよね?


month_records = pd.DataFrame({
	"Gross" : grouped.Profit.sum(),
	"Rate" : round(grouped.Rate.mean()*100,1),
	"Drawdown" : grouped.Drawdown.max(),
	"Periods" : grouped.Periods.mean()
	})
print("---------------------------------------------------")
print( month_records )

これを実行すると以下のようになります。

実行結果

これで最初の5回分のトレード成績を、以下のように月別にグルーピングできました。

月別集計 最大ドローダウン 平均リターン 月間損益 平均保有期間
2018/2 0円 1.7% 75676円 11期間
2018/3 -18437円 -2.0% -18437円 12期間

練習問題

ドンチャン・ブレイクアウトBOTの月別リターンを集計しよう!

では、最後にここまで勉強したpandasの知識を使って、前回までの記事で作成した「ドンチャンブレイクアウトBOT」のバックテストのコードを改良しましょう!

以下のように月別の成績を集計して表示できるようにします! 具体的なコードは以下の記事に記載していますが、まずはご自身で挑戦してみてください。


・解答のサンプル記事はこちら

Pandasの練習問題ーBTCFXのドンチャンブレイクアウトBOTの月別成績を集計する

前回の記事「Pandasを使ってBTCFXの自動売買BOTの月別の成績を集計しよう!」の練習問題の回答コードです。

1時間足での30期間ドンチャン・ブレイクアウトBOTの月別の成績を集計してみましょう! なお、今回のコードではPandasの集計方法を使って以下のような指標も加えておきました。

・全トレードの回数と勝率
・全トレードの平均リターン
・最大の勝ちトレードでの利益
・最大の負けトレードでの損失
・最終的な利益合計
・最終的な損失合計

これらも自動売買BOTの成績を評価する際に参考にしてみてください。


import requests
from datetime import datetime
import time
import matplotlib.pyplot as plt
import pandas as pd

#-----設定項目

chart_sec = 3600    # 1時間足を使用
term = 30           # 過去n期間の設定
wait = 0            # ループの待機時間
lot = 1             # BTCの注文枚数
slippage = 0.001    # 手数料・スリッページ


# CryptowatchのAPIを使用する関数
def get_price(min, before=0, after=0):
	price = []
	params = {"periods" : min }
	if before != 0:
		params["before"] = before
	if after != 0:
		params["after"] = after

	response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc",params)
	data = response.json()
	
	if data["result"][str(min)] is not None:
		for i in data["result"][str(min)]:
			if i[1] != 0 and i[2] != 0 and i[3] != 0 and i[4] != 0:
				price.append({ "close_time" : i[0],
					"close_time_dt" : datetime.fromtimestamp(i[0]).strftime('%Y/%m/%d %H:%M'),
					"open_price" : i[1],
					"high_price" : i[2],
					"low_price" : i[3],
					"close_price": i[4] })
		return price
		
	else:
		print("データが存在しません")
		return None


# 時間と高値・安値をログに記録する関数
def log_price( data,flag ):
	log =  "時間: " + datetime.fromtimestamp(data["close_time"]).strftime('%Y/%m/%d %H:%M') + " 高値: " + str(data["high_price"]) + " 安値: " + str(data["low_price"]) + "\n"
	flag["records"]["log"].append(log)
	return flag


# ドンチャンブレイクを判定する関数
def donchian( data,last_data ):
	
	highest = max(i["high_price"] for i in last_data)
	if data["high_price"] > highest:
		return {"side":"BUY","price":highest}
	
	lowest = min(i["low_price"] for i in last_data)
	if data["low_price"] < lowest:
		return {"side":"SELL","price":lowest}
	
	return {"side" : None , "price":0}


# ドンチャンブレイクを判定してエントリー注文を出す関数
def entry_signal( data,last_data,flag ):
	signal = donchian( data,last_data )
	if signal["side"] == "BUY":
		flag["records"]["log"].append("過去{0}足の最高値{1}円を、直近の高値が{2}円でブレイクしました\n".format(term,signal["price"],data["high_price"]))
		flag["records"]["log"].append(str(data["close_price"]) + "円で買いの指値注文を出します\n")

		# ここに買い注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "BUY"
		flag["order"]["price"] = round(data["close_price"] * lot)

	if signal["side"] == "SELL":
		flag["records"]["log"].append("過去{0}足の最安値{1}円を、直近の安値が{2}円でブレイクしました\n".format(term,signal["price"],data["low_price"]))
		flag["records"]["log"].append(str(data["close_price"]) + "円で売りの指値注文を出します\n")

		# ここに売り注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "SELL"
		flag["order"]["price"] = round(data["close_price"] * lot)

	return flag



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


# 手仕舞いのシグナルが出たら決済の成行注文 + ドテン注文 を出す関数
def close_position( data,last_data,flag ):
	
	flag["position"]["count"] += 1
	signal = donchian( data,last_data )
	
	if flag["position"]["side"] == "BUY":
		if signal["side"] == "SELL":
			flag["records"]["log"].append("過去{0}足の最安値{1}円を、直近の安値が{2}円でブレイクしました\n".format(term,signal["price"],data["low_price"]))
			flag["records"]["log"].append(str(data["close_price"]) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			flag["records"]["log"].append("さらに" + str(data["close_price"]) + "円で売りの指値注文を入れてドテンします\n")
			
			# ここに売り注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"
			flag["order"]["price"] = round(data["close_price"] * lot)
			

	if flag["position"]["side"] == "SELL":
		if signal["side"] == "BUY":
			flag["records"]["log"].append("過去{0}足の最高値{1}円を、直近の高値が{2}円でブレイクしました\n".format(term,signal["price"],data["high_price"]))
			flag["records"]["log"].append(str(data["close_price"]) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			flag["records"]["log"].append("さらに" + str(data["close_price"]) + "円で買いの指値注文を入れてドテンします\n")
			
			# ここに買い注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "BUY"
			flag["order"]["price"] = round(data["close_price"] * lot)
			
	return flag


# 各トレードのパフォーマンスを記録する関数
def records(flag,data):
	
	# 取引手数料等の計算
	entry_price = flag["position"]["price"]
	exit_price = round(data["close_price"] * lot)
	trade_cost = round( exit_price * slippage )
	
	log = "スリッページ・手数料として " + str(trade_cost) + "円を考慮します\n"
	flag["records"]["log"].append(log)
	flag["records"]["slippage"].append(trade_cost)
	
	# 手仕舞った日時と保有期間を記録
	flag["records"]["date"].append(data["close_time_dt"])
	flag["records"]["holding-periods"].append( flag["position"]["count"] )
	
	# 値幅の計算
	buy_profit = exit_price - entry_price - trade_cost
	sell_profit = entry_price - exit_price - trade_cost
	
	# 利益が出てるかの計算
	if flag["position"]["side"] == "BUY":
		flag["records"]["side"].append( "BUY" )
		flag["records"]["profit"].append( buy_profit )
		flag["records"]["return"].append( round( buy_profit / entry_price * 100, 4 ))
		if buy_profit  > 0:
			log = str(buy_profit) + "円の利益です\n"
			flag["records"]["log"].append(log)
		else:
			log = str(buy_profit) + "円の損失です\n"
			flag["records"]["log"].append(log)
	
	if flag["position"]["side"] == "SELL":
		flag["records"]["side"].append( "SELL" )
		flag["records"]["profit"].append( sell_profit )
		flag["records"]["return"].append( round( sell_profit / entry_price * 100, 4 ))
		if sell_profit > 0:
			log = str(sell_profit) + "円の利益です\n"
			flag["records"]["log"].append(log)
		else:
			log = str(sell_profit) + "円の損失です\n"
			flag["records"]["log"].append(log)
	
	return flag

# バックテストの集計用の関数
def backtest(flag):
	
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Gross.cummax().subtract(records.Gross)
	records["DrawdownRate"] = round(records.Drawdown / records.Gross.cummax() * 100,1)
	
	# 買いエントリーと売りエントリーだけをそれぞれ抽出する
	buy_records = records[records.Side.isin(["BUY"])]
	sell_records = records[records.Side.isin(["SELL"])]
	
	# 月別のデータを集計する
	records["月別集計"] = pd.to_datetime( records.Date.apply(lambda x: x.strftime('%Y/%m')))
	grouped = records.groupby("月別集計")
	
	month_records = pd.DataFrame({
		"Number"   :  grouped.Profit.count(),
		"Gross"    :  grouped.Profit.sum(),
		"Rate"     :  round(grouped.Rate.mean(),2),
		"Drawdown" :  grouped.Drawdown.max(),
		"Periods"  :  grouped.Periods.mean()
		})
	
	print("バックテストの結果")
	print("-----------------------------------")
	print("買いエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(buy_records) ))
	print("勝率               :  {}%".format(round(len(buy_records[buy_records.Profit>0]) / len(buy_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(buy_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( buy_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(buy_records.Periods.mean(),1) ))
	
	print("-----------------------------------")
	print("売りエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(sell_records) ))
	print("勝率               :  {}%".format(round(len(sell_records[sell_records.Profit>0]) / len(sell_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(sell_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( sell_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(sell_records.Periods.mean(),1) ))
	
	print("-----------------------------------")
	print("総合の成績")
	print("-----------------------------------")
	print("全トレード数       :  {}回".format(len(records) ))
	print("勝率               :  {}%".format(round(len(records[records.Profit>0]) / len(records) * 100,1)))
	print("平均リターン       :  {}%".format(round(records.Rate.mean(),2)))
	print("平均保有期間       :  {}足分".format( round(records.Periods.mean(),1) ))
	print("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	print("最大ドローダウン   :  {0}円 / {1}%".format(-1 * records.Drawdown.max(),  -1 * records.DrawdownRate.loc[records.Drawdown.idxmax()]  ))
	print("利益合計           :  {}円".format( records[records.Profit>0].Profit.sum() ))
	print("損失合計           :  {}円".format( records[records.Profit<0].Profit.sum() ))
	print("")
	print("最終損益           :  {}円".format( records.Profit.sum() ))
	print("手数料合計         :  {}円".format( -1 * records.Slippage.sum() ))
	
	print("-----------------------------------")
	print("月別の成績")
	
	for index , row in month_records.iterrows():
		print("-----------------------------------")
		print( "{0}年{1}月の成績".format( index.year, index.month ) )
		print("-----------------------------------")
		print("トレード数         :  {}回".format( row.Number.astype(int) ))
		print("月間損益           :  {}円".format( row.Gross.astype(int) ))
		print("平均リターン       :  {}%".format( row.Rate ))
		print("月間ドローダウン   :  {}円".format( -1 * row.Drawdown.astype(int) ))

	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])

	# 損益曲線をプロット
	plt.plot( records.Date, records.Gross )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	plt.show()
	


# ここからメイン処理

# 価格チャートを取得
price = get_price(chart_sec,after=1483228800)

flag = {
	"order":{
		"exist" : False,
		"side" : "",
		"price" : 0,
		"count" : 0
	},
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"count":0
	},
	"records":{
		"date":[],
		"profit":[],
		"return":[],
		"side":[],
		"holding-periods":[],
		"slippage":[],
		"log":[]
	}
}


last_data = []
i = 0
while i < len(price):

	# ドンチャンの判定に使う過去30期間分の安値・高値データを準備する
	if len(last_data) < term:
		last_data.append(price[i])
		flag = log_price(price[i],flag)
		time.sleep(wait)
		i += 1
		continue
	
	data = price[i]
	flag = log_price(data,flag)
	
	
	if flag["order"]["exist"]:
		flag = check_order( flag )
	elif flag["position"]["exist"]:
		flag = close_position( data,last_data,flag )
	else:
		flag = entry_signal( data,last_data,flag )
	
	
	# 過去データを30個に保つために先頭を削除
	del last_data[0]
	last_data.append( data )
	i += 1
	time.sleep(wait)


print("--------------------------")
print("テスト期間:")
print("開始時点 : " + str(price[0]["close_time_dt"]))
print("終了時点 : " + str(price[-1]["close_time_dt"]))
print(str(len(price)) + "件のローソク足データで検証")
print("--------------------------")

backtest(flag)

実行結果

以下が1時間足でシンプルな30期間のドンチャンブレイクBOTを、2017年8月~2018年4月にかけて運用した場合の成績です。

損益グラフ

ドンチャン・ブレイクアウトBOTの成績


(base) C:\Pydoc>python test.py
--------------------------
テスト期間:
開始時点 : 2017/08/13 08:00
終了時点 : 2018/04/20 15:00
6000件のローソク足データで検証
--------------------------
バックテストの結果
-----------------------------------
買いエントリの成績
-----------------------------------
トレード回数       :  49回
勝率               :  51.0%
平均リターン       :  3.04%
総損益             :  1090811円
平均保有期間       :  62.2足分
-----------------------------------
売りエントリの成績
-----------------------------------
トレード回数       :  49回
勝率               :  42.9%
平均リターン       :  0.85%
総損益             :  670134円
平均保有期間       :  56.7足分
-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  98回
勝率               :  46.9%
平均リターン       :  1.94%
平均保有期間       :  59.5足分

最大の勝ちトレード :  545284円
最大の負けトレード :  -296151円
最大ドローダウン   :  -483332円 / -24.4%
利益合計           :  4591094円
損失合計           :  -2830149円

最終損益           :  1760945円
手数料合計         :  -104961円
-----------------------------------
月別の成績
-----------------------------------
2017年8月の成績
-----------------------------------
トレード数         :  6回
月間損益           :  1552円
平均リターン       :  0.08%
月間ドローダウン   :  -20319円
-----------------------------------
2017年9月の成績
-----------------------------------
トレード数         :  10回
月間損益           :  139626円
平均リターン       :  3.02%
月間ドローダウン   :  -20785円
-----------------------------------
2017年10月の成績
-----------------------------------
トレード数         :  12回
月間損益           :  119928円
平均リターン       :  2.12%
月間ドローダウン   :  -50845円
-----------------------------------
2017年11月の成績
-----------------------------------
トレード数         :  11回
月間損益           :  405276円
平均リターン       :  4.89%
月間ドローダウン   :  -138695円
-----------------------------------
2017年12月の成績
-----------------------------------
トレード数         :  10回
月間損益           :  175018円
平均リターン       :  1.68%
月間ドローダウン   :  -364745円
-----------------------------------
2018年1月の成績
-----------------------------------
トレード数         :  14回
月間損益           :  425925円
平均リターン       :  1.08%
月間ドローダウン   :  -247323円
-----------------------------------
2018年2月の成績
-----------------------------------
トレード数         :  13回
月間損益           :  284263円
平均リターン       :  2.58%
月間ドローダウン   :  -483332円
-----------------------------------
2018年3月の成績
-----------------------------------
トレード数         :  11回
月間損益           :  155253円
平均リターン       :  1.07%
月間ドローダウン   :  -425155円
-----------------------------------
2018年4月の成績
-----------------------------------
トレード数         :  11回
月間損益           :  54104円
平均リターン       :  0.3%
月間ドローダウン   :  -216376円

月別の成績で見ても、すべての月でプラスの成績が出ています。
これはなかなか悪くない結果ですね。

こちらの月別の成績は、あくまで「ポジションを手仕舞った日時」を基準に区切っている点に注意してください。例えば、2月の成績はプラスになっていますが、これは1月から持ち越したポジションで大きな利益が出ているからです。もし2月からBOTの稼働を開始していたら、2月の損益はマイナスになります。

コードの解説

基本的には、前回の記事「Pandasを使って自動売買BOTの成績を月別に集計しよう!」で、例として解説したコードをそのまま使っています。特に難しいところは無かったのではないでしょうか。

以下のところだけ、前回の記事には登場していなかった書き方なので、追加で解説しておきます。

最大ドローダウン率

最大ドローダウン率とは、ある行の最大ドローダウンの金額を、その行までの最大資産額で割った数字です。特定の行までの最大値は、cummax()で取得できます。そのため、以下のような式になります。

records["DrawdownRate"] = round(records.Drawdown / records.Gross.cummax() * 100,1)

ただし実際に最大ドローダウン率を表示するときには注意が必要です。単にドローダウン率の中から最大値を選ぶだけだと、最大ドローダウンの金額と時期が一致するとは限らないからです。

例えば、「最大ドローダウン率でみると序盤の2月の30%が最大だけど、金額ベースでみると4月の300万円が最大ドローダウンだ」ということもあり得ます。

このとき私たちが知りたいのは、最大ドローダウン率ではなく、「最大ドローダウン金額が最大だったときのドローダウン率」であるはずです。そのため、以下のように記述します。


# 最大ドローダウンの行番号を取得
records.Drawdown.idxmax()

# 最大ドローダウンと同じ行のドローダウン率を取得
records.DrawdownRate.loc[records.Drawdown.idxmax()]

これで最大ドローダウンと同じ時期のドローダウン率を取得することができました。

pandasの表データのfor文処理

for index,row in month_records.iterrows():
		print("-----------------------------------")
		print( "{0}年{1}月の成績".format( index.year, index.month ) )
		print("-----------------------------------")
		print("トレード数         :  {}回".format( row.Number.astype(int) ))
		print("月間損益           :  {}円".format( row.Gross.astype(int) ))
		print("平均リターン       :  {}%".format( row.Rate ))
		print("月間ドローダウン   :  {}円".format( -1 * row.Drawdown.astype(int) ))

pandasの表データを1行ずつループ処理したい場合、以下のように書くことができます。

for index,row in 表データ変数.iterrows():
		# 1行ずつ処理したい内容

index (インデックス)というのは、表データから特定の「行」を検索するときの「索引」のことです。

行には通常、1行ごとに先頭から 0,1,2,3,4,5.....などの行番号が割り振られていますが、それだと検索するときに不便です。縦の列にカラム名(ここでは Gross/Rate/Drawdownなど)があるのと同じように、行にも名前を付けることができます。それが index です。

今回のコードでは、pandas の groupby()で「月別」にデータをグループ化しているため、index には「2017/08」「2017/09」「2017/10」などのDate型のデータが指定されています。そのため、for文の中で index.year / index.month を指定することで、これらの数字を print できます。

pythonで資産曲線を作って自動売買BOTの成績を視覚的に評価しよう!

自動売買BOTの世界では、長期的に使える強いトレードシステムのことを「堅牢性(ロバスト性)がある」と表現します。わかりやすい言葉でいうと「環境の変化に強く安定したシステム」のことです。

「堅牢性がある」とは、単に期待リターンが高いというだけではありません。以下のような特徴を備えた売買ロジックのことをいいます。

1.期待リターンに対してリスクが低い
2.毎月の期待リターンのバラツキ具合が小さい
3.最大ドローダウンが小さい
4.最適化された固定値のパラメーターが少ない

前回の記事では、自動売買BOTの勝率・期待リターンなどを計算する方法を勉強しましたが、それだけではシステムの堅牢性は評価できません。

この記事では、ドンチャン・ブレイクアウトBOTの堅牢性を評価するために資産推移のグラフを描写して、さらに最大ドローダウンを計算する方法を解説していきます。

1)グラフをpythonで描画する方法

先にpythonでグラフを描画する方法だけ解説しておきましょう。

大丈夫、めちゃくちゃ簡単です! まずは練習としてBTCの日々の終値の価格グラフを作成してみましょう。以下のように書くだけです。

import matplotlib.pyplot as plt
import pandas as pd

# 日付の配列データ(x軸)
date_list = [
	"2018/1/1",
	"2018/1/2",
	"2018/1/3",
	"2018/1/4",
	"2018/1/5",
	"2018/1/6",
	"2018/1/7",
	"2018/1/8",
	"2018/1/9",
	"2018/1/10",
	"2018/1/11",
	"2018/1/12",
	"2018/1/13",
	"2018/1/14"
]

# BTC価格(ドル)のデータ(y軸)
price_list = [
	13657,
	14982,
	15201,
	15599,
	17429,
	17527,
	16477,
	15170,
	14595,
	14973,
	13405,
	13980,
	14360,
	13772
]

# 日付データに変換
date_list = pd.to_datetime(date_list)

# plot(x,y)でx軸とy軸を指定
plt.plot( date_list,price_list )
plt.xlabel("Date")
plt.ylabel("Price")

# 描写を実行
plt.show()

グラフの描画には matplotlib というライブラリを使います。

これはAnacondaを使ってpythonをインストールした方であれば、最初から入っていますので、以下の1行を先頭に書くだけで使用できます。

import matplotlib.pyplot as plt

コードの解説

まずX軸とY軸にそれぞれ描画したいデータを配列として用意します。

それぞれの要素の数が一致していないといけないので、注意してください。今回は、日付データ(date_list)と価格データ(price_list)にそれぞれ14個ずつのデータを用意しました。

次に、日付データを「テキスト型」から「日付型」に変換します。なぜ日付型に変換するのかというと、テキスト型のままだと以下のようなデータがすべてX軸に等間隔で並んでしまうからです。

例)
date = ["1/1","1/2","3/2","3/4","4/2"]

テキスト型のままだとこの5個のデータは、同じ目盛り上に等しい間隔で並んでしまいます。1/1~1/2と、1/2~3/2が、同じ間隔で並んでしまうと、後ほど資産推移の曲線などを描画するときに困りますね。

これを避けるために、以下のコードで日付型に変換しておきます。

import pandas as pd
# 日付データに変換
date_list = pd.to_datetime(date_list)

ここでは変換にpandasというライブラリを使用しています。こちらもAnacondaでPythonをインストールした方であれば、最初からインストールされています。そうでない方はpipを使ってください。

実行結果

当サイトと同じ方法で(Anacondaで)Pythonをインストールしている方なら、すでにSpyderという実行環境がインストールされています。そのため、上記のコードを実行すると、自動的に以下のようなウィンドウが立ち上がり、グラフの描画が実行されます。

グラフの描画の説明はこれだけです!

2)資産曲線を描く

まずは資産カーブを描くために、ポジションを手仕舞うたびにその時点での損益(資産推移)を記録するコードを作っておきましょう。やり方は前回までの記事と全く同じです。

データを記録する準備

資産推移のグラフを描くために必要なデータは以下の2つです。

1)各ポジションを手仕舞った時点での資産額
2)各ポジションを手仕舞った時点の日付(時間)

まずは資産額と日付を記録するために、flag変数に以下を追加しておきましょう。

"records":{
	"buy-count": 0,
	"buy-winning" : 0,
	#(中略)
	
	"date":[], # 追加
	"gross-profit":[0],# 追加
	"slippage":[],
	"log":[]
	}

総損益を記録するための配列データには、初期値として0を入れておきます。

そして手仕舞いの関数が呼ばれるたびに、その時点での日付データと総損益を append で記録しておきます。 以下のように書けばいいですね。

# 各トレードのパフォーマンスを記録する関数
def records(flag,data):
	# 手仕舞った日時の記録
	flag["records"]["date"].append(data["close_time_dt"])

	if flag["position"]["side"] == "BUY":
		# 追記部分
		flag["records"]["gross-profit"].append( flag["records"]["gross-profit"][-1] + buy_profit )
	if flag["position"]["side"] == "SELL":
		# 追記部分
		flag["records"]["gross-profit"].append( flag["records"]["gross-profit"][-1] + sell_profit )

各トレードの成績を記録する関数(def records)で、ポジションを手仕舞ったときの日付データを記録しておきます。これはローソク足のデータを記録する変数(data)にある日時情報(data[“close_time_dt”])を代入するだけです。

また、総損益を記録する変数(gross-profit)の前回の値([-1])を取り出し、そこに今回の最新の損益(buy_profit/sell_profit)を足した数字を、appendで配列の末尾に追加します。

これで準備は完了です。

グラフを描画する

最後にバックテスト集計用の関数(def backtest)の中で資産カーブを描画するためのコードを追加します。

# 損益曲線をプロット
del flag["records"]["gross-profit"][0] # X軸/Y軸のデータ数を揃えるため、先頭の0を削除
date_list = pd.to_datetime( flag["records"]["date"] ) # 日付型に変換

plt.plot( date_list, flag["records"]["gross-profit"] )
plt.xlabel("Date")
plt.ylabel("Balance")
plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
plt.show()

最初の例と全く同じですね。

flag[records][gross-profit]には、初期値として0円が入っており、日付データと数が一致しないため、del [0]で先頭の0円を削除しています。また、pd.to_datetime()で、日付のテキストデータを日付型に変換しています。

では実行してみましょう!

実行結果

以下は、30期間のドンチャン・ブレイクアウト(損切なし・ドテン売買)を、1時間足を使って 2017/8/9 ~ 2018/4/16 までの期間、6000足分のローソク足データを使って検証した結果です。

▽ 最終結果

▽ 資産カーブ

右肩上がりといえば右肩上がりですが、何度か大きなドローダウン(利益が大きく削られる局面)を経験していることがわかりますね。では、この売買システムの最大ドローダウンはどのくらいの大きさなのでしょうか?

3)最大ドローダウンを計算する

まずpythonで最大ドローダウンの計算を実装するにあたって、ドローダウンとは何なのか?を明確に定義しておきましょう。

自動売買BOTを自作でプログラミングする最大のメリットは、あらゆるテクニカル指標・売買ロジック・バックテストの指標などをコーディングする過程で、正確にその意味を理解できるようになることです。

私もそうですが、文系で何となく投資本を読んでいるだけだと、ドローダウンの意味1つとっても、「何となくこういうものだろう」というイメージだけで理解してしまいがちです。しかし自分でコーディングするようになると、どんな指標・ロジックでも明確な定義を考える習慣がつきます。

定義を考える習慣がつくようになると、単に概念を本で学んで覚えているだけの人に比べて、その指標の活用方法や弱点に気づきやすくなります。

1.ドローダウンの定義

基本的にはドローダウンは、「ある時点での資産額と、それ以前の資産の最大額との落差のこと」と定義できます。

例えば、以下のように資産が推移したと過程しましょう。

1月 50万円
2月 100万円
3月 200万円 ← 直近の最大額
4月 180万円
5月 150万円 ← 最大ドローダウン
6月 170万円
7月 220万円 ← 直近の最大額を更新
8月 190万円
9月 200万円
10月 170万円 ← 最大ドローダウン
11月 210万円

この場合、大きなドローダウンは2つ存在します。

1つ目は、3月のピーク200万円から5月の150万円までのドローダウン(-50万円)です。もう1つは、7月のピーク(220万円)から10月の170万円までのドローダウン(-50万円)です。

この場合、最大ドローダウンは-50万円、ということになります。%表記にすると、50/220 = 0.2272… なので約22.7%のドローダウンです。

2.Pythonコードで実装する

ではこれを実装していきましょう。

手始めに、とりあえず、いつものように記録用の変数を追加します。以下のような変数を追加しておきましょう。

"records":{
	"buy-count": 0,
	"buy-winning" : 0,
	# 追記
	"drawdown": 0,

次にポジションを閉じるたびに呼ばれる「各トレードの成績を記録する関数」に、以下の3行を追加しましょう。

# 各トレードのパフォーマンスを記録する関数
def records(flag,data):
	# (中略)
	# ドローダウンの計算
	drawdown =  max(flag["records"]["gross-profit"]) - flag["records"]["gross-profit"][-1] 
	if  drawdown  > flag["records"]["drawdown"]:
		flag["records"]["drawdown"] = drawdown

ポジションを閉じるたびに、その時点における過去の資産の最大額をmax(flag[records][gross-profit])で探します。そこから、現時点での資産額(flag[records][gross-profit][-1])を引いて現在のドローダウンを計算します。

そして現在のドローダウンが過去に記録したものよりも大きければ、flag[records][drawdown] に代入して値を更新します。これにより、最終的に「最大ドローダウン」だけが残ります。

そして最後に「バックテスト集計用の関数」に以下を追記します。

# バックテストの集計用の関数
def backtest(flag):
	# (中略)
	print("最大ドローダウン :  {0}円 / {1}%".format(-1 * flag["records"]["drawdown"], -1 * round(flag["records"]["drawdown"]/max(flag["records"]["gross-profit"])*100,1)  ))

ここで最大ドローダウンの金額を表示するとともに、そのパーセンテージも計算して表示しています。ではこれを実行してみましょう。

3.実行結果

以下のようになりました。

もし過去の半年間に1時間足で30期間のドンチャンブレイクアウトを採用していたら、この部分の最大ドローダウンが約48万円(-23%)だったということですね。

※ 2月中盤~後半にかけて、資産207万円 ⇒ 159万円のドローダウン

3)もし2月から自動売買BOTの運用を開始していた場合

では、もし2月からドンチャン・ブレイクアウトの自動売買BOTの運用を開始していた場合はどうなっていたのでしょうか? これを最後に検証して、今回の記事を終えておきましょう。

以下のコードの部分を変更するだけです。

・UNIX時間変換ツール

これを実行すると、以下のようになります。

▽ バックテストの結果

▽ 資産カーブ

1枚のBTCを売買したとすると、開始早々-20万円のドローダウンに見舞われることになります。その後、+20万円まで持ち直しますが、そこから-約50万円のドローダウンを食らいます。

長期的には期待値プラスの売買ロジックでも、このように開始早々、大きなマイナスを食らったり、数カ月連続してプラス転換しない場合もあります。このようなときに、長期的な期待値を信じて我慢できるかどうか、あるいは「相場が変わったからもうこのシステムは通用しないんだ…」と諦めるべきかは、判断が難しいところです。

パラメーターの過剰最適化がされていない、バックテストのデータ数が十分である、など、堅牢性の高いシステムであればあるほど、このような局面でも期待値を信じて我慢することが可能になります。この章のはじめで、サンプル数の重要性などを説明したのはそのためです。

今回の勉強で使ったコード

最後に今回の記事で作ったpythonコードを掲載しておきましょう。


import requests
from datetime import datetime
import time
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd

#-----設定項目

chart_sec = 3600    # 1時間足を使用
term = 30           # 過去n足の設定
wait = 0            # ループの待機時間
lot = 1             # BTCの注文枚数
slippage = 0.001    # 手数料・スリッページ


# CryptowatchのAPIを使用する関数
def get_price(min, before=0, after=0):
	price = []
	params = {"periods" : min }
	if before != 0:
		params["before"] = before
	if after != 0:
		params["after"] = after

	response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc",params)
	data = response.json()
	
	if data["result"][str(min)] is not None:
		for i in data["result"][str(min)]:
			if i[1] != 0 and i[2] != 0 and i[3] != 0 and i[4] != 0:
				price.append({ "close_time" : i[0],
					"close_time_dt" : datetime.fromtimestamp(i[0]).strftime('%Y/%m/%d %H:%M'),
					"open_price" : i[1],
					"high_price" : i[2],
					"low_price" : i[3],
					"close_price": i[4] })
		return price
		
	else:
		print("データが存在しません")
		return None


# 時間と高値・安値をログに記録する関数
def log_price( data,flag ):
	log =  "時間: " + datetime.fromtimestamp(data["close_time"]).strftime('%Y/%m/%d %H:%M') + " 高値: " + str(data["high_price"]) + " 安値: " + str(data["low_price"]) + "\n"
	flag["records"]["log"].append(log)
	return flag


# ドンチャンブレイクを判定する関数
def donchian( data,last_data ):
	
	highest = max(i["high_price"] for i in last_data)
	if data["high_price"] > highest:
		return {"side":"BUY","price":highest}
	
	lowest = min(i["low_price"] for i in last_data)
	if data["low_price"] < lowest:
		return {"side":"SELL","price":lowest}
	
	return {"side" : None , "price":0}


# ドンチャンブレイクを判定してエントリー注文を出す関数
def entry_signal( data,last_data,flag ):
	signal = donchian( data,last_data )
	if signal["side"] == "BUY":
		flag["records"]["log"].append("過去{0}足の最高値{1}円を、直近の高値が{2}円でブレイクしました\n".format(term,signal["price"],data["high_price"]))
		flag["records"]["log"].append(str(data["close_price"]) + "円で買いの指値注文を出します\n")

		# ここに買い注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "BUY"
		flag["order"]["price"] = round(data["close_price"] * lot)

	if signal["side"] == "SELL":
		flag["records"]["log"].append("過去{0}足の最安値{1}円を、直近の安値が{2}円でブレイクしました\n".format(term,signal["price"],data["low_price"]))
		flag["records"]["log"].append(str(data["close_price"]) + "円で売りの指値注文を出します\n")

		# ここに売り注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "SELL"
		flag["order"]["price"] = round(data["close_price"] * lot)

	return flag



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


# 手仕舞いのシグナルが出たら決済の成行注文 + ドテン注文 を出す関数
def close_position( data,last_data,flag ):
	
	flag["position"]["count"] += 1
	signal = donchian( data,last_data )
	
	if flag["position"]["side"] == "BUY":
		if signal["side"] == "SELL":
			flag["records"]["log"].append("過去{0}足の最安値{1}円を、直近の安値が{2}円でブレイクしました\n".format(term,signal["price"],data["low_price"]))
			flag["records"]["log"].append(str(data["close_price"]) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			flag["records"]["log"].append("さらに" + str(data["close_price"]) + "円で売りの指値注文を入れてドテンします\n")
			
			# ここに売り注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"
			flag["order"]["price"] = round(data["close_price"] * lot)
			

	if flag["position"]["side"] == "SELL":
		if signal["side"] == "BUY":
			flag["records"]["log"].append("過去{0}足の最高値{1}円を、直近の高値が{2}円でブレイクしました\n".format(term,signal["price"],data["high_price"]))
			flag["records"]["log"].append(str(data["close_price"]) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			flag["records"]["log"].append("さらに" + str(data["close_price"]) + "円で買いの指値注文を入れてドテンします\n")
			
			# ここに買い注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "BUY"
			flag["order"]["price"] = round(data["close_price"] * lot)
			
	return flag


# 各トレードのパフォーマンスを記録する関数
def records(flag,data):
	
	# 取引手数料等の計算
	entry_price = flag["position"]["price"]
	exit_price = round(data["close_price"] * lot)
	trade_cost = round( exit_price * slippage )
	
	log = "スリッページ・手数料として " + str(trade_cost) + "円を考慮します\n"
	flag["records"]["log"].append(log)
	flag["records"]["slippage"].append(trade_cost)
	
	# 手仕舞った日時の記録
	flag["records"]["date"].append(data["close_time_dt"])
	
	# 値幅の計算
	buy_profit = exit_price - entry_price - trade_cost
	sell_profit = entry_price - exit_price - trade_cost
	
	# 利益が出てるかの計算
	if flag["position"]["side"] == "BUY":
		flag["records"]["buy-count"] += 1
		flag["records"]["buy-profit"].append( buy_profit )
		flag["records"]["gross-profit"].append( flag["records"]["gross-profit"][-1] + buy_profit )
		flag["records"]["buy-return"].append( round( buy_profit / entry_price * 100, 4 ))
		flag["records"]["buy-holding-periods"].append( flag["position"]["count"] )
		if buy_profit  > 0:
			flag["records"]["buy-winning"] += 1
			log = str(buy_profit) + "円の利益です\n"
			flag["records"]["log"].append(log)
		else:
			log = str(buy_profit) + "円の損失です\n"
			flag["records"]["log"].append(log)
	
	if flag["position"]["side"] == "SELL":
		flag["records"]["sell-count"] += 1
		flag["records"]["sell-profit"].append( sell_profit )
		flag["records"]["gross-profit"].append( flag["records"]["gross-profit"][-1] + sell_profit )
		flag["records"]["sell-return"].append( round( sell_profit / entry_price * 100, 4 ))
		flag["records"]["sell-holding-periods"].append( flag["position"]["count"] )
		if sell_profit > 0:
			flag["records"]["sell-winning"] += 1
			log = str(sell_profit) + "円の利益です\n"
			flag["records"]["log"].append(log)
		else:
			log = str(sell_profit) + "円の損失です\n"
			flag["records"]["log"].append(log)
	
	# ドローダウンの計算
	drawdown =  max(flag["records"]["gross-profit"]) - flag["records"]["gross-profit"][-1] 
	if  drawdown  > flag["records"]["drawdown"]:
		flag["records"]["drawdown"] = drawdown
	
	return flag

# バックテストの集計用の関数
def backtest(flag):
	
	buy_gross_profit = np.sum(flag["records"]["buy-profit"])
	sell_gross_profit = np.sum(flag["records"]["sell-profit"])
	
	print("バックテストの結果")
	print("--------------------------")
	print("買いエントリの成績")
	print("--------------------------")
	print("トレード回数     :  {}回".format(flag["records"]["buy-count"] ))
	print("勝率             :  {}%".format(round(flag["records"]["buy-winning"] / flag["records"]["buy-count"] * 100,1)))
	print("平均リターン     :  {}%".format(round(np.average(flag["records"]["buy-return"]),2)))
	print("総損益           :  {}円".format( np.sum(flag["records"]["buy-profit"]) ))
	print("平均保有期間     :  {}足分".format( round(np.average(flag["records"]["buy-holding-periods"]),1) ))
	
	print("--------------------------")
	print("売りエントリの成績")
	print("--------------------------")
	print("トレード回数     :  {}回".format(flag["records"]["sell-count"] ))
	print("勝率             :  {}%".format(round(flag["records"]["sell-winning"] / flag["records"]["sell-count"] * 100,1)))
	print("平均リターン     :  {}%".format(round(np.average(flag["records"]["sell-return"]),2)))
	print("総損益           :  {}円".format( np.sum(flag["records"]["sell-profit"]) ))
	print("平均保有期間     :  {}足分".format( round(np.average(flag["records"]["sell-holding-periods"]),1) ))
	
	print("--------------------------")
	print("総合の成績")
	print("--------------------------")
	print("最大ドローダウン :  {0}円 / {1}%".format(-1 * flag["records"]["drawdown"], -1 * round(flag["records"]["drawdown"]/max(flag["records"]["gross-profit"])*100,1)  ))
	print("総損益           :  {}円".format( flag["records"]["gross-profit"][-1] ))
	print("手数料合計       :  {}円".format( -1 * np.sum(flag["records"]["slippage"]) ))
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])

	# 損益曲線をプロット
	del flag["records"]["gross-profit"][0]
	date_list = pd.to_datetime( flag["records"]["date"] )
	
	plt.plot( date_list, flag["records"]["gross-profit"] )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	plt.show()
	


# ここからメイン処理

# 価格チャートを取得
price = get_price(chart_sec,after=1483228800)

flag = {
	"order":{
		"exist" : False,
		"side" : "",
		"price" : 0,
		"count" : 0
	},
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"count":0
	},
	"records":{
		"buy-count": 0,
		"buy-winning" : 0,
		"buy-return":[],
		"buy-profit": [],
		"buy-holding-periods":[],
		
		"sell-count": 0,
		"sell-winning" : 0,
		"sell-return":[],
		"sell-profit":[],
		"sell-holding-periods":[],
		
		"drawdown": 0,
		"date":[],
		"gross-profit":[0],
		"slippage":[],
		"log":[]
	}
}


last_data = []
i = 0
while i < len(price):

	# ドンチャンの判定に使う過去30足分の安値・高値データを準備する
	if len(last_data) < term:
		last_data.append(price[i])
		flag = log_price(price[i],flag)
		time.sleep(wait)
		i += 1
		continue
	
	data = price[i]
	flag = log_price(data,flag)
	
	
	if flag["order"]["exist"]:
		flag = check_order( flag )
	elif flag["position"]["exist"]:
		flag = close_position( data,last_data,flag )
	else:
		flag = entry_signal( data,last_data,flag )
	
	
	# 過去データを30個に保つために先頭を削除
	del last_data[0]
	last_data.append( data )
	i += 1
	time.sleep(wait)


print("--------------------------")
print("テスト期間:")
print("開始時点 : " + str(price[0]["close_time_dt"]))
print("終了時点 : " + str(price[-1]["close_time_dt"]))
print(str(len(price)) + "件のローソク足データで検証")
print("--------------------------")

backtest(flag)

BTCFXのドンチャンチャネルブレイクアウトの勝率をバックテストで検証する

前回までの記事で、過去チャートのバックテストで自動売買BOTの勝率や平均リターン、総損益を検証する基本的な方法がわかったと思います。

今回の記事では、「ドンチャン・ブレイクアウト」というより実践的な売買ロジックを使って、さらに高度な自動売買BOTの評価方法を解説していきたいと思います!

ドンチャンブレイクアウトとは

ブレイクアウト手法とは、狭い値幅での揉み合い(レンジ相場)をブレイクアウトした瞬間を捕まえてエントリーし、その後のトレンドに乗って利益を出す順張り(トレンドフォロー型)の手法のことをいいます。

ブレイクアウトを捉えるための手法はさまざまなものが開発されていますが、シストレなどの自動売買BOTの世界では、以下の2つが最も定番です。

1.ドンチャン・チャネル・ブレイクアウト

過去n期間の最高値・最安値を更新したときに、その方向にエントリーする手法です。

例えば、過去20期間の最高値を更新したときに「買い」でエントリーします。次に過去20日の最安値を更新したときに決済して手仕舞いますが、このときさらに反対方向にそのままエントリー(ドテン)する場合も多いです。

このシンプルな戦略の優位性は、トレーダーの必読本である「タートル流投資の魔術」や、ジョン・ヒルの「究極のトレーディングガイド」などの本に詳しい解説があります。

非常に単純な売買ロジックなので、プログラミングしやすく、シストレや自動売買BOTの勉強にも最適です。

2.オープニングレンジ・ブレイクアウト

オープニングレンジ・ブレイクアウトも同じく「どうやったらブレイクアウトの開始点を、シンプルな数字とロジックで発見できるか?」という視点で開発された手法です。

こちらは、過去n期間の値幅の平均値(ATR)を基準値として利用し、ある足が始値から(基準値の)X%以上動いたらその方向にエントリーします。例えば、過去3期間の安値–高値の平均値が10万円でXが70%とすると、次の足で始値から7万円上に動いた時点で買いでエントリーします。

前の足が陰線か陽線かで場合分けをして、買いと売りとで別々のX%のパラメーターを使用することが多いです。

この戦略については、ラリーウィリアムズの「短期売買法」や、先ほどのジョンヒルの「究極のトレーディングガイド」などの本に詳しい解説があります。

3.戦略の使い方

どちらも多くのトレーダーが知っている手法ですが、実際には、どの時間軸でどういうパラメーターを使うか、どういうトレンド判定の条件やフィルターと組み合わて「騙し」を除去するか、どういう手仕舞いの方法を選択するか、などによって全く違う成績になります。

これらの手法は、あくまで売買ロジックを考えるときにベースのアイデアとして使いやすいというだけで、それ自体が必勝法というわけではありません。詳しい戦略のカスタマイズ方法などは、今後、解説していく予定です。

今回はバックテストの方法の解説記事なので、まずは一番シンプルな「ドンチャン・ブレイクアウト」のロジックをpythonでコーディングするところから始めます。

ドンチャンブレイクアウトは非常に単純なロジックなので、今まで勉強した知識だけでも、pythonのプログラムコードを書くことができます。早速、やっていきましょう!

ドンチャン・ブレイクアウトのpythonコード

プログラミング初心者の方は、いきなり複雑なロジックを書こうとしてはいけません。まずは必要最小限のシンプルな骨組みの部分を作り、そこから徐々に条件などを足していくのがお勧めです。

例えば、買いと売りの両方を考えて混乱するならまずは買いエントリーだけ実装しましょう。エントリーと手仕舞いの両方を考えて混乱するなら、まずは「n足後に無条件で手仕舞う」といったシンプルなロジックで実装しましょう。

ではドンチャン・ブレイクアウトの最もシンプルなロジックとは何でしょうか? 今回は以下のように定義してみます。

1.今回実装するロジック

1)過去n期間の最高値を直近の足の高値が上回ったら(その足の終値の指値で)買いエントリーする。過去n期間の最安値を直近の足の安値が下回ったら売りエントリーする。
2)買いエントリーをした場合、過去n期間の最安値を更新したら手仕舞い、さらに売りエントリーする
3)売りエントリーをした場合、過去n期間の最高値を更新したら手仕舞い、さらに買いエントリーする

これだけです!

ではこれを「第4回 BOT作成編」で勉強した内容を前提としながらコーディングしてみましょう。

2.pythonコード

以下は、Cryptowatchから過去のローソク足を取得して、20期間のドンチャンブレイクアウトで売買シグナルや手仕舞いのタイミングを確認するためのコードです。



import requests
from datetime import datetime
import time


#-----設定項目

chart_sec = 3600  # 1時間足を使用
term = 20         # 過去n期間の設定
wait = 0          # ループの待機時間



# CryptowatchのAPIを使用する関数
def get_price(min, before=0, after=0):
	price = []
	params = {"periods" : min }
	if before != 0:
		params["before"] = before
	if after != 0:
		params["after"] = after

	response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc",params)
	data = response.json()
	
	if data["result"][str(min)] is not None:
		for i in data["result"][str(min)]:
			if i[1] != 0 and i[2] != 0 and i[3] != 0 and i[4] != 0:
				price.append({ "close_time" : i[0],
					"close_time_dt" : datetime.fromtimestamp(i[0]).strftime('%Y/%m/%d %H:%M'),
					"open_price" : i[1],
					"high_price" : i[2],
					"low_price" : i[3],
					"close_price": i[4] })
		return price
		
	else:
		print("データが存在しません")
		return None


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


# ドンチャンブレイクを判定する関数
def donchian( data,last_data ):
	
	highest = max(i["high_price"] for i in last_data)
	if data["high_price"] > highest:
		return {"side":"BUY","price":highest}
	
	lowest = min(i["low_price"] for i in last_data)
	if data["low_price"] < lowest:
		return {"side":"SELL","price":lowest}
	
	return {"side" : None , "price":0}


# ドンチャンブレイクを判定してエントリー注文を出す関数
def entry_signal( data,last_data,flag ):
	signal = donchian( data,last_data )
	if signal["side"] == "BUY":
		print("過去{0}足の最高値{1}円を、直近の高値が{2}円でブレイクしました".format(term,signal["price"],data["high_price"]))
		print(str(data["close_price"]) + "円で買いの指値注文を出します")

		# ここに買い注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "BUY"

	if signal["side"] == "SELL":
		print("過去{0}足の最安値{1}円を、直近の安値が{2}円でブレイクしました".format(term,signal["price"],data["low_price"]))
		print(str(data["close_price"]) + "円で売りの指値注文を出します")

		# ここに売り注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "SELL"

	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


# 手仕舞いのシグナルが出たら決済の成行注文 + ドテン注文 を出す関数
def close_position( data,last_data,flag ):
	
	flag["position"]["count"] += 1
	signal = donchian( data,last_data )
	
	if flag["position"]["side"] == "BUY":
		if signal["side"] == "SELL":
			print("過去{0}足の最安値{1}円を、直近の安値が{2}円でブレイクしました".format(term,signal["price"],data["low_price"]))
			print("成行注文を出してポジションを決済します")
			
			# 決済の成行注文コードを入れる
			
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			print("さらに" + str(data["close_price"]) + "円で売りの指値注文を入れてドテンします")
			
			# ここに売り注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"
			
			

	if flag["position"]["side"] == "SELL":
		if signal["side"] == "BUY":
			print("過去{0}足の最高値{1}円を、直近の高値が{2}円でブレイクしました".format(term,signal["price"],data["high_price"]))
			print("成行注文を出してポジションを決済します")
			
			# 決済の成行注文コードを入れる
			
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			print("さらに" + str(data["close_price"]) + "円で買いの指値注文を入れてドテンします")
			
			# ここに買い注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "BUY"
			
	return flag


# ------------------------------
# ここからメイン処理
# ------------------------------

price = get_price(chart_sec)
last_data = []

flag = {
	"order":{
		"exist" : False,
		"side" : "",
		"count" : 0
	},
	"position":{
		"exist" : False,
		"side" : "",
		"count" : 0
	}
}

i = 0
while i < len(price):

	# ドンチャンの判定に使う過去n足分の安値・高値データを準備する
	if len(last_data) < term:
		last_data.append(price[i])
		print_price(price[i])
		time.sleep(wait)
		i += 1
		continue
	
	data = price[i]
	print_price(data)
	
	
	if flag["order"]["exist"]:
		flag = check_order( flag )
	elif flag["position"]["exist"]:
		flag = close_position( data,last_data,flag )
	else:
		flag = entry_signal( data,last_data,flag )
	
	
	# 過去データをn個ピッタリに保つために先頭を削除
	del last_data[0]
	last_data.append( data )
	i += 1
	time.sleep(wait)

基本的な仕組みは、第4回で作成した練習用のBOTと同じです。

data という変数に最新のローソク足のデータを入れて、last_data という変数に過去のローソク足のデータを入れて、それを比べることでエントリーシグナルの条件を満たしたどうかを判定しています。

1.ドンチャンブレイクアウトのデータ準備

ただしドンチャンブレイクアウトでは、過去n足(以下、20期間として説明します)の最高値をブレイクしたかどうかを判定する必要があるため、過去20期間分のローソク足データを保持する変数が必要です。

そこでメイン処理のループ文の最初に以下のような処理を入れます。

while i < len(price):
	# ドンチャンの判定に使う過去n足分の安値・高値データを準備する
	if len(last_data) < term:
		last_data.append(price[i])
		print_price(price[i])
		time.sleep(wait)
		i += 1
		continue

まず3行目の if len(last_data) < term: の部分で、last_dataに含まれているローソク足データの数が、20足分に達するまで上記の処理を実行するように指示しています。

continue は「これより下は実行せずにwhile文の先頭に戻る」という意味です。つまり、last_dataという変数に過去20足のローソク足データが保持されるまでは、この先の処理はおこなわず、この部分の処理だけをループで実行します。

いったん20足分のデータが溜まった後は、ループのたびに以下の処理を実行します。

# 過去データをn個ピッタリに保つために先頭を削除
del last_data[0]
last_data.append( data )

1行目のdel 変数名[0] で、last_data に含まれる先頭のデータを削除しています。さらに、2行目の last_data.append(data)で、最新のローソク足のデータを last_data の末尾に追加しています。

メイン処理のループのたびにこれを実行することで、last_data という変数の中身を、常に過去の新しい20期間分のローソク足データにぴったり揃えることができます。

2.ドンチャンブレイクアウトを判定する関数

# ドンチャンブレイクを判定する関数
def donchian( data,last_data ):
	
	highest = max(i["high_price"] for i in last_data)
	if data["high_price"] > highest:
		return {"side":"BUY","price":highest}
	
	lowest = min(i["low_price"] for i in last_data)
	if data["low_price"] < lowest:
		return {"side":"SELL","price":lowest}
	
	return {"side" : None , "price":0}

この関数で、ドンチアン・ブレイクアウトが発生したかどうか、どちらの方向に発生したかを判定しています。

ロングの方向にブレイクアウトが発生した場合は「BUY」シグナルを返し、ショートの方向にブレイクアウトが発生した場合は「SELL」シグナルを返します。また一緒に、ブレイクアウトされた最高値・最安値の価格を返す(return)ようにしておきます。

過去20足の最高値を更新したかどうか、を判定するロジックが以下の部分です。

highest = max(i["high_price"] for i in last_data)
if data["high_price"] > highest:
	return {"side":"BUY","price":highest}

1行目で、last_data に含まれる変数を1個ずつチェックし、["high_price"]だけを取り出した配列を作り、max()でそこから最大値を取得しています。

これはリスト内包表記というfor文を1行で書くための書き方ですが、少し難しく感じる方は、今まで勉強した知識を使って以下のように書いても構いません。

# 「リスト内包表記」で1行で書いた場合
highest = max(i["high_price"] for i in last_data)

# わかりやすく書いた場合
highest = 0
for i in last_data:
	if highest < i["high_price"]:
		highest = i["high_price"]

また、この最高値(highest)を、現在の最新のローソク足の高値(data["high_price"])と比べ、もし現在の最新のローソク足の高値の方が高ければ、ブレイクアウトの条件を満たしたことになります。

ブレイクアウトされていた場合は、その方向と価格をセットで返します。

3.エントリー注文を出す関数

def entry_signal( data,last_data,flag ):
	signal = donchian( data,last_data )
	if signal["side"] == "BUY":
		print("過去{0}足の最高値{1}円を、直近の高値が{2}円でブレイクしました".format(term,signal["price"],data["high_price"]))
		print(str(data["close_price"]) + "円で買いの指値注文を出します")

		# ここに買い注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "BUY"

最初に1行目でさきほど作成した donchian() 関数を呼び、ドンチャンブレイクアウトが発生しているかどうかを確認します。返ってきたデータは signal という変数で受け取ります。

signal = donchian( data,last_data )

これで"BUY"が返ってくれば買いでエントリーし、"SELL"が返ってくれば売りでエントリーしています。

4.決済注文とドテン注文を出す関数

def close_position( data,last_data,flag ):
	flag["position"]["count"] += 1 # ポジションを持って何足経過してるかを計測
	signal = donchian( data,last_data )

	if flag["position"]["side"] == "BUY":
		if signal["side"] == "SELL":
			print("過去{0}足の最安値{1}円を、直近の安値が{2}円でブレイクしました".format(term,signal["price"],data["low_price"]))
			print("成行注文を出してポジションを決済します")
			
			# 決済の成行注文コードを入れる
			
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			print("さらに" + str(data["close_price"]) + "円で売りの指値注文を入れてドテンします")
			
			# ここに売り注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"

こちらも基本的な手順は同じです。最初の1行目に signal = donchian() を実行して、ドンチャンブレイクアウトのシグナルを確認します。

もし買いポジションを持っている場合は、売り方向にブレイクアウトが発生した場合のみ、ポジションを決済します。

if flag["position"]["side"] == "BUY":
	if signal["side"] == "SELL":
		print("過去{0}足の最安値{1}円を、直近の安値が{2}円でブレイクしました".format(term,signal["price"],data["low_price"]))
		print("成行注文を出してポジションを決済します")
		
		# 決済の成行注文コードを入れる
		
		flag["position"]["exist"] = False
		flag["position"]["count"] = 0

ポジションを手仕舞ったので、flag変数の[exist]をFalseに更新しておきます。

さらに、そのまま売り方向でのエントリーを試みます。これがドテン注文です。

print("さらに" + str(data["close_price"]) + "円で売りの指値注文を入れてドテンします")

# ここに売り注文のコードを入れる

flag["order"]["exist"] = True
flag["order"]["side"] = "SELL"

ドテンでエントリー注文を出したので、またflag変数の[order][exist]をTrueに更新しておきます。これで、次のwhile文では「注文が通ったかどうか確認する関数」が呼ばれることになります。

それでは最後に実行結果を見ておきましょう。

実行結果

練習問題

このドンチャンブレイクアウトのバックテスト(勝率の検証用)のコードを作り、以下の項目を計算してみましょう。コードの書き方は前回の記事で説明したのと全く同じです。

・トレード回数
・勝率
・平均リターン
・総損益
・平均保有期間

解答サンプルのコード

次回記事

上記の練習問題で、こちらのドンチャンブレイクアウトの勝率をバックテストで検証すると、以下のような成績になります。

これだけシンプルな売買ロジックでも、成績はそれほど悪くないように見えますね。

トレンドフォロー戦略は、一般論として勝率が低いです。トレードの相場の大半がレンジ相場なので、ブレイクアウトの方向に順張りするロジックは「騙し」に引っかかりやすく、勝率が低くなりがちです。その代わり、1度トレンドに乗れれば大きな利益を得られるため、勝率が50%を下回っても期待値は正(プラス)になる、というのが典型的な教科書の考え方です。

バックテストの注意点

しかしこのような戦略には、特有の弱点もあります。それはドローダウンが大きいことです。平均保有期間が長いため、途中で一時的に大きく資産が目減りする時期があるかもしれません。

また月別のリターンにかなり大きなバラつきがある可能性もあります。最終的な平均リターンが2%でも、例えば、その範囲がマイナス数十%までバラついている可能性もあります。

これらのリスクは、上記の成績表を見ているだけではわかりません。

そのため、次回の記事では、バックテストのコードをさらに改良して、資産カーブ(曲線グラフ)を描画したり、平均リターンのバラツキ具合をグラフにする方法を解説します!

【解答】ドンチャンチャネルブレイクアウトBOTの勝率や平均リターンを検証するコード

前回の記事「BTCFXでドンチャンブレイクアウトの勝率をバックテストで検証する」の練習問題の回答コードです。


import requests
from datetime import datetime
import time
import numpy as np


#-----設定項目

chart_sec = 3600    # 1時間足を使用
term = 30           # 過去n日の設定
wait = 0            # ループの待機時間
lot = 1             # BTCの注文枚数
slippage = 0.001    # 手数料・スリッページ


# CryptowatchのAPIを使用する関数
def get_price(min, before=0, after=0):
	price = []
	params = {"periods" : min }
	if before != 0:
		params["before"] = before
	if after != 0:
		params["after"] = after

	response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc",params)
	data = response.json()
	
	if data["result"][str(min)] is not None:
		for i in data["result"][str(min)]:
			if i[1] != 0 and i[2] != 0 and i[3] != 0 and i[4] != 0:
				price.append({ "close_time" : i[0],
					"close_time_dt" : datetime.fromtimestamp(i[0]).strftime('%Y/%m/%d %H:%M'),
					"open_price" : i[1],
					"high_price" : i[2],
					"low_price" : i[3],
					"close_price": i[4] })
		return price
		
	else:
		print("データが存在しません")
		return None


# 時間と高値・安値をログに記録する関数
def log_price( data,flag ):
	log =  "時間: " + datetime.fromtimestamp(data["close_time"]).strftime('%Y/%m/%d %H:%M') + " 高値: " + str(data["high_price"]) + " 安値: " + str(data["low_price"]) + "\n"
	flag["records"]["log"].append(log)
	return flag


# ドンチャンブレイクを判定する関数
def donchian( data,last_data ):
	
	highest = max(i["high_price"] for i in last_data)
	if data["high_price"] > highest:
		return {"side":"BUY","price":highest}
	
	lowest = min(i["low_price"] for i in last_data)
	if data["low_price"] < lowest:
		return {"side":"SELL","price":lowest}
	
	return {"side" : None , "price":0}


# ドンチャンブレイクを判定してエントリー注文を出す関数
def entry_signal( data,last_data,flag ):
	signal = donchian( data,last_data )
	if signal["side"] == "BUY":
		flag["records"]["log"].append("過去{0}足の最高値{1}円を、直近の高値が{2}円でブレイクしました\n".format(term,signal["price"],data["high_price"]))
		flag["records"]["log"].append(str(data["close_price"]) + "円で買いの指値注文を出します\n")

		# ここに買い注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "BUY"
		flag["order"]["price"] = round(data["close_price"] * lot)

	if signal["side"] == "SELL":
		flag["records"]["log"].append("過去{0}足の最安値{1}円を、直近の安値が{2}円でブレイクしました\n".format(term,signal["price"],data["low_price"]))
		flag["records"]["log"].append(str(data["close_price"]) + "円で売りの指値注文を出します\n")

		# ここに売り注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "SELL"
		flag["order"]["price"] = round(data["close_price"] * lot)

	return flag



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


# 手仕舞いのシグナルが出たら決済の成行注文 + ドテン注文 を出す関数
def close_position( data,last_data,flag ):
	
	flag["position"]["count"] += 1
	signal = donchian( data,last_data )
	
	if flag["position"]["side"] == "BUY":
		if signal["side"] == "SELL":
			flag["records"]["log"].append("過去{0}足の最安値{1}円を、直近の安値が{2}円でブレイクしました\n".format(term,signal["price"],data["low_price"]))
			flag["records"]["log"].append(str(data["close_price"]) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			flag["records"]["log"].append("さらに" + str(data["close_price"]) + "円で売りの指値注文を入れてドテンします\n")
			
			# ここに売り注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"
			flag["order"]["price"] = round(data["close_price"] * lot)
			

	if flag["position"]["side"] == "SELL":
		if signal["side"] == "BUY":
			flag["records"]["log"].append("過去{0}足の最高値{1}円を、直近の高値が{2}円でブレイクしました\n".format(term,signal["price"],data["high_price"]))
			flag["records"]["log"].append(str(data["close_price"]) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			flag["records"]["log"].append("さらに" + str(data["close_price"]) + "円で買いの指値注文を入れてドテンします\n")
			
			# ここに買い注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "BUY"
			flag["order"]["price"] = round(data["close_price"] * lot)
			
	return flag


# 各トレードのパフォーマンスを記録する関数
def records(flag,data):
	
	# 取引手数料等の計算
	entry_price = flag["position"]["price"]
	exit_price = round(data["close_price"] * lot)
	trade_cost = round( exit_price * slippage )
	
	log = "スリッページ・手数料として " + str(trade_cost) + "円を考慮します\n"
	flag["records"]["log"].append(log)
	flag["records"]["slippage"].append(trade_cost)
	
	# 値幅の計算
	buy_profit = exit_price - entry_price - trade_cost
	sell_profit = entry_price - exit_price - trade_cost
	
	# 利益が出てるかの計算
	if flag["position"]["side"] == "BUY":
		flag["records"]["buy-count"] += 1
		flag["records"]["buy-profit"].append( buy_profit )
		flag["records"]["buy-return"].append( round( buy_profit / entry_price * 100, 4 ))
		flag["records"]["buy-holding-periods"].append( flag["position"]["count"] )
		if buy_profit  > 0:
			flag["records"]["buy-winning"] += 1
			log = str(buy_profit) + "円の利益です\n"
			flag["records"]["log"].append(log)
		else:
			log = str(buy_profit) + "円の損失です\n"
			flag["records"]["log"].append(log)
	
	if flag["position"]["side"] == "SELL":
		flag["records"]["sell-count"] += 1
		flag["records"]["sell-profit"].append( sell_profit )
		flag["records"]["sell-return"].append( round( sell_profit / entry_price * 100, 4 ))
		flag["records"]["sell-holding-periods"].append( flag["position"]["count"] )
		if sell_profit > 0:
			flag["records"]["sell-winning"] += 1
			log = str(sell_profit) + "円の利益です\n"
			flag["records"]["log"].append(log)
		else:
			log = str(sell_profit) + "円の損失です\n"
			flag["records"]["log"].append(log)
	
	return flag

# バックテストの集計用の関数
def backtest(flag):
	
	buy_gross_profit = np.sum(flag["records"]["buy-profit"])
	sell_gross_profit = np.sum(flag["records"]["sell-profit"])
	
	print("バックテストの結果")
	print("--------------------------")
	print("買いエントリの成績")
	print("--------------------------")
	print("トレード回数  :  {}回".format(flag["records"]["buy-count"] ))
	print("勝率          :  {}%".format(round(flag["records"]["buy-winning"] / flag["records"]["buy-count"] * 100,1)))
	print("平均リターン  :  {}%".format(round(np.average(flag["records"]["buy-return"]),2)))
	print("総損益        :  {}円".format( np.sum(flag["records"]["buy-profit"]) ))
	print("平均保有期間  :  {}足分".format( round(np.average(flag["records"]["buy-holding-periods"]),1) ))
	
	print("--------------------------")
	print("売りエントリの成績")
	print("--------------------------")
	print("トレード回数  :  {}回".format(flag["records"]["sell-count"] ))
	print("勝率          :  {}%".format(round(flag["records"]["sell-winning"] / flag["records"]["sell-count"] * 100,1)))
	print("平均リターン  :  {}%".format(round(np.average(flag["records"]["sell-return"]),2)))
	print("総損益        :  {}円".format( np.sum(flag["records"]["sell-profit"]) ))
	print("平均保有期間  :  {}足分".format( round(np.average(flag["records"]["sell-holding-periods"]),1) ))
	
	print("--------------------------")
	print("総合の成績")
	print("--------------------------")
	print("総損益        :  {}円".format( np.sum(flag["records"]["sell-profit"]) + np.sum(flag["records"]["buy-profit"]) ))
	print("手数料合計    :  {}円".format( np.sum(flag["records"]["slippage"]) ))
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])



# ここからメイン処理

# 価格チャートを取得
price = get_price(chart_sec,after=1483228800)

flag = {
	"order":{
		"exist" : False,
		"side" : "",
		"price" : 0,
		"count" : 0
	},
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"count":0
	},
	"records":{
		"buy-count": 0,
		"buy-winning" : 0,
		"buy-return":[],
		"buy-profit": [],
		"buy-holding-periods":[],
		
		"sell-count": 0,
		"sell-winning" : 0,
		"sell-return":[],
		"sell-profit":[],
		"sell-holding-periods":[],
		
		"slippage":[],
		"log":[]
	}
}


last_data = []
i = 0
while i < len(price):

	# ドンチャンの判定に使う過去30日分の安値・高値データを準備する
	if len(last_data) < term:
		last_data.append(price[i])
		flag = log_price(price[i],flag)
		time.sleep(wait)
		i += 1
		continue
	
	data = price[i]
	flag = log_price(data,flag)
	
	
	if flag["order"]["exist"]:
		flag = check_order( flag )
	elif flag["position"]["exist"]:
		flag = close_position( data,last_data,flag )
	else:
		flag = entry_signal( data,last_data,flag )
	
	
	# 過去データを30個に保つために先頭を削除
	del last_data[0]
	last_data.append( data )
	i += 1
	time.sleep(wait)


print("--------------------------")
print("テスト期間:")
print("開始時点 : " + str(price[0]["close_time_dt"]))
print("終了時点 : " + str(price[-1]["close_time_dt"]))
print(str(len(price)) + "件のローソク足データで検証")
print("--------------------------")

backtest(flag)