バックテストと実際の成績乖離の原因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

BitflyerFXの成行注文のスリッページのコストの影響を測定しよう

BOTの運用を成行注文で行っている場合、バックテスト上の成績と実際の成績が乖離する原因の1つにスリッページがあります。

成行注文や逆指値注文は、必ずしも理想の価格で約定するわけではありません。例えば、バックテスト上では「100万円で買いエントリーして98万円で損切りした」はずでも、成行注文が1%不利な価格で約定した場合、実際の運用は「101万円で買って97万円で売却する」ことになります。

この場合、1回の取引の損失は-2%(バックテスト)から-約4%(実際の運用)まで悪化します。

実際にどのくらいのスリッページが発生するかは、おそらく売買ロジックやボラティリティ、成行注文の数量によって異なるので、一概に何%と言うことはできません。この記事では、測定方法などを解説しますが、実際の数値は参考程度にしてください。

スリッページの計算方法

実際のスリッページの影響は、自分のロジックでBOTを一定の期間や回数動かしてみて、シグナル価格と約定価格の乖離を記録するしかないと思います。

例えば、以下は実際にログから過去300回程度の取引を抽出して、シグナル価格と約定価格の乖離をまとめたものの一部です。こちらの記事の方法を参考に、普段からBOTでシグナル価格や約定価格をログファイルに出力しておけば、簡単に集計できます。


「滑り」の欄でプラスの数値になっているのは、想定していた価格よりも不利な価格で約定したときの乖離率(スリッページ)です。マイナスの数値になっているものは、想定していた価格よりも有利な価格で約定したときの乖離率です。

売買ロジックにもよると思いますが、実際に集計してみると「予想よりも有利な価格」で約定するケースも結構あります。後ほど、このヒストグラムを紹介します。

1)スリッページの計算式

この記事では、スリッページの定義を「シグナル価格に対して何%不利な価格で約定したか?」のコストを表す数値とします。そのままバックテストで使えるようにするためです。

計算式は以下になります。

スリッページの計算式では、注文がエントリーなのか手仕舞いなのかを区別する必要はありません。買い注文は想定より高く約定したら不利で、売り注文は想定より安く約定したら不利です。このことは常に成り立ちます。

2)ログから欲しい数値を集計する方法

ログなどのテキストファイルから、欲しい数値(ここではシグナル価格と約定価格)を抜き出す方法を紹介します。どのようなログを出力するかは人によって違うので、ここでは具体的なコードというより、考え方だけ紹介しておきます。

▽ 例)ログのフォーマット


時間: 2019/04/26 06:00 高値: 622124 安値: 618640 終値: 622000
時間: 2019/04/26 07:00 高値: 622423 安値: 609400 終値: 610253
時間: 2019/04/26 08:00 高値: 610597 安値: 555556 終値: 580936
過去**足の最安値609350円を、直近の価格が580936円でブレイクしました
現在のアカウント残高は**円です
現在の**期間の平均ボラティリティは**円です
許容リスクから購入できる枚数は最大**BTCまでです
**回に分けて**BTCずつ注文します
580936円あたりに**BTCの売りの成行注文を出します
--------------------
{'info': {'child_order_acceptance_id':***}
--------------------
すべての成行注文が執行されました
執行価格は平均 579700円です
 

このテキストから抽出したい情報を以下の2つとします。

1)シグナル価格(バックテスト上で用いている価格) 580936円
2)実際の約定価格 579700円
3)スリッページ(乖離率) +0.21%

これを抽出するには、正規表現 というものを使うと便利です。例えば、以下のようなコードを作成すれば、ログファイルを1行ずつ読み込んで、欲しい数字だけを抽出することができます。

▽ コードの例


# 正規表現を扱うライブラリ
import re

# テキストファイルを読み込む
f = open("./logfile.txt", "r", encoding="UTF-8")
line = f.readline()

# 抽出したい情報
signal_price    = []
execution_price = []

# 1行ずつ読み込んで処理
while line:
	line = f.readline()
	if "ブレイクしました" in line:
		price = re.match(".*価格が?(\d+)円でブレイクしました", line).group(1)
		price = int(price) # 数値にする
		signal_price.append( price )
	
	if "執行価格は" in line:
		price = re.match(".*執行価格は.*?(\d+)円です", line).group(1)
		price = int(price)
		execution_price.append( price )

f.close()
print(signal_price)
print(execution_price)

re.match() の関数の箇所が「正規表現」です。

正規表現とは、毎回、出現する文字や数字の内容が違っている場合でも、その出現パターンさえ同じであれば、()で括られた箇所だけを抜き出すことができる便利な記述ルールです。以下の記事で、詳しい正規表現のルールが記述されているので参考にしてください。

参考:Qiita「わかりやすいpythonの正規表現の例」

実際のログファイルには、BOTの停止や手動での決済が混じってる場合も多く、欲しい情報を正しくセットで抜き出すためには、もう少し複雑なコードを考える必要があります。が、基本的には上記の方法の組み合わせで実現できます。

欲しい数字を全て抽出したら、以前の記事で紹介したようにpandasでデータフレームに変換して、csvなどの形式で出力すれば準備完了です。

3)スリッページの集計

全ての取引のスリッページ(シグナル価格と約定価格との乖離率)をcsvにまとめることができたら、次にそれを分析してみましょう。
今回は以下のような分析結果を作る方法を紹介します。


私の運用BOTの場合は上記のような集計結果になりました。

全取引のうち、およそ6割が不利な価格で約定し、約3割が有利な価格で約定しています。また順張りのロジックのため、明らかにエントリー時に偏って約定価格の滑りが発生しています。複数回に分けてエントリーする場合、後半になるほどバックテストで想定するより不利な価格で約定しています。一方、損切りや利確時にはほとんど滑りは発生していませんでした。

また全取引のうち95%は-0.3%~0.6%の乖離で約定していて、平均としては 0.1%程度の滑りを考慮すれば足りそうだとわかりました。たまに±3%前後で大きく滑っていますが、異常値は必ずしも不利な方向に発生するわけではないようです。

4)集計コードの作り方

最初に示したようなフォーマットのcsvファイルを無事に作成できたと仮定して、話を進めましょう。
具体的な分析のコードが以下です。

▽ 分析用のコード


import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
import csv

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

# 集計データを読み込み
data = pd.read_csv("./data.csv", encoding="UTF-8", sep=",")

# 全取引の95%が収まる範囲を計算
percentile_5  = round( np.percentile( data["滑り"].dropna(),5 ) *100,3) # 下位5%
percentile_95 = round( np.percentile( data["滑り"].dropna(),95) *100,3) # 上位5%

print("-----------------------------------")
print(" 1BTC辺りのスリッページの測定")
print("-----------------------------------")

print(" 取引回数 : {}回".format( len(data["滑り"]) ))
print(" 平均値   : {}%".format( round(data["滑り"].mean() * 100,3)  ))
print(" 標準偏差 : {}%".format( round(data["滑り"].std() * 100,3 )  ))
print(" 最大値   : {}%".format( data["滑り"].max() * 100 ))
print(" 最小値   : {}%".format( data["滑り"].min() * 100 ))
print(" 95%区間  : {}%~{}%".format( percentile_5, percentile_95 ))
print(" 不利な約定回数 :  {}回 / {}回".format( len( data[ data["滑り"]>0 ]), len(data) ))
print(" 有利な約定回数 :  {}回 / {}回".format( len( data[ data["滑り"]<0 ]), len(data) ))
print("")

print("-----------------------------------")

print("")
print("成行買いの滑り          :   平均{}%".format( round( data[ data["売買方向"] == "BUY" ]["滑り"].mean(),4) * 100 ))
print("成行売りの滑り          :   平均{}%".format( round( data[ data["売買方向"] == "SELL" ]["滑り"].mean(),4) * 100 ))
print("エントリー1回目の滑り  :   平均{}%".format( round( data[ data["内容"] == "entry1" ]["滑り"].mean(),4) * 100 ))
print("エントリー2回目の滑り  :   平均{}%".format( round( data[ data["内容"] == "entry2" ]["滑り"].mean(),4) * 100 ))
print("損切や手仕舞い時の滑り  :   平均{}%".format( round( data[ data["内容"] == "close" ]["滑り"].mean(),6) * 100 ))
#print("ボラティリティとの相関係数  :   {}".format( data["滑り"].corr( data["ボラティリティ"] ) ))

print("----------------------------------")

#------------------------------------------
# グラフの描画
#------------------------------------------

n1, n2 = np.histogram( data["滑り"].dropna()*100, bins=200 ) 
# numpy の histogram() を使って n1(binの境界)とn2(度数)を取得する
# dropna() は空白行を落とすために使用

# ヒストグラムを表示
y = n1/float(n1.sum())         # 頻度(度数)をもとに確率を計算する(縦軸)
x = (n2[1:] + n2[:-1])/2       # 境界[0番目,1番目,2番目,...5番目]と[1番目,2番目,3番目,...6番目]からbinの中央値を計算(横軸)
x_width  = n2[1] - n2[0]       # binの幅を取得
plt.bar( x, y, width=x_width ) # 棒グラフの描画

# 平均値をグラフに表示
plt.axvline( data["滑り"].mean()*100, color="red",linewidth=1, label="平均値 {}%".format( round(data["滑り"].mean()*100,2 ) ))

# 95パーセンタイル区間を塗りつぶし
plt.axvspan( percentile_5, percentile_95, color="blue",alpha=0.1, label="95パーセンタイル区間") 

plt.grid(True)
plt.xlabel("シグナル価格と執行価格の乖離(スリッページ)%")
plt.ylabel("頻度(確率)")
plt.legend()
plt.show()


 

csvファイルを読み込んで、pandasを使って集計しています。
pandasの使い方はこちらの記事で解説しているので参考にしてください。

またmatplotlibのhist()関数では、縦軸の目盛りを確率にすることが難しいようだったので、numpyでヒストグラムにしました。

スリッページの影響

スリッページコストが最終的な成績にどの程度の影響を及ぼすかは、BOTの売買頻度によって異なります。

期待リターン2%の取引を年間200回するBOTと、期待リターン0.2%の取引を年間2000回するBOTでは、理論上の期待リターンは同じです。しかしここに0.1%のスリッページコストを考慮すると、前者の利回りは年間380%である一方、後者は期待リターンが100%になり、最終成績は4倍近い差となります。

BTCFXの自動売買BOTで売買の状況をLINEに通知させる方法

外出時や仕事中でもBOTの稼働状況を監視して把握する方法の2つ目です。
前回の記事ではコマンドラインへの出力結果と同じ内容をメールで送信する方法を解説しました。

今回はもう1つの定番のLINE通知の方法について解説します! やる前は難しそうに感じるかもしれませんが、15分もかからないほど簡単なので安心してください。

BOTの状況をLINEに通知する方法

今回の記事では、PythonでLINEに通知する関数を作ります。
そしてprint文と同じように要所要所に1行入れるだけで、そのテキスト内容をLINEに飛ばす方法を解説します。

手順

(1)LINE公式ページで開発者向けのアクセストークンを発行する
(2)Pythonで自由なメッセージをLINEに飛ばす関数を作る
(3)エントリーや決済・損切りなどの箇所に1行追加して通知する

では、やっていきましょう!

1.LINE公式でアクセストークンを取得する

(1)まず最初に以下の「LINE Notify」のページにアクセスします。
https://notify-bot.line.me/ja/

右上の「ログイン」をクリックして、ご利用のLINEアカウントでログインしてください。

登録したメールアドレスを忘れた方は、LINEのスマホ端末側で、「設定 -> アカウント -> メールアドレス」で確認できます。

ログインが完了したら、右上のメニューから「マイページ」を選択してください。

マイページをクリックして画面をスクロールすると、下の方に「アクセストークンの発行(開発者向け)」という箇所があるので、そこで「トークンを発行する」をクリックしてください。

▽ アクセストークンの発行(開発者向け)

すると、以下のようなトークン発行画面になります。

▽ トークン発行画面

設定する箇所は、「トークン名」と「トークルーム」の2つです。

トークン名は何でも構いませんが、この名前で通知が来るので、通知が来たときにわかる名前を付けておきましょう。ここでは「自動売買BOT(チャネルブレイクアウト)」としています。トークルームの方は、「1:1でLINE Notifyから通知を受け取る」を選択します。

できたら「発行する」をクリックします。
すると以下のようにトークンが表示されます。

▽ 発行されたトークン番号

このトークン画面は再表示できないので、コピーをしてメモ帳などに保存しておきましょう。

ただしもし間違えて閉じてしまっても、もう1度、新しいトークンを作ればいいだけなので大した問題ではありません。

2.LINE通知する関数を作る

次はBOTのPythonコードの方です。
以下のようなLINE通知をする関数を作ります。


import requests

# 設定項目
line_token = "************"    # さっき保存したLINEトークン

# LINEに通知する関数
def line_notify( text ):
	url = "https://notify-api.line.me/api/notify"
	data = {"message" : text}
	headers = {"Authorization": "Bearer " + line_token} 
	requests.post(url, data=data, headers=headers)

はい、LINE通知する関数はこれで完成です!

これでLINEに通知を送りたい箇所に、 line_notify(“テキスト”) のかたちでコードを1行追加すれば、LINE通知を実装することができます。

LINE通知の実装例

例えば、前回の第4回「BOT作成編」の章で作成したBOTでは、エントリーするたびに以下のような出力をしていましたよね。


# 売買の決済をする箇所
if data["close_price"] < last_data["close_price"]:
	print("前回の終値を下回ったので" + str(data["close_price"]) + "あたりで成行で決済します")

ここに以下のように行を追加すれば、LINEにも同じ通知を送ることができます。


# 売買の決済をする箇所
if data["close_price"] < last_data["close_price"]:
	print("前回の終値を下回ったので" + str(data["close_price"]) + "あたりで成行で決済します")
	line_notify("前回の終値を下回ったので" + str(data["close_price"]) + "あたりで成行で決済します")

同じことを2回書かないといけませんが、エントリー・決済・損切りなどの重要な箇所だけLINEに通知させたいのであれば、この方法が一番いいと思います。

3.出力を全て1行にまとめたい場合

逆にすべての print文の出力結果をLINEにも通知したい場合は、全部の箇所で同じことを2回書くよりも、1つの関数にまとめてしまった方が管理が楽です。

例えば、前回の記事の内容とあわせて、コマンドラインへの標準出力・ログファイルへの書き出し・LINEへの通知をすべて1つの関数にまとめておけば、コードはもっとすっきりします。


import requests
from logging import getLogger,Formatter,StreamHandler,FileHandler,INFO

# ログの設定
logger = getLogger(__name__)
handlerSh = StreamHandler()
handlerFile = FileHandler("c:/Pydoc/helloBot.log")
handlerSh.setLevel(INFO)
handlerFile.setLevel(INFO)
logger.setLevel(INFO)
logger.addHandler(handlerSh)
logger.addHandler(handlerFile)

# LINEの設定
line_token = "************"

# print文のかわりに使用
def print_log( text ):
	
	# コマンドラインへの出力とファイル保存
	logger.info( text )
	
	# LINEへの通知
	url = "https://notify-api.line.me/api/notify"
	data = {"message" : text}
	headers = {"Authorization": "Bearer " + line_token} 
	requests.post(url, data=data, headers=headers)

これで、全てのprint("")文の箇所を上記の print_log("") に置き換えれば、同じことを2回書かなくても済みます。

例えば、以下の1行だけで、コマンドラインへの出力・ファイルへのログ保存・LINEへの通知がすべて実行できます。


print_log("前回の終値を下回ったので" + str(data["close_price"]) + "あたりで成行で決済します")

ただし全ての出力内容をいちいちLINEに通知させるのは、少し鬱陶しいかもしれませんね。

BOTの稼働状況の監視(1)定期的にコマンドラインの標準出力をメールで受け取ろう!

自宅やクラウド、WindowsVPSなどでBOTを稼働したまま外出していると、今のBOTの稼働状況が気になることがあります。

売買のタイミングでLINE通知する方法もありますが、私はできればコマンドラインの内容を全部確認したいです。そこで、この記事では、コマンドラインに出力されるログの内容をそのまま6時間おきにメールで転送してBOTの稼働状況を把握する方法を解説します!

▽ コマンドラインの内容を外でも把握したい

▽ メールで定期的に通知する方法

それではやっていきましょう!

全体の流れ

これはあくまで私のやり方ですが、上記のことを実現するために以下の2つの手順を実行しています。

(1)ログをファイルに書き出す

今まではコマンドラインへの出力はすべてprint()文を使っていましたが、pythonには標準のログ用モジュール(logging)が存在します。これを使って、全てのprint文を logger.info(“”)に置き換えると、コマンドラインに出力する内容をリアルタイムで同時にログファイルに書き出すことができます。

(2)ログファイルの中身をメールする

トレードBOT本体とは別のpythonファイル(例:mail.py)を作って、その別BOTに定期的にログファイルの内容を指定のメールアドレスに送信させます。実行は、Windowsタスクスケジューラを使って6時間おきに自動実行します。

1.標準出力の内容をログファイルに書き出す

pythonには標準で logging というログ用のモジュールが用意されています。

このログ用モジュールを使って、いままでprint文で出力していた箇所を、すべてlogging.info に置き換えます。すると、コマンドラインに出力した文を同時にログファイルにも書き出すことができます。

説明だけではピンと来ないと思うので、実際にやってみましょう! 例えば、以下のように1秒おきにprint(“Hello!”)と出力するようなコードを作ってみてください。


import time
while True:

	print("Hello!")
	time.sleep(1)


これを実行すると、以下のように1秒おきにコマンドラインに「Hello!」と出力されます。

ログ用モジュールを使う場合

では全く同じことを、ログ用モジュールを使って書いてみましょう。ログ用モジュールで書き直すと以下のようになります。


import time
from logging import getLogger,Formatter,StreamHandler,FileHandler,INFO

logger = getLogger(__name__)
handlerSh = StreamHandler()
handlerFile = FileHandler("c:/Pydoc/helloBot.log") # ログファイルの出力先とファイル名を指定
handlerSh.setLevel(INFO)
handlerFile.setLevel(INFO)
logger.setLevel(INFO)
logger.addHandler(handlerSh)
logger.addHandler(handlerFile)

while True:

	logger.info("Hello!")
	time.sleep(1)


2行目~10行目までは、ただの「おまじない」だと思ってコピーしていただいても構いません。ログファイルの出力先とファイル名の箇所だけご自身で必要に応じて変更してください。

そして先ほど、print(“Hello!”) と書いた箇所を、logger.info(“Hello!”) に書き換えます。これを実行すると以下のようになります。

先ほどのprint文と同じように、コマンドラインに「Hello!」が出力されています。しかし同時に指定したフォルダに「helloBot.log」というログファイルが出力されている点に注目してください。

このログファイルを開いてみましょう。「Hello!」BOTは実行中のまま開いても構いません。すると以下のように、メモ帳にコマンドラインと同じ内容が出力されているのがわかります。

このファイルを同時にリアルタイムで、別のpythonファイルから読み込んでメールするようなコードを書けば、コマンドラインの出力結果を定期的にメールで受け取ることができるわけです!

▽ (例)Bitflyerの自動売買BOTのログファイル

ログ機能の説明の補足

なお、ここでは「コマンドラインへの標準出力を同時にファイルに書き出す」ということだけがやりたかったので、ログ機能(logging)の説明は最低限にとどめました。Python標準のログ機能についてもっと詳しく知りたい方は、以下の外部記事が参考になると思います。

[Quitta]Pythonのログ出力のまとめ
Pythonでのロギング機能を実装してみる

2.出力したファイルの内容をメールで転送する

次に別のpythonファイルを作って、さきほどのログファイルの内容を定期的に指定のメールアドレスに転送するスクリプトを作ります。

監視用のpythonコードは、本体BOTとは全く関係のない機能なので、切り離して別プロセスで実行します。こうしておけば、複数BOTを運用するときでも複数のログを同時に監視できますし、万が一、トラブルで止まったりしても、本体BOTに影響を与えないので安心です。

Gメールを送信するpythonコード

Pythonでメールを送るのは、GmailのようなWebメールを送信元として使うのであれば、全く難しくありません。
以下のようなコードを作るだけです。


import smtplib
from email.message import EmailMessage
from datetime import datetime

with open( "helloBot.log" ) as file:   # さっきのログファイルを指定して読み込み
	msg = EmailMessage()
	msg.set_content(file.read())

msg["Subject"] = "BOT稼働状況の通知:{}".format(datetime.now().strftime("%Y-%m-%d-%H-%M"))
msg["From"] = "xxxxxxxxxxxx@gmail.com"         # 送信元のアドレス
msg["To"] = "xxxxxxxxxxxx@gmail.com"           # 受け取りたいアドレス

server = smtplib.SMTP("smtp.gmail.com",587)    # これはGmailのSMTPなら共通
server.starttls()
server.login("Account", "PassWord")            # Gmailのアカウント名とパスワード
server.sendmail( msg["From"],msg["To"],msg.as_string() )
server.close()


メールの送信には、SMTPというプロトコルを使います。

GmailのようなWebメールであれば、メール送信サーバーは「smtp.gmail.com」、TLS/STARTTLSのポートは「587」と決まっているので、上記のようなコードを書いて、アカウント名とパスワードを入れれば、どこからでもpythonでメールの送信を実行できます。

なお、以下のページを参考にさせていただきました。
ありがとうございます。

Python3公式ドキュメント(smtplib)
Python3公式ドキュメント(email使用例)
[Quitta]Pythonでメール送信~Gmail編~

実行手順

ではこのコードを、「mail.py」などの別ファイルで保存して実行してみましょう!

より実践っぽく試したい方は、さきほどの「helloBot.py」を動かしたまま、Anacondaプロンプトをもう1画面立ち上げて、同時に「mail.py」を実行してみるとわかりやすいと思います。

▽ 左画面「helloBot.py」実行中、右画面「mail.py」実行

このように並行して複数のpythonプログラムを別のコマンドラインから実行することを、「別プロセス」といいます。

「別プロセスってよく聞くけどどういう意味だろうな?」と思っっていた方は、このようにコマンドプロンプトの画面を複数立ち上がる状況をイメージすればわかりやすいと思います。

実行結果

以下のように受信したいアドレス宛にメールが届いていれば成功です!
ちゃんとコマンドラインに標準出力されているのと同じ内容が届いていることを確認してください。

もしログインがブロックされた場合

なお、アカウントによってはPython経由でのGmailアカウントへのログインがブロックされることがあります。

間違って不審なアプリからのログインだと判断された場合、「ブロックされたログインについてご確認ください」という警告メールが届きます。

この場合、ログインを許可させるためには、同じメールの下の方の文章にある「安全性の低いアプリへのアクセスを許可」をクリックして、アプリからのログインを許可する必要があります。

しかし、これをするとGmailのセキュリティレベルが下がってしまいます。

そのため、個人的には「普段使いのメールアドレスを送信元アドレスに指定しない方がいい」と思います。つまりBOT稼働状況の通知用に新しい専用のメールアドレスを作った方がいいです。私はそうしました。

3.Windowsタスクスケジューラで自動実行する

さて、最後のステップです!
さきほど作成した「mail.py」をWindowsのタスクスケジューラに登録して、定期的に自動で実行して貰いましょう! 私は1日に4回ほど状況を教えて欲しいので、6時間おきに設定しています。

なお、これはWindowsの場合の手順です。一般のサーバーを使っている場合はcronの設定で同様のことができます。

1)タスクスケジューラを探す

WindowsVPSの方は、まず左下のメニューを右クリックして「検索」をクリックします。すると右上に検索窓が出ますので、「タスクスケジューラ」を検索します。普通のWindowsOSの方はスタートメニューから検索するだけです。

2)タスクスケジューラの設定

あとは基本的な流れは、別の記事「Windowsのタスクスケジューラを使ってpythonを定期的に自動実行しよう!」で解説したのと同じ内容なので、そちらを参考にしてください。

そちらを読んでいただく前提で、少し違うところだけ解説しておきます。

6時間おきの実行方法

Windowsタスクスケジューラーには、トリガーは「毎日」「毎週」「毎月」の選択肢しかなく、「6時間おき」というのは存在しません。そこで、このトリガーの画面では「1回限り」を選択します。時間は6時間後くらいを指定しておけばいいでしょう。

そして全てのタスクの登録作業が終わったら、左メニューの「タスク スケジューラライブラリ」から先ほど作成したタスクを探します。見つけたら選択してダブルクリックしてください。

すると以下のようなウィンドウが立ち上がると思うので、「トリガー」タブを選択して「編集」をクリックします。

そして詳細設定の箇所で、「繰り返し間隔」を6時間にし、「継続時間」を「無期限」に設定します。これで1回限りのトリガーのタスクを、6時間おきにずっと繰り返し実行することが可能になります。

まとめ

さて、これでWindowsVPSなどの外部サーバーでBOTを稼働したまま、外出していても、定期的に実行状況をメールで配信して貰うことができるようになりました。出先でもスマホで確認できるので便利です。

次回は、自動売買BOTがエントリーしたり決済をしたタイミング、またはBOT側で把握してるポジション情報と実際のポジション情報が一致しなくなった場合などのトラブル発生時にLINEでそれを通知する方法を解説します!

BTCFXの出来高を使ったフィルターの有効性をScipyの「t検定」で検証する

トレードをやっている方なら「真のブレイクアウトは出来高を伴う」という投資の助言をよく聞くことがあると思います。

今回の記事では、出来高を使ったフィルターでブレイクアウトの騙しを取り除くことが可能かどうかを検証します。またフィルターに本当に効果があるのかどうか、ただの偶然でないかどうかをpythonで統計的に検証する方法を解説します!

出来高を使ったフィルター

たしかにチャートなどを見ていると、過去30期間のブレイクアウトなどの場面では出来高が平均を上回っているケースが多いような気がします。

もしブレイクアウトが出来高を伴わなければ、それは「騙し」であるというようなことも、よくトレード本には書かれています。例えば、「出来高・価格分析の完全ガイド」などの本に詳しい説明があります。

では、例えば、過去30期間の高値/安値のブレイクアウト時に、「過去30期間の平均出来高を上回っているかどうか?」でフィルターをかける意味はあるのでしょうか? 今回はこれを検証してみたいと思います!

Pythonコード

まずはサクッと前回の記事と同じようにフィルターを作ってみましょう。
まず本編でCryptowatchの出来高データを使うのは初めてなので、以下を忘れずに追記しておいてください。


# CryptowatchのAPIを使用する関数
def get_price(min, before=0, after=0):

	# 略
	if data["result"][str(min)] is not None:
		for i in data["result"][str(min)]:
			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],
				"volume": i[5] })  # 追記
		return price

また、前回の記事と同様に、以下のようなフィルター関数を作ります。
この関数は、ドンチアンブレイクアウトの判定をする関数の後に呼ばれ、エントリーの条件判定に利用されます。ここまでは前回の記事と全く同じ流れなので、難しいところはありません。


# エントリーフィルターの関数
def filter( signal ):
	average_volume = sum(i["volume"] for i in last_data[-1 * buy_term:]) / buy_term
	if data["volume"] > average_volume * 1.2:
		return True
	return False

今回は「過去30期間の平均的な出来高を20%以上上回った場合のみエントリーする」という仕掛けフィルターを作ってみます。

なお、ここでは上値ブレイクアウト期間(buy_term)と下値ブレイクアウト期間(sell_term)を同じ値に設定することを想定しています。もし違う値を設定する場合は、条件分岐が必要です。

検証してみよう!

まずはこちらを普通に前回と同じ方法で検証してみましょう!
実際にフィルターを適用してみて、その成績指標や運用パフォーマンスを比較します。この方法の問題点は後ほど解説しますが、ひとまずやってみましょう。

検証条件

1.検証期間(2017/9/22~2018/5/30)
2.1時間足を使用
3.上値・下値ブレイクアウト 30期間
4.ブレイクアウトの判定 終値/終値
5.ボラティリティ計算期間 30期間
6.ストップレンジ幅 2ATR
7.全トレードで1BTCだけを売買

なお、今回はブレイクアウトの判定には終値を使います。

出来高フィルター無しの場合


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  101回
勝率               :  42.6%
期待リターン       :  2.66%
標準偏差           :  8.76%
平均利益率         :  10.15%
平均損失率         :  -2.89%
平均保有期間       :  35.0足分
損切りの回数       :  97回

最大の勝ちトレード :  584043円
最大の負けトレード :  -176313円
最大連敗回数       :  11回
最大ドローダウン   :  -346620円 / -15.2%
利益合計           :  5299362円
損失合計           :  -2140345円
最終損益           :  3159017円

初期資金           :  1000000円
最終資金           :  4159017円
運用成績           :  416.0%
手数料合計         :  -116704円
-----------------------------------
各成績指標
-----------------------------------
CAGR(年間成長率)         :  701.17%
MARレシオ                :  20.78
シャープレシオ           :  0.3
プロフィットファクター   :  2.48
損益レシオ               :  3.51
------------------------------------------
+10%を超えるトレードの回数  :  18回
------------------------------------------
-10%を下回るトレードの回数  :  0回
------------------------------------------

出来高のフィルターありの場合


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  93回
勝率               :  44.1%
平均リターン       :  2.88%
標準偏差           :  8.82%
平均利益率         :  10.03%
平均損失率         :  -2.75%
平均保有期間       :  36.1足分
損切りの回数       :  89回

最大の勝ちトレード :  584043円
最大の負けトレード :  -176313円
最大連敗回数       :  11回
最大ドローダウン   :  -207374円 / -7.1%
利益合計           :  5060427円
損失合計           :  -1660796円
最終損益           :  3399631円

初期資金           :  1000000円
最終資金           :  4399631円
運用成績           :  440.0%
手数料合計         :  -104373円
-----------------------------------
各成績指標
-----------------------------------
CAGR(年間成長率)         :  731.58%
MARレシオ                :  20.81
シャープレシオ           :  0.32
プロフィットファクター   :  2.73
損益レシオ               :  3.62

比較表

フィルター無し フィルター有り
トレード回数 101回 93回
勝率 42.6% 44.1%
期待値 2.66% 2.88%
最大DD -15.2% -7.1%
CAGR 701.17% 769.74%
MARレシオ 20.78 41.97
PF 2.28 3.05
損益レシオ 3.51 3.64

この結果だけを見ると、勝率・期待リターン・プロフィットファクターのどれを見ても成績が改善していて、全体のパフォーマンスも向上しているように見えます。

一応、手仕舞いの方法の影響を受けていないことを確認するために、トレイリングストップを無効にした場合もテストしてみましょう。具体的には、通常のストップを使った場合や、ストップを無効にした場合でも比較してみます。

通常のストップを使った場合

フィルター無し フィルター有り
トレード回数 80回 67回
勝率 36.2% 38.8%
期待値 2.36% 2.73%
最大DD -10.2% -14.6%
CAGR 469.49% 457.04%
MARレシオ 18.94 14.28
PF 2.13 2.44
損益レシオ 3.69 3.82

ストップを使わない場合

フィルター無し フィルター有り
トレード回数 70回 58回
勝率 45.7% 48.3%
期待値 2.76% 3.57%
最大DD -15.1% -15.6%
CAGR 483.68% 519.62%
MARレシオ 15.55 15.95
PF 2.04 2.61
損益レシオ 2.38 2.76

いずれの場合も出来高が少ないブレイクアウトをフィルターにかけた場合の方が、勝率や期待リターンは改善しているように見えます。では、このまますぐに「出来高フィルター」を採用することを決めるべきでしょうか?

個人的にはそうではないと思います。前回のトレンドフィルターの記事でも少しだけ触れましたが、フィルターの条件に一致する回数が少なすぎる場合、その結果はただの偶然の可能性があるからです。

「ただの偶然」の可能性を疑ってみよう!

例えば、次のようなフィルターを想像してみてください。
エントリーのシグナルが出るたびにサイコロを振って、6の目が出ればエントリーしないフィルターです。

普通に考えれば、こんなフィルターを採用したいと思う人はいないでしょう。このフィルターの条件にエッジが全くないことは誰でもわかるからです。しかし実際にこのフィルター関数を作ってバックテストをすると、何回かに1回は成績が改善します。

サイコロを使ったフィルターの例

興味がある方は、実際に以下のような関数を作ってテストしてみてください。 以下は、1~6の範囲で乱数を生成して6に一致したらエントリーしないフィルターです。


import random

# エントリーフィルターの関数
def filter( signal ):
	num = random.randint(1,6)
	if num != 6:
		return True
	return False


これは全ブレイクアウトのシグナルのうち、およそ1/6をランダムにフィルターにかける関数です。フィルターの頻度はさきほどの出来高フィルターと同程度にしてあります。

早速このフィルターを適用して4回ほどテストしてみます。
すると、先ほどの出来高のフィルターを上回るとてもいい成績が出てしまいました!

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  96回
勝率               :  42.7%
平均リターン       :  3.04%
標準偏差           :  8.75%
平均利益率         :  10.62%
平均損失率         :  -2.61%
平均保有期間       :  34.7足分
損切りの回数       :  92回

最大の勝ちトレード :  584043円
最大の負けトレード :  -170307円
最大連敗回数       :  10回
最大ドローダウン   :  -349118円 / -11.1%
利益合計           :  5344595円
損失合計           :  -1748506円
最終損益           :  3596089円

初期資金           :  1000000円
最終資金           :  4596089円
運用成績           :  460.0%
手数料合計         :  -107435円
-----------------------------------
各成績指標
-----------------------------------
CAGR(年間成長率)         :  827.02%
MARレシオ                :  32.4
シャープレシオ           :  0.35
プロフィットファクター   :  3.06
損益レシオ               :  4.06

もちろん、いくらバックテストで良い成績が出たからといって、このフィルターを使いたい方はいないと思います。それは誰が聞いても、「サイコロを振ることとトレードの勝率が上がることとの間に何の因果関係もない」ことが明らかだからです。

しかし出来高フィルターやトレンドフィルターのように、一見するともっともらしいトレード理論が背景にある場合には、ついバックテストで良い成績が出ると、その因果関係や相関性をそのまま信じてしまいがちです。

またサイコロのように同じ条件で何回も再テストできないことも、幻相関に気付きにくい要因の1つになります。

フィルターの罠

フィルターにこのような錯誤がおこりやすいのは、そもそも以前の記事でも説明したように、トレンドフォローBOTの勝率が悪いことも1つの原因だと思います。

ブレイクアウトBOTは「たまに来る大勝ち」に賭けるタイプのトレードです。そのため、少ない回数しかトレードをしなかった場合、通常は負ける確率の方が高いはずです。

例えば、100回のトレードのうち5回のトレードをランダムに選んでフィルターにかけた場合、フィルターにかかったトレードは、平均の期待リターンを下回っている可能性が高いと思います。

つまりフィルターの条件に一致する回数が少なすぎる場合、平均リターンを下回るトレードだけが偶然フィルターに選ばれる確率も高くなり、フィルターを適用した結果、まるで成績が向上したように見える、という問題があります。

有効性のあるフィルターを見分ける

個人的にそのフィルターが役立つかどうかを見分ける1つの方法は、フィルター条件のシグナルが出たトレードと出ていないトレードをすべて実行し、両方の結果を別々に集計して、リターン分布をヒストグラムにすることです。

例えば、以下のようなヒストグラムを作ります。

▽ フィルター条件と一致したトレード(上)と一致しなかったトレード(下)のリターン分布図

数値上は勝率や期待リターンに違いがある場合でも、このようにリターン分布図にプロットしてみると、「あれ? ほとんど形状が変わらないな…。もしかして、ランダムにトレードを選んだのと同じかな?」と気づきやすくなります。

pythonだと気軽にPandasを使ってこのような比較ができるので便利です。簡単なのでやってみましょう!

Pythonコード

さきほどの出来高のフィルター関数を少しアレンジしてみましょう。
エントリー機会を絞るのではなく、単にフィルターのシグナルが出たことを「記録」しておくだけのコードに変更してみます。例えば、以下のような感じです。


# エントリーフィルターの関数
def filter( flag ):	
	average_volume = sum(i["volume"] for i in last_data[-1 * buy_term:]) / buy_term
	if data["volume"] > average_volume * 1.2:
		flag["records"]["volume"].append("high_volume")
	else:
		flag["records"]["volume"].append("low_volume")
	return flag

そして全てのトレードが終わったあとに、pandasで集計します。このやり方は以前に「バックテスト編」で、売りと買いの成績を別々に集計するときに解説した方法と全く同じ手順です。なので詳しくはそちらを参考にしてください。

Pandasを使ってBOTの成績を月別に集計する方法

以下、バックテスト用のコードだけ載せておきます。


# バックテストの集計用の関数
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"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"],
		"Volume"   :  flag["records"]["volume"] # 追記
	})

	# 出来高フィルターにかかった場面とかかってない場面を集計
	high_vol_records = records[records.Volume.isin(["high_volume"])]
	low_vol_records = records[records.Volume.isin(["low_volume"])]

	print("バックテストの結果")
	print("-----------------------------------")
	print("出来高が多かったときの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(high_vol_records) ))
	print("勝率               :  {}%".format(round(len(high_vol_records[high_vol_records.Profit>0]) / len(high_vol_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(high_vol_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( high_vol_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(high_vol_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( high_vol_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("出来高が少なかったときの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(low_vol_records) ))
	print("勝率               :  {}%".format(round(len(low_vol_records[low_vol_records.Profit>0]) / len(low_vol_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(low_vol_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( low_vol_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(low_vol_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( low_vol_records.Stop.sum() ))

	# (略)

	# 「出来高が多いとき」のリターン分布図
	plt.subplot(2,1,1)
	plt.hist( high_vol_records.Rate,50,rwidth=0.9)
	plt.xlim(-15,45) # X軸の目盛り幅を揃える
	plt.axvline( x=0,linestyle="dashed",label="Return = 0" )
	plt.axvline( high_vol_records.Rate.mean(), color="orange", label="AverageReturn" )
	plt.legend()
	
	# 「出来高が少ないとき」のリターン分布図
	plt.subplot(2,1,2)
	plt.hist( low_vol_records.Rate,50,rwidth=0.9,color="coral")
	plt.xlim(-15,45) # X軸の目盛り幅を揃える
	plt.gca().invert_yaxis() # 上下反転
	plt.axvline( x=0,linestyle="dashed",label="Return = 0" )
	plt.axvline( low_vol_records.Rate.mean(), color="orange", label="AverageReturn" )
	plt.show()

比較しやすいようにリターン分布図を上下にプロットしたいので、X軸の目盛り幅を、xlim(-15,45)で揃えています。またsubplot(2,1,n)で2行1列(縦向き)にプロットし、下側の図を .invert_yaxis() で上下反転させています。

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

出来高の大小それぞれのリターン分布

今回は純粋にエントリー(フィルター)の条件にエッジがあるかどうかだけが知りたいので、トレイリングストップは無効にして、30期間ブレイクアウト以外の条件は何もつけずに検証してみます。

私もこの方法が正解かはわかりませんが、手仕舞いの方法が優れていると結果が歪んでしまう可能性がある気がするので、なるべく最低限の条件(ドテンルールのみ、または通常のストップのみ)で検証した方がわかりやすいと思います。

▽ ストップ無し(ドテンルールのみ)

出来高多 出来高少
回数 55回 15回
勝率 49.1% 33.3%
期待リターン 3.39% 0.46%
総損益 2489447円 -141504円
平均保有 86.2期間 77.1期間

たしかに数値だけ見れば、「エントリー時に出来高が少なかったトレード」は、勝率も期待リターンも悪いように見えます。しかし前述のように、リターン分布図を見ると、一見して明らかなほどの形状の違いはありません。一応、通常のストップを用いた場合も見ておきましょう。

▽ 通常のストップのみ

この違いがわかりにくいのは、前述のように「フィルター条件に一致した回数」が少なすぎるからです。

例えば、前回の記事で紹介したような「終値が長期移動平均線より上にあるか下にあるか?」といったフィルター条件の場合、全トレードのうちおよそ半分がフィルター条件に引っかかります。

そのため、それぞれのリターン分布図は以下のように、比較的わかりやすい分布形状の違いとして現れます。

前回のトレンドフィルターの例

トレンドと一致 トレンドと不一致
回数 55回 55回
勝率 45.5% 27.3%
期待リターン 3.61% -0.44%
総損益 2393675円 -547580円
平均保有 52.3期間 36.7期間
損切り 25回 32回

トレンドと一致した方向のブレイクアウトでは、リターンは幅広く右側にバラつき、期待リターンは正です。一方、トレンドと一致しない方向にエントリーしたケースでは、リターン分布は左右対称に近い形になっており、期待リターンはマイナスの数値になっています。

これでも完璧に確信が持てるわけではありませんが、それぞれ50回以上のトレード数でシグナルが出ていて、かつこれだけ分布に違いがあれば、トレンドフィルターの有効性を「試す価値がある」と思えるかもしれません。

pythonで「t検定」をしてみよう!

では、リターン分布で明らかな違いがわからないときは、そのままフィルターのアイデアを捨てた方がいいのでしょうか?

もちろん、上記の出来高フィルターにまだ利用価値がないと決まったわけではありません。フィルターにかかった回数が少なすぎて、ただの偶然かどうか判断が難しいというだけです。そこで考え方は2つあります。

(1)観察期間を伸ばしてもっとサンプル数を確保する
(2)統計的な検定手法を使う

一見、データ数が少なすぎて人間の目では直感的にわからない場合でも、統計的な検定手法を使うことで、両者の期待リターンの差が「偶然で説明できる範囲を超えた明かな違いかどうか?」を科学的に検証することができます。

今回は「t検定」という方法を紹介します!
統計の知識がない方でもわかるように説明するので、読んでくれると嬉しいです!

t検定とは

t検定とは、あるグループ(A)の結果とあるグループ(B)の結果の違いが偶然のバラつき(誤差)から生じる確率を計算して、その確率が5%以下なら「この差は偶然ではない」と結論付ける統計手法のことをいいます。

例えば、上記の例では、ブレイクアウト時に平均的な出来高を上回った場合と、出来高が少なかった場合とを比較しました。このとき期待リターンには 3.27% - 0.36% = 2.91% の開きがありましたね。

t検定では、最初に「両者のトレードはランダムに振り分けられただけで元の期待値は全く同じだ」という一番嬉しくない仮説を立てます。その上で、「もしその仮説が正しい場合、ただの偶然からこれだけの差が生じる確率はどのくらいあるのか?」を計算します。この確率のことをp値といいます。

もしp値が5%以下であれば、「ただの偶然にしては差が大きすぎる。だから最初の仮説は間違っていた」(つまり偶然ではなく出来高フィルターに有効性があった)という結論を下します。

 

△ 図の「2」は適当な数字で、実際はサンプル数によって変わります。

 
なお、t検定をちゃんと理解したい方には、以下の本がおすすめです。
私はほとんどの統計学の固い教科書が理解できずに挫折しましたが(笑)、西内さんの著書シリーズを読んで、だいぶ統計学の基礎的なところが理解できるようになりました。[実践編]がいいです。

統計学が最強の学問である[実践編]

Pythonでt検定する方法

実際のt検定の計算式は少しだけ複雑ですが、pythonの「Scipy」というライブラリを使えば、1行書くだけでp値を計算してくれます。これも手を動かしてやってみましょう!

▽p値を計算するコード


from scipy import stats
import numpy as np

sample_a = [4,3,3,3,5,-1,2,.......,3]  # 条件1の全データの配列
sample_b = [2,-2,-1,3,5,-4,.......,2]  # 条件2の全データの配列 (ndarray)

p = stats.ttest_ind(sample_a, sample_b)
print("p値 : {}".format(p[1]))

参考:scipyの公式リファランス
参考:t検定とpython

コードの解説

それぞれのサンプルデータには、ただの配列(リスト)ではなく、numpy型の配列(ndarray)を用意しなければならない点に注意してください。pandasで集計した列データをndarrayに変換するには、.values を使います。

また比較したいグループ(A)と(B)のデータ数が大体同じであれば、上記のコードのままで大丈夫です。ですが、両方の結果のサンプル数に大きな違いがある場合は、以下のように equal_var = False を付けてください。

p = stats.ttest_ind(sample_a, sample_b, equal_var = False)

※ 以下、興味がある方のために補足しますが、全く意味がわからない方は心配しないで飛ばしてください!

t検定を使うためには両グループの母集団が等分散であるという仮定を置く必要があります。サンプル数が同程度であれば問題ありませんが、そうでない場合は、等分散の仮定をおかない「ウェルチ検定」という類似手法を使う必要があるので、equal_var(等分散)をFalseに設定しています。

では、さきほどpandasで集計したデータからp値を計算するコードを作りましょう!

バックテストの検証コード


from scipy import stats
import numpy as np

# バックテストの集計用の関数
def backtest(flag):

	# (略)

	# T検定を実行する
	sample_a = high_vol_records.Rate.values
	sample_b =  low_vol_records.Rate.values
	print("------------------------------------------")
	print("t検定を実行")
	print("------------------------------------------")
	p = stats.ttest_ind(sample_a, sample_b, equal_var = False)
	print("p値 : {}".format(p[1]))

簡単ですね。

では、これで「出来高が平均を上回るときのブレイクアウト」と「それ以下の出来高のときのブレイクアウト」とのエントリーに、偶然や誤差の範囲を超えるほどの期待リターンの違いがあったのかどうか、結果を見てみましょう!

実行結果

▽ドテンルールのみの場合(ストップ無し)

p値: 0.34626205887421146

▽通常のストップ有りの場合

p値 : 0.4575055281798647

p値はいずれも5%を大幅に上回り、単なる偶然の可能性を否定できない水準(全くランダムなフィルターでも34~45%程度の確率でこのくらいの差が生じる)という結果になりました。

このように統計ツールは万能ではなく、単にヒストグラムから予想された通りの結果を示すだけのことも多いですが、一応、統計的な検定方法を知っておいて損はないと思います!

トレンドフィルターのp値

せっかくなので、さきほどの移動平均線(200MA)を使ったトレンドフィルターのp値も確認しておきましょう。

▽ ドテンルールのみの場合(ストップ無し)

p値 : 0.007921851375299075

▽通常のストップ有りの場合

p値 : 0.012742691982286171

いずれもp値は5%を明らかに下回る水準となりました。つまり「偶然だけでこれだけの期待リターンの差が生じる可能性は考えにくい」ということです。

これは(少なくともヒストリカルデータの検証期間においては)トレンドを使ってエントリー条件に何らかのフィルターをかけることは、統計的な合理性があったことを意味します。

補足

なお、この記事の検証結果は「出来高を使ったフィルターに有用性がない」という意味ではありません。単に私が適当に考えた「過去30期間の出来高を20%上回る場合」という仕掛けのフィルターに、有意なエッジが無かったというだけなので、もっと研究する価値はあると思います。

以上でトレンドフォローBOTのフィルター編は終わりです!

今回使ったコード

一応、今回の出来高フィルターの検証に使ったコードを記載しておきますが、今回のコードはかなり適当に変数名をつけていて、あまり読みやすく作ってません。

フィルター条件の一致を確認する配列だけだと、バックテストがポジションを持ったまま終了したときに数が合わなくなるので、それを手仕舞いのたびに filter-match から volume にコピーして移す、という処理をしていますが、そこがわかりにくいと思います。

なので、参考程度にしてください。


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


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

chart_sec = 3600           #  1時間足を使用
buy_term =  30             #  買いエントリーのブレイク期間の設定
sell_term = 30             #  売りエントリーのブレイク期間の設定

judge_price={
  "BUY" : "close_price",   #  ブレイク判断 高値(high_price)か終値(close_price)を使用
  "SELL": "close_price"    #  ブレイク判断 安値 (low_price)か終値(close_price)を使用
}

TEST_MODE_LOT = "fixed"    # fixed なら常に1BTC固定 / adjustable なら可変ロット

volatility_term = 30       # 平均ボラティリティの計算に使う期間
stop_range = 2             # 何レンジ幅にストップを入れるか
trade_risk = 0.03          # 1トレードあたり口座の何%まで損失を許容するか
levarage = 3               # レバレッジ倍率の設定
start_funds = 1000000      # シミュレーション時の初期資金

entry_times = 2            # 何回に分けて追加ポジションを取るか
entry_range = 1            # 何レンジごとに追加ポジションを取るか

stop_config = "ON"         # ON / OFF / TRAILING の3つが設定可
stop_AF = 0.02             # 加速係数
stop_AF_add = 0.02         # 加速係数を増やす度合
stop_AF_max = 0.2          # 加速係数の上限

wait = 0                   #  ループの待機時間
slippage = 0.001           #  手数料・スリッページ



#-------------検証したいフィルター--------------

# エントリーフィルターの関数
def filter( flag ):
		
	average_volume = sum(i["volume"] for i in last_data[-30:]) / 30
	if data["volume"] > average_volume * 1.2:
		flag["records"]["filter-match"] = "high_volume"
	else:
		flag["records"]["filter-match"] = "low_volume"
	return flag



#-------------補助ツールの関数--------------

# 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],
					"volume": i[5] })
		return price
		
	else:
		flag["records"]["log"].append("データが存在しません")
		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 calculate_volatility( last_data ):

	high_sum = sum(i["high_price"] for i in last_data[-1 * volatility_term :])
	low_sum  = sum(i["low_price"]  for i in last_data[-1 * volatility_term :])
	volatility = round((high_sum - low_sum) / volatility_term)
	flag["records"]["log"].append("現在の{0}期間の平均ボラティリティは{1}円です\n".format( volatility_term, volatility ))
	return volatility


# 単純移動平均を計算する関数
def calculate_MA( value,before=None ):
	if before is None:
		MA = sum(i["close_price"] for i in last_data[-1*value:]) / value
	else:
		MA = sum(i["close_price"] for i in last_data[-1*value + before: before]) / value
	return round(MA)


# 指数移動平均を計算する関数
def calculate_EMA( value,before=None ):
	if before is not None:
		MA = sum(i["close_price"] for i in last_data[-2*value + before : -1*value + before]) / value
		EMA = (last_data[-1*value + before]["close_price"] * 2 / (value+1)) + (MA * (value-1) / (value+1))
		for i in range(value-1):
			EMA = (last_data[-1*value+before+1 + i]["close_price"] * 2 /(value+1)) + (EMA * (value-1) / (value+1))
	else:
		MA = sum(i["close_price"] for i in last_data[-2*value: -1*value]) / value
		EMA = (last_data[-1*value]["close_price"] * 2 / (value+1)) + (MA * (value-1) / (value+1))
		for i in range(value-1):
			EMA = (last_data[-1*value+1 + i]["close_price"] * 2 /(value+1)) + (EMA * (value-1) / (value+1))
	return round(EMA)



#-------------資金管理の関数--------------

# 注文ロットを計算する関数
def calculate_lot( last_data,data,flag ):
	
	# 固定ロットでのテスト時
	if TEST_MODE_LOT == "fixed":
		flag["records"]["log"].append("固定ロット(1枚)でテスト中のため、1BTCを注文します\n")
		lot = 1
		volatility = calculate_volatility( last_data )
		stop = stop_range * volatility
		flag["position"]["ATR"] = round( volatility )
		return lot,stop,flag
	
	
	# 口座残高を取得する
	balance = flag["records"]["funds"]

	# 最初のエントリーの場合
	if flag["add-position"]["count"] == 0:
		
		# 1回の注文単位(ロット数)と、追加ポジの基準レンジを計算する
		volatility = calculate_volatility( last_data )
		stop = stop_range * volatility
		calc_lot = np.floor( balance * trade_risk / stop * 100 ) / 100
		
		flag["add-position"]["unit-size"] = np.floor( calc_lot / entry_times * 100 ) / 100
		flag["add-position"]["unit-range"] = round( volatility * entry_range )
		flag["add-position"]["stop"] = stop
		flag["position"]["ATR"] = round( volatility )
		
		flag["records"]["log"].append("\n現在のアカウント残高は{}円です\n".format( balance ))
		flag["records"]["log"].append("許容リスクから購入できる枚数は最大{}BTCまでです\n".format( calc_lot ))
		flag["records"]["log"].append("{0}回に分けて{1}BTCずつ注文します\n".format( entry_times, flag["add-position"]["unit-size"] ))
		
	# 2回目以降のエントリーの場合
	else:
		balance = round( balance - flag["position"]["price"] * flag["position"]["lot"] / levarage )
	
	# ストップ幅には、最初のエントリー時に計算したボラティリティを使う
	stop = flag["add-position"]["stop"]
	
	# 実際に購入可能な枚数を計算する
	able_lot = np.floor( balance * levarage / data["close_price"] * 100 ) / 100
	lot = min(able_lot, flag["add-position"]["unit-size"])
	flag["records"]["log"].append("証拠金から購入できる枚数は最大{}BTCまでです\n".format( able_lot ))
	
	return lot,stop,flag



# 複数回に分けて追加ポジションを取る関数
def add_position( data,flag ):
	
	# ポジションがない場合は何もしない
	if flag["position"]["exist"] == False:
		return flag
	
	# 固定ロット(1BTC)でのテスト時は何もしない
	if TEST_MODE_LOT == "fixed":
		return flag
	
	# 最初(1回目)のエントリー価格を記録
	if flag["add-position"]["count"] == 0:
		flag["add-position"]["first-entry-price"] = flag["position"]["price"]
		flag["add-position"]["last-entry-price"] = flag["position"]["price"]
		flag["add-position"]["count"] += 1
	
	while True:
		
		# 以下の場合は、追加ポジションを取らない
		if flag["add-position"]["count"] >= entry_times:
			return flag
		
		# この関数の中で使う変数を用意
		first_entry_price = flag["add-position"]["first-entry-price"]
		last_entry_price = flag["add-position"]["last-entry-price"]
		unit_range = flag["add-position"]["unit-range"]
		current_price = data["close_price"]
		
		
		# 価格がエントリー方向に基準レンジ分だけ進んだか判定する
		should_add_position = False
		if flag["position"]["side"] == "BUY" and (current_price - last_entry_price) > unit_range:
			should_add_position = True
		elif flag["position"]["side"] == "SELL" and (last_entry_price - current_price) > unit_range:
			should_add_position = True
		else:
			break
		
		# 基準レンジ分進んでいれば追加注文を出す
		if should_add_position == True:
			flag["records"]["log"].append("\n前回のエントリー価格{0}円からブレイクアウトの方向に{1}ATR({2}円)以上動きました\n".format( last_entry_price, entry_range, round( unit_range ) ))
			flag["records"]["log"].append("{0}/{1}回目の追加注文を出します\n".format(flag["add-position"]["count"] + 1, entry_times))
			
			# 注文サイズを計算
			lot,stop,flag = calculate_lot( last_data,data,flag )
			if lot < 0.01:
				flag["records"]["log"].append("注文可能枚数{}が、最低注文単位に満たなかったので注文を見送ります\n".format(lot))
				flag["add-position"]["count"] += 1
				return flag
			
			# 追加注文を出す
			if flag["position"]["side"] == "BUY":
				entry_price = first_entry_price + (flag["add-position"]["count"] * unit_range)
				#entry_price = round((1 + slippage) * entry_price)
				
				flag["records"]["log"].append("現在のポジションに追加して、{0}円で{1}BTCの買い注文を出します\n".format(entry_price,lot))
				
				# ここに買い注文のコードを入れる
				
				
			if flag["position"]["side"] == "SELL":
				entry_price = first_entry_price - (flag["add-position"]["count"] * unit_range)
				#entry_price = round((1 - slippage) * entry_price)
				
				flag["records"]["log"].append("現在のポジションに追加して、{0}円で{1}BTCの売り注文を出します\n".format(entry_price,lot))
				
				# ここに売り注文のコードを入れる
				
				
			# ポジション全体の情報を更新する
			flag["position"]["stop"] = stop
			flag["position"]["price"] = int(round(( flag["position"]["price"] * flag["position"]["lot"] + entry_price * lot ) / ( flag["position"]["lot"] + lot )))
			flag["position"]["lot"] = np.round( (flag["position"]["lot"] + lot) * 100 ) / 100
			
			if flag["position"]["side"] == "BUY":
				flag["records"]["log"].append("{0}円の位置にストップを更新します\n".format(flag["position"]["price"] - stop))
			elif flag["position"]["side"] == "SELL":
				flag["records"]["log"].append("{0}円の位置にストップを更新します\n".format(flag["position"]["price"] + stop))
			flag["records"]["log"].append("現在のポジションの取得単価は{}円です\n".format(flag["position"]["price"]))
			flag["records"]["log"].append("現在のポジションサイズは{}BTCです\n\n".format(flag["position"]["lot"]))
			
			flag["add-position"]["count"] += 1
			flag["add-position"]["last-entry-price"] = entry_price
	
	return flag


# トレイリングストップの関数
def trail_stop( data,flag ):

	# まだ追加ポジションの取得中であれば何もしない
	if flag["add-position"]["count"] < entry_times and TEST_MODE_LOT != "fixed":
		return flag
	
	# 高値/安値がエントリー価格からいくら離れたか計算
	if flag["position"]["side"] == "BUY":
		moved_range = round( data["high_price"] - flag["position"]["price"] )
	if flag["position"]["side"] == "SELL":
		moved_range = round( flag["position"]["price"] - data["low_price"] )
	
	# 最高値・最安値を更新したか調べる
	if moved_range < 0 or flag["position"]["stop-EP"] >= moved_range:
		return flag
	else:
		flag["position"]["stop-EP"] = moved_range
	
	# 加速係数に応じて損切りラインを動かす
	flag["position"]["stop"] = round(flag["position"]["stop"] - ( moved_range + flag["position"]["stop"] ) * flag["position"]["stop-AF"])
	
	
	# 加速係数を更新
	flag["position"]["stop-AF"] = round( flag["position"]["stop-AF"] + stop_AF_add ,2 )
	if flag["position"]["stop-AF"] >= stop_AF_max:
		flag["position"]["stop-AF"] = stop_AF_max
	
	# ログ出力
	if flag["position"]["side"] == "BUY":
		flag["records"]["log"].append("トレイリングストップの発動:ストップ位置を{}円に動かして、加速係数を{}に更新します\n".format( round(flag["position"]["price"] - flag["position"]["stop"]) , flag["position"]["stop-AF"] ))
	else:
		flag["records"]["log"].append("トレイリングストップの発動:ストップ位置を{}円に動かして、加速係数を{}に更新します\n".format( round(flag["position"]["price"] + flag["position"]["stop"]) , flag["position"]["stop-AF"] ))
	
	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 = filter( flag )
		
		lot,stop,flag = calculate_lot( last_data,data,flag )
		if lot > 0.01:
			flag["records"]["log"].append("{0}円で{1}BTCの買い注文を出します\n".format(data["close_price"],lot))
			
			# ここに買い注文のコードを入れる
			
			flag["records"]["log"].append("{0}円にストップを入れます\n".format(data["close_price"] - stop))
			flag["position"]["lot"],flag["position"]["stop"] = lot,stop
			flag["position"]["exist"] = True
			flag["position"]["side"] = "BUY"
			flag["position"]["price"] = data["close_price"]
		else:
			flag["records"]["log"].append("注文可能枚数{}が、最低注文単位に満たなかったので注文を見送ります\n".format(lot))

	if signal["side"] == "SELL":
		flag["records"]["log"].append("過去{0}足の最安値{1}円を、直近の価格が{2}円でブレイクしました\n".format(sell_term,signal["price"],data[judge_price["SELL"]]))
		
		# フィルター条件を確認
		flag = filter( flag )
		
		lot,stop,flag = calculate_lot( last_data,data,flag )
		if lot > 0.01:
			flag["records"]["log"].append("{0}円で{1}BTCの売り注文を出します\n".format(data["close_price"],lot))
			
			# ここに売り注文のコードを入れる
			
			flag["records"]["log"].append("{0}円にストップを入れます\n".format(data["close_price"] + stop))
			flag["position"]["lot"],flag["position"]["stop"] = lot,stop
			flag["position"]["exist"] = True
			flag["position"]["side"] = "SELL"
			flag["position"]["price"] = data["close_price"]
		else:
			flag["records"]["log"].append("注文可能枚数{}が、最低注文単位に満たなかったので注文を見送ります\n".format(lot))

	return flag



# 手仕舞いのシグナルが出たら決済の成行注文 + ドテン注文 を出す関数
def close_position( data,last_data,flag ):
	
	if flag["position"]["exist"] == False:
		return 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,data["close_price"] )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			flag["position"]["stop-AF"] = stop_AF
			flag["position"]["stop-EP"] = 0
			flag["add-position"]["count"] = 0
			
			
			# ドテン注文の箇所
			flag = filter( flag )
			
			lot,stop,flag = calculate_lot( last_data,data,flag )
			if lot > 0.01:
				flag["records"]["log"].append("\n{0}円で{1}BTCの売りの注文を入れてドテンします\n".format(data["close_price"],lot))
				
				# ここに売り注文のコードを入れる
				
				flag["records"]["log"].append("{0}円にストップを入れます\n".format(data["close_price"] + stop))
				flag["position"]["lot"],flag["position"]["stop"] = lot,stop
				flag["position"]["exist"] = True
				flag["position"]["side"] = "SELL"
				flag["position"]["price"] = data["close_price"]
			


	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,data["close_price"] )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			flag["position"]["stop-AF"] = stop_AF
			flag["position"]["stop-EP"] = 0
			flag["add-position"]["count"] = 0
			
			
			# ドテン注文の箇所
			flag = filter( flag )
			
			lot,stop,flag = calculate_lot( last_data,data,flag )
			if lot > 0.01:
				flag["records"]["log"].append("\n{0}円で{1}BTCの買いの注文を入れてドテンします\n".format(data["close_price"],lot))
				
				# ここに買い注文のコードを入れる
				
				flag["records"]["log"].append("{0}円にストップを入れます\n".format(data["close_price"] - stop))
				flag["position"]["lot"],flag["position"]["stop"] = lot,stop
				flag["position"]["exist"] = True
				flag["position"]["side"] = "BUY"
				flag["position"]["price"] = data["close_price"]
			
	return flag



# 損切ラインにかかったら成行注文で決済する関数
def stop_position( data,flag ):
	
	# トレイリングストップを実行
	if stop_config == "TRAILING":
		flag = trail_stop( data,flag )

	if flag["position"]["side"] == "BUY":
		stop_price = flag["position"]["price"] - flag["position"]["stop"]
		if data["low_price"] < stop_price:
			flag["records"]["log"].append("{0}円の損切ラインに引っかかりました。\n".format( stop_price ))
			stop_price = round( stop_price - 2 * calculate_volatility(last_data) / ( chart_sec / 60) )
			flag["records"]["log"].append(str(stop_price) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data,stop_price,"STOP" )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			flag["position"]["stop-AF"] = stop_AF
			flag["position"]["stop-EP"] = 0
			flag["add-position"]["count"] = 0
	
	
	if flag["position"]["side"] == "SELL":
		stop_price = flag["position"]["price"] + flag["position"]["stop"]
		if data["high_price"] > stop_price:
			flag["records"]["log"].append("{0}円の損切ラインに引っかかりました。\n".format( stop_price ))
			stop_price = round( stop_price + 2 * calculate_volatility(last_data) / (chart_sec / 60) )
			flag["records"]["log"].append(str(stop_price) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data,stop_price,"STOP" )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			flag["position"]["stop-AF"] = stop_AF
			flag["position"]["stop-EP"] = 0
			flag["add-position"]["count"] = 0
			
	return flag


#------------バックテストの部分の関数--------------


# 各トレードのパフォーマンスを記録する関数
def records(flag,data,close_price,close_type=None):
	
	flag["records"]["volume"].append(flag["records"]["filter-match"])
	
	# 取引手数料等の計算
	entry_price = int(round(flag["position"]["price"] * flag["position"]["lot"]))
	exit_price = int(round(close_price * flag["position"]["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"] )
	
	# 損切りにかかった回数をカウント
	if close_type == "STOP":
		flag["records"]["stop-count"].append(1)
	else:
		flag["records"]["stop-count"].append(0)
	
	# 値幅の計算
	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 ))
		flag["records"]["funds"] = flag["records"]["funds"] + buy_profit
		if buy_profit  > 0:
			log = str(buy_profit) + "円の利益です\n\n"
			flag["records"]["log"].append(log)
		else:
			log = str(buy_profit) + "円の損失です\n\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 ))
		flag["records"]["funds"] = flag["records"]["funds"] + sell_profit
		if sell_profit > 0:
			log = str(sell_profit) + "円の利益です\n\n"
			flag["records"]["log"].append(log)
		else:
			log = str(sell_profit) + "円の損失です\n\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"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"],
		"Volume"   :  flag["records"]["volume"]
	})
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 資産推移の列を追加する
	records["Funds"] = records.Gross + start_funds
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Funds.cummax().subtract(records.Funds)
	records["DrawdownRate"] = round(records.Drawdown / records.Funds.cummax() * 100,1)
	
	# フィルター有無別のトレードをそれぞれ抽出する
	high_vol_records = records[records.Volume.isin(["high_volume"])]
	low_vol_records = records[records.Volume.isin(["low_volume"])]
	
	
	
	print("バックテストの結果")
	print("-----------------------------------")
	print("出来高が多かったときの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(high_vol_records) ))
	print("勝率               :  {}%".format(round(len(high_vol_records[high_vol_records.Profit>0]) / len(high_vol_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(high_vol_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( high_vol_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(high_vol_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( high_vol_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("出来高が少なかったときの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(low_vol_records) ))
	print("勝率               :  {}%".format(round(len(low_vol_records[low_vol_records.Profit>0]) / len(low_vol_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(low_vol_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( low_vol_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(low_vol_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( low_vol_records.Stop.sum() ))
	
	
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])
	
	
	
	# T検定
	sample_a = high_vol_records.Rate.values
	sample_b = low_vol_records.Rate.values
	print("------------------------------------------")
	print("t検定を実行")
	print("------------------------------------------")
	p = stats.ttest_ind(sample_a, sample_b, equal_var = False)
	print("p値 : {}".format(p[1]))
	
	
	
	# 「出来高が多いとき」のリターン分布図
	plt.subplot(2,1,1)
	plt.hist( high_vol_records.Rate,50,rwidth=0.9)
	plt.xlim(-15,45)
	plt.axvline( x=0,linestyle="dashed",label="Return = 0" )
	plt.axvline( high_vol_records.Rate.mean(), color="orange", label="AverageReturn" )
	plt.legend()
	

	# 「出来高が少ないとき」のリターン分布図
	plt.subplot(2,1,2)
	plt.hist( low_vol_records.Rate,50,rwidth=0.9,color="coral")
	plt.xlim(-15,45)
	plt.gca().invert_yaxis()
	plt.axvline( x=0,linestyle="dashed",label="Return = 0" )
	plt.axvline( low_vol_records.Rate.mean(), color="orange", label="AverageReturn" )
	plt.show()
	


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

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

flag = {
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"stop":0,
		"stop-AF": stop_AF,
		"stop-EP":0,
		"ATR":0,
		"lot":0,
		"count":0
	},
	"add-position":{
		"count":0,
		"first-entry-price":0,
		"last-entry-price":0,
		"unit-range":0,
		"unit-size":0,
		"stop":0
	},
	"records":{
		"date":[],
		"profit":[],
		"return":[],
		"side":[],
		"stop-count":[],
		"funds" : start_funds,
		"holding-periods":[],
		"slippage":[],
		"log":[],
		"filter-match":"",
		"volume":[]
	}
}


last_data = []
need_term = max(buy_term,sell_term,volatility_term)
i = 0
while i < len(price):

	# ドンチャンの判定に使う期間分の安値・高値データを準備する
	if len(last_data) < need_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["position"]["exist"]:
		if stop_config != "OFF":
			flag = stop_position( data,flag )
		flag = close_position( data,last_data,flag )
		flag = add_position( 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)

CryptoCompareのAPIを使ってBitflyerのBTCFX価格を取得しよう!

自動売買BOTなどでBTCFXの価格を取得する場合、基本的にはCryptowatchを使うのがベストな案だと思います。Cryptowatchの価格はBitfyer管理画面のチャートに表示されている価格に最も近いからです。

しかし最近はCryptowatchのサーバーが不安定になることも増えてきました。そこで第2の選択肢として、CryptoCompareからBTCFXの価格データを取得する方法を紹介します。

基本的な仕様

普段Cryptowatchを使っている方は、以下の仕様を把握しておきましょう!

(1)取得できるのは、1分足/1時間足/日足の3つだけ
(2)取得可能な最大件数は2000件まで
 デフォルトは1分足1440件/1時間足168件
(3)データの並びは時系列の古い順
(4)BitflyerFXのチャート画面の価格(終値)とは完全に一致しない

公式のAPI仕様書ページはこちら

Pythonコード

まずは最初にpythonコードを見ておきましょう。
以下のpythonコードを使えば、当サイトで紹介しているほとんどのコードとそのまま互換性があります。


import requests
from datetime import datetime

# CryptoCompareの価格データを取得する関数
def get_price(min):

	price = []
	params = {"fsym":"BTC","tsym":"JPY","e":"bitflyerfx","limit":2000 }
	
	response = requests.get("https://min-api.cryptocompare.com/data/histohour",params, timeout = 10)
	data = response.json()
	
	if data["Response"] == "Success":
		for i in data["Data"]:
			price.append({ "close_time" : i["time"],
				"close_time_dt" : datetime.fromtimestamp(i["time"]).strftime('%Y/%m/%d %H:%M'),
				"open_price" : i["open"],
				"high_price" : i["high"],
				"low_price" : i["low"],
				"close_price": i["close"] })
		return price
		
	else:
		print("データが存在しません")
		return None

データの時系列は古い順なので、例えば、上記の関数の返り値から以下のようにアクセスすれば、直近の価格データを取得できます。

price[-1][“high_price”] … 現在の足の高値
price[-2][“close_price”] … 前回の足の終値

APIの形式

CryptoCompareのAPIのURL形式は、以下のようになっています。

https://min-api.cryptocompare.com/data/histo時間軸?fsym=通貨名&tsym=通貨名&e=取引所名&limit=取得件数

他の時間軸のローソク足を取得したい場合は、URLを以下のように変更します。

・/data/histominute … 1分足
・/data/histohour … 1時間足
・/data/histoday … 日足

例えば、以下のURLはそのままアクセスできます。
確認してみてください。

https://min-api.cryptocompare.com/data/histohour?fsym=BTC&tsym=JPY&e=bitflyerfx

期間の指定

期間の指定は、末尾の日時のみ「?toTS=XXXXXX(UNIXタイムスタンプ)」の形式で指定できます。

ただしどのような指定方法を使っても、直近の2000件以上を遡って取得することはできないようです。また「&limit=2000」と併用した場合は期間指定は無視されて合計2000件の取得となります。

1時間足の注意点

直近の足へのリアルタイム価格の反映は10分に1回しか行われていないようです。これはCryptowatchのAPIとの大きな違いです。

▽ 直近の足だけを20秒に1回取得してみた結果

そのため、リアルタイム価格を使って損切り等の何らかの判定をおこないたい場合は、同時にCryptoCompareの1分足のデータを取得するか、BitflyerのパブリックAPIを利用してリアルタイム価格を取得する必要があります。

BTCFXのチャネルブレイクアウトBOTにトレンドフィルターを追加してみよう!

さて、前回の記事では、チャネルブレイクアウトBOTの基本的な特徴としてリターンの分布が非対称であることと、なぜプラスの期待値が生まれるのかを相対度数表を使って確認しました。

今回の記事では、できるだけその特性を邪魔せずにエントリー精度を上げるための「フィルター」の追加について考えます。

フィルターの目的

ブレイクアウトBOTのようなトレンドフォロー型のBOTの場合、フィルターを追加する目的は主に以下の3つです。

(1)中長期のトレンドと方向性が一致しているか確認したい
(2)ブレイクアウトが本当に成功したかを確認したい
(3)ボラティリティの大きさや変動率を確認したい

他にも色々な目的のフィルターがあると思いますが、ここではおおまかな主目的をこの3つに絞ることにします。では具体的な例をみてみましょう。

トレンドの方向性を確認するフィルター

これは全体のトレンドと逆行する方向へのブレイクアウトを取り除くためのフィルターです。例えば、30期間の上値をブレイクアウトした場合、中長期でのトレンドが上向きかどうかを確認し、上向きと判断できた場合のみエントリーします。

いわゆるトレンドフィルターのことですが、これにも、シンプルなものから複雑なものまでいくつかバリエーションがあります。

買いのトレンドフィルターの例

1)現在の終値がn足前の終値の水準より高ければOK
2)現在の終値が長期移動平均線より上にあればOK
3)中期移動平均線が、前回の足より直近の足の方が上ならOK
4)現在の短期移動平均線が長期移動平均線より上にあればOK

いずれのフィルターも要するに、何らかの方法で全体の相場が中長期で上向きであることを確認しているに過ぎません。

「売買ルールはシンプルであればあるほど堅牢性があっていい」という信念のある方は、1)や2)のフィルターをより好むでしょう。一方、ある程度しっかり最適化することを好む方は、より変数の調整が可能な3)や4)のフィルターを好むでしょう。

フィルターを検証してみよう!

ではまずは検証条件を確認しておきましょう。前回までと同じ1時間足の30期間ドンチアン・チャネルブレイクアウトBOTで、全トレードは1BTCだけを売買するものとします。ストップは有効にします。

検証条件

・検証期間(2017/9/16~2018/5/25)
・1時間足を使用
・上値・下値ブレイクアウト 30期間
・ブレイクアウトの判定 高値/安値
・ボラティリティ計算期間 30期間
・ストップレンジ幅 2ATR
・トレイリングストップ 有効
・全トレードで1BTCだけを売買

フィルター無しの場合

まずは比較対照となる「フィルターを使わなかった場合」の成績を確認しておきます。

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  129回
勝率               :  42.6%
平均リターン       :  1.98%
標準偏差           :  7.74%
平均利益率         :  8.48%
平均損失率         :  -2.86%
平均保有期間       :  31.7足分
損切りの回数       :  111回

最大の勝ちトレード :  584043円
最大の負けトレード :  -235479円
最大連敗回数       :  10回
最大ドローダウン   :  -343866円 / -15.6%
利益合計           :  5693956円
損失合計           :  -2544463円
最終損益           :  3149493円

初期資金           :  1000000円
最終資金           :  4149493円
運用成績           :  415.0%
-----------------------------------
各成績指標
-----------------------------------
CAGR(年間成長率)         :  698.5%
MARレシオ                :  20.19
シャープレシオ           :  0.26
プロフィットファクター   :  2.24
損益レシオ               :  2.97
------------------------------------------
+10%を超えるトレードの回数  :  18回
------------------------------------------
-10%を下回るトレードの回数  :  1回
------------------------------------------

このリターン分布やトレード回数、勝率、期待値、プロフィットファクターなどの成績指標が、フィルターの適用でどう変化するのかを見ていきましょう!

1.「終値の水準が200期間前より上(下)」のフィルターを検証

まずは200期間を使った1つ目のフィルターをかけてみましょう。

1時間足の買いエントリーなら「現在の終値の水準が200時間前(約1週間前)の終値の水準より高い」ことをフィルター条件とし、売りエントリーなら逆に、現在の終値が200時間前の終値の水準より低いことをフィルター条件とします。

Pythonコード

詳しいロジックはあとで解説しますが、簡単にいうと、ブレイクアウトを判定する関数を呼んだあとに、エントリー(ドテン含む)のときだけ以下のようなフィルター判定の関数を実行します。手仕舞いの判定時にはフィルターをかけないので注意してください。


# エントリーフィルターの関数
def filter( signal ):
	if len(last_data) < 200:
		return True
	if data["close_price"] > last_data[-200]["close_price"] and signal["side"] == "BUY":
		return True
	if data["close_price"] < last_data[-200]["close_price"] and signal["side"] == "SELL":
		return True
	return False

なお、バックテストでは最初の200期間はフィルターチェックができないので無視しています。

検証結果

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  98回
勝率               :  42.9%
平均リターン       :  1.93%
標準偏差           :  7.77%
平均利益率         :  8.26%
平均損失率         :  -2.82%
平均保有期間       :  29.7足分
損切りの回数       :  85回

最大の勝ちトレード :  584043円
最大の負けトレード :  -235479円
最大連敗回数       :  8回
最大ドローダウン   :  -474261円 / -19.4%
利益合計           :  4111063円
損失合計           :  -1956058円
最終損益           :  2155005円

初期資金           :  1000000円
最終資金           :  3155005円
運用成績           :  316.0%
-----------------------------------
各成績指標
-----------------------------------
CAGR(年間成長率)         :  435.23%
MARレシオ                :  11.11
シャープレシオ           :  0.25
プロフィットファクター   :  2.1
損益レシオ               :  2.93
------------------------------------------
+10%を超えるトレードの回数  :  14回
------------------------------------------
-10%を下回るトレードの回数  :  1回
------------------------------------------

比較

フィルター無し フィルター有り
トレード回数 129回 98回
勝率 42.6% 42.9%
期待値 1.98% 1.93%
最大DD -15.6% -19.4%
CAGR 698.5% 435.2%
MARレシオ 20.19 11.11
PF 2.24 2.1
損益レシオ 2.97 2.93

このフィルターは単にトレード回数を削っているだけで、ほとんど意味がありませんね。リターン分布の形状も変わっておらず、単に利益の出るトレード機会と損失の出るトレード機会をランダムに削ってしまっただけに見えます。

次にいきましょう。

2.「終値が200期間の単純移動平均より上(下)」のフィルターを検証

次に終値が200期間の単純移動平均(200MA)より上にあることを買いフィルターの条件とし、その逆を売りフィルターの条件としてみましょう。

Pythonコード

エントリーフィルターの関数は以下のように書きました。前回の記事で作成した移動平均線を計算する関数(calculate_MA())を使っています。


# エントリーフィルターの関数
def filter( signal ):
	if len(last_data) < 200:
		return True
	if data["close_price"] > calculate_MA(200) and signal["side"] == "BUY":
		return True
	if data["close_price"] < calculate_MA(200) and signal["side"] == "SELL":
			return True
	return False

検証結果


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  100回
勝率               :  47.0%
平均リターン       :  2.65%
標準偏差           :  7.8%
平均利益率         :  8.49%
平均損失率         :  -2.53%
平均保有期間       :  32.9足分
損切りの回数       :  86回

最大の勝ちトレード :  584043円
最大の負けトレード :  -170307円
最大連敗回数       :  8回
最大ドローダウン   :  -170307円 / -7.6%
利益合計           :  4897309円
損失合計           :  -1402143円
最終損益           :  3495166円

初期資金           :  1000000円
最終資金           :  4495166円
運用成績           :  450.0%
-----------------------------------
各成績指標
-----------------------------------
CAGR(年間成長率)         :  797.45%
MARレシオ                :  45.99
シャープレシオ           :  0.34
プロフィットファクター   :  3.49
損益レシオ               :  3.36
------------------------------------------
+10%を超えるトレードの回数  :  16回
------------------------------------------
-10%を下回るトレードの回数  :  0回
------------------------------------------

比較

フィルター無し フィルター有り
トレード回数 129回 100回
勝率 42.6% 47.0%
期待値 1.98% 2.65%
最大DD -15.6% -7.6%
CAGR 698.5% 797.45%
MARレシオ 20.19 45.99
PF 2.24 3.49
損益レシオ 2.97 3.36

同じ200期間を使ったフィルターでも、こちらの方が遥かに優秀なフィルターとして機能しています。リターン分布を見ると、-3%台に多く分布していた損失がかなりフィルターにかけられているのがわかります。

では、このフィルターが機能したのは具体的にどのような場面なのでしょうか? ログから直近のチャートでフィルターにかかった箇所を確認してみましょう。

▽ 5月19日22時の確定足

全体として下落相場が続くなか、1時間足が30期間の最高値をブレイクアウトしたものの、終値が200期間単純移動平均より下にあるため、エントリーを見送っていたことがわかります。

3.「20期間移動平均の数値が前回の足よりも直近の足で上(下)」のフィルターを検証

これも簡易的なトレンドの判定方法の1つです。「終値の20期間移動平均線が前足よりも直近の足のほうが高い」ことを買いフィルターの条件とし、その逆を売りフィルターの条件とします。

これは要するに、移動平均線の「傾き」を簡易的にチェックする方法です。移動平均線の向きはそう簡単に変わりませんので、直近の足とその1つ前の足を比較すれば、現在の向き(傾き)がわかります。

参考情報

この判定方法は、ラリーウィリアムズ氏が「短期売買法」という本の中で、ボラティリティブレイクアウトについて解説している箇所(4章)で紹介した方法です。以下そのまま引用してみます。

市場が上昇トレンドにあるときだけトレードするというのはどうだろう。良い考えだが、上昇トレンドであることをどう見極めればよいのだろうか。私が好んで用いる方法のひとつは、終値の20日移動平均線が前日よりも今日のほうが高いときを上昇トレンドとする、というものだ。(略)。ボラティリティブレイクアウト戦略を市場が上昇トレンドにあるときのみ使った結果を見ると、その成果は絶大であることが分かる。

143項より

どの期間の移動平均線を使うかは(200MAのような明かな長期線を除き)かなり主観的な問題になるので、カーブフィッティングに繋がりやすくなります。そのため、判断が難しいところなのですが、ここでは、この本のまま20期間移動平均を使うことにします。

▽ 検証結果


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  122回
勝率               :  44.3%
平均リターン       :  2.27%
標準偏差           :  7.78%
平均利益率         :  8.58%
平均損失率         :  -2.74%
平均保有期間       :  32.9足分
損切りの回数       :  105回

最大の勝ちトレード :  584043円
最大の負けトレード :  -173559円
最大連敗回数       :  8回
最大ドローダウン   :  -343866円 / -15.1%
利益合計           :  5659592円
損失合計           :  -2102250円
最終損益           :  3557342円

初期資金           :  1000000円
最終資金           :  4557342円
運用成績           :  456.0%
-----------------------------------
各成績指標
-----------------------------------
CAGR(年間成長率)         :  815.63%
MARレシオ                :  23.56
シャープレシオ           :  0.29
プロフィットファクター   :  2.69
損益レシオ               :  3.13
------------------------------------------
+10%を超えるトレードの回数  :  18回
------------------------------------------
-10%を下回るトレードの回数  :  0回
------------------------------------------

比較

フィルター無し フィルター有り
トレード回数 129回 122回
勝率 42.6% 44.3%
期待値 1.98% 2.27%
最大DD -15.6% -15.1%
CAGR 698.5% 815.63%
MARレシオ 20.19 23.56
PF 2.24 2.69
損益レシオ 2.97 3.13

今までのフィルターの中では、最も緩いフィルターですね。全てのエントリー機会(129回)のうち、フィルターにかけたのは7回分だけです。一方で成績はかなり改善しており、10%を超えるトレード機会は1度も逃していません。

ただ、このデータだけだと「たまたま一番悪い損失が外れただけ」の可能性もあるので注意が必要です。一応、さきほどと同じように直近のチャートから実際の場面を確認しておきましょう。

ログを見ると直近では5月6日7時の確定足がフィルターにかかっています。以下の場面ですね。

▽ 5月6日7時の確定足

最安値がブレイクアウトしたものの、20期間移動平均がまだ上向きのため、エントリーを見送っています。

Pythonコード


# エントリーフィルターの関数
def filter( signal ):
	if len(last_data) < 20:
		return True
	if calculate_MA(20) > calculate_MA(20,-1) and signal["side"] == "BUY":
		return True
	if calculate_MA(20) < calculate_MA(20,-1) and signal["side"] == "SELL":
		return True
	return False

4.「短期移動平均線が長期移動平均線よりも上(下)」のフィルターを検証

最後はもっとも複雑なフィルターです。

例えば、買いエントリーであれば、短期移動平均線が長期移動平均線よりも上にある場合にのみエントリーします。売りエントリーであれば、短期移動平均線が長期移動平均線よりも下にある場合のみエントリーします。

このフィルターを使うためには、「どの期間の移動平均線を使うか?」という点で、最低でも2つ恣意的な数値(変数)を決めなければなりません。

そのため、好みが分かれそうな手法ですが、チャネルブレイクアウトのバイブル的な存在の本「タートル流 投資の魔術」で、トレンドフィルターとして紹介されているので、こちらも紹介しておきます。

使用する移動平均線

本家では、「350日指数移動平均と25日指数移動平均線」とされていますので、ここでもそのまま350EMAと25EMAを使うことにします。ただし本家のタートル流チャネルブレイクアウトは、30期間ブレイクアウトではないので前提条件は少し違います。

▽ 検証結果

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  82回
勝率               :  50.0%
平均リターン       :  2.76%
標準偏差           :  8.1%
平均利益率         :  8.19%
平均損失率         :  -2.67%
平均保有期間       :  33.4足分
損切りの回数       :  71回

最大の勝ちトレード :  584043円
最大の負けトレード :  -170307円
最大連敗回数       :  5回
最大ドローダウン   :  -336319円 / -13.2%
利益合計           :  3884653円
損失合計           :  -1313956円
最終損益           :  2570697円

初期資金           :  1000000円
最終資金           :  3570697円
運用成績           :  357.0%
-----------------------------------
各成績指標
-----------------------------------
CAGR(年間成長率)         :  541.24%
MARレシオ                :  19.47
シャープレシオ           :  0.34
プロフィットファクター   :  2.96
損益レシオ               :  3.07
------------------------------------------
+10%を超えるトレードの回数  :  13回
------------------------------------------
-10%を下回るトレードの回数  :  0回
------------------------------------------

比較

フィルター無し フィルター有り
トレード回数 129回 82回
勝率 42.6% 50.0%
期待値 1.98% 2.76%
最大DD -15.6% -13.2%
CAGR 698.5% 541.24%
MARレシオ 20.19 19.47
PF 2.24 2.96
損益レシオ 2.97 3.07

今まで見てきたトレンドフィルターの中では、最もアクティブ(攻撃的)なフィルターです。全129回のトレード機会のうち、49回(およそ1/3)をフィルターにかけてエントリー機会を絞っています。

その結果、勝率は50%、期待値は2.76%、プロフィットファクターは2.96と、個別のトレードの質(成績)はかなり向上しています。しかしトレード回数がかなり減ってしまったため、運用成績(CAGR)は悪化しています。

Pythonコード


# エントリーフィルターの関数
def filter( signal ):
	if len(last_data) < 700:
		return True
	if calculate_EMA(350) < calculate_EMA(25) and signal["side"] == "BUY":
		return True
	if calculate_EMA(350) > calculate_EMA(25) and signal["side"] == "SELL":
		return True	
	return False

指数平滑移動平均(EMA)の計算には、こちらの記事の関数を使っています。

トレンドフィルターの意味

なお、ここまで敢えてわかりやすく「エントリー機会を絞る」と表現してきましたが、もう少し正確にトレンドフィルターを使うことの意味を、チャートやログで確認しておきましょう。

トレンドフォロー型のBOTの基本的なコンセプトは、将来の価格を予測するのではなく、単にブレイクアウトした方向に事後的に追従するというものです。そのため、勝率はともかくとしてトレンドが発生すればそれに乗ることは保証されます。

一方、ここに移動平均線を用いたトレンドフィルターを加えるということは、本質的には「トレンドとの一致を確認するまでエントリーを保留する」ことを意味します。

具体的なケース(1)

例えば、2番目の「現在の終値が200MAより上(下)」のフィルターを使った場合を見てみましょう。ログから直近の2018年5月7日のBitflyerFXの1時間足のチャートを確認してみます。

以下の場面です。

※ ブレイクアウトの判定基準が「終値」の場合

この場面では、最初に30期間の最安値をブレイクアウトして売りシグナルが出ていますが、「終値が200MAより上にある」という理由でエントリーを見送っています。

しかし「30期間の下値ブレイクアウト」のシグナルは、その後も、最安値を更新するたびに出続けます。そして次のシグナルでは「終値が200MAより下にある」ため、フィルター条件と一致し、結局、エントリーは成立しています。

このように移動平均線は価格の遅行指標なので、ブレイクアウトが本当にトレンドを伴うものであれば、そのうち必ずエントリーシグナルとフィルター条件は一致します。これが「トレンドを確認するまでエントリーを保留する」といった意味です。

具体的なケース(2)

もちろん上記の役割だけだと、単にトレンドに乗り遅れるだけでメリットがありません。しかしこのフィルターの役割がさらに有効に機能する場面があります。

例えば、以下は先ほどと同じ「現在の終値が200MAより上(下)」フィルターを使った場合の、2018年5月16日のチャートの場面です。

ここでは1度目の「30期間の最高値ブレイクアウト」を、終値が200MAより下にあるという理由で見送っています。

もしこのブレイクアウトがトレンドの転換を伴うのであれば、その後、終値は200MAを超えていくはずです。しかし価格は下落を続け、今度は「30期間の最安値」をブレイクアウトしました。今度は、終値が200MAより下にあるため、エントリーの条件を満たしています。

結果、無駄な最高値ブレイクアウトによる買いエントリーを1つ減らすことができました。

まとめ

このようにトレンドフィルターを付けるということの本質的な意味は、「トレンド指標とブレイクアウトの方向が一致していなければ、トレンド指標が追従してくるまでエントリーを保留する」という点にあります。

そう理解しておけば、メリットとデメリットも把握できます。フィルターが強すぎるとエントリーが遅れて参入エッジが無くなる一方、適切なフィルターを用いると、「トレンドを伴わないブレイクアウト」に参加して損切りにかかる回数を減らすことができます。

フィルター条件を追加するpythonコード

それでは、最後にPythonでフィルターをつける方法を解説しましょう。
まず今回作成したフィルターは、以下のようなかたちで関数にしておきます。


# 設定値
filter_VER = "A"  # OFFでフィルター無効

# エントリーフィルターの関数
def filter( signal ):
	
	if filter_VER == "OFF":
		return True
	
	if filter_VER == "A":
		if len(last_data) < 200:
			return True
		if data["close_price"] > last_data[-200]["close_price"] and signal["side"] == "BUY":
			return True
		if data["close_price"] < last_data[-200]["close_price"] and signal["side"] == "SELL":
			return True
	
	if filter_VER == "B":
		if len(last_data) < 200:
			return True
		if data["close_price"] > calculate_MA(200) and signal["side"] == "BUY":
			return True
		if data["close_price"] < calculate_MA(200) and signal["side"] == "SELL":
			return True
		
	if filter_VER == "C":
		if len(last_data) < 20:
			return True
		if calculate_MA(20) > calculate_MA(20,-1) and signal["side"] == "BUY":
			return True
		if calculate_MA(20) < calculate_MA(20,-1) and signal["side"] == "SELL":
			return True
		
	if filter_VER == "D":
		if len(last_data) < 700:
			return True
		if calculate_EMA(350) < calculate_EMA(25) and signal["side"] == "BUY":
			return True
		if calculate_EMA(350) > calculate_EMA(25) and signal["side"] == "SELL":
			return True
		
	return False

このフィルター関数は、今まで作成していた「エントリー条件を判定する関数」とセットで使います。

エントリー条件の判定関数が、買いか売りかのシグナル(signal)を返す設計になっているので、そのsignalをそのまま渡します。フィルター条件を満たしていればTrueを返し、フィルター条件を満たさなければFalseを返します。もちろんTrueが返ってきた場合のみエントリーします。

そのため、以下のようにエントリー関数も修正します。

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


# エントリー注文を出す関数
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"]]))
		
		# フィルター条件を確認
		if filter( signal ) == False:
			flag["records"]["log"].append("フィルターのエントリー条件を満たさなかったため、エントリーしません\n")
			return flag

		# 以下同じ

フィルター条件の判定を「ブレイクアウトを判定する関数」の中に入れてしまっても構いません。ただしその場合は、手仕舞いのときに間違ってフィルターがかからないように条件分岐が必要です。

▽ フィルター適用の注意点

(1)30期間の上値ブレイクアウトで買いエントリー
 ⇒ フィルターが必要
(2)エントリー後、30期間の下値ブレイクアウトで手仕舞い
 ⇒ フィルターは不要
(3)さらにそのままドテンして売りエントリー
 ⇒ フィルターが必要

BitflyerのEMAの注意点

なお、4つ目のフィルターの検証結果について、こちらも実際にBitflyerのチャートと照らし合わせて確認したかったのですが、350EMAのズレが大きかったので断念しました。

指数移動平滑平均(EMA)は「誰が計算しても1つの値に定まる」という性質の数値ではありません。どれだけの過去データを考慮に入れるかによって数値が変動します。そのため、売買シグナルにBitflyerのチャート画面に表示されるのと同じEMAの数値を使いたい場合は注意が必要です。

詳しくは前回の記事で解説しているので参考にしてください。

BitflyerのEMAを実践で使う場合の注意点

今回勉強したコード

最後に今回使ったコードを記載しておきます。
次回は、「ブレイクアウトに成功したことを確認するフィルター」について考察します。



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


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

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)を使用
}

TEST_MODE_LOT = "fixed"    # fixed なら常に1BTC固定 / adjustable なら可変ロット

volatility_term = 30       # 平均ボラティリティの計算に使う期間
stop_range = 2             # 何レンジ幅にストップを入れるか
trade_risk = 0.03          # 1トレードあたり口座の何%まで損失を許容するか
levarage = 3               # レバレッジ倍率の設定
start_funds = 1000000      # シミュレーション時の初期資金

entry_times = 2            # 何回に分けて追加ポジションを取るか
entry_range = 1            # 何レンジごとに追加ポジションを取るか

stop_config = "TRAILING"   # ON / OFF / TRAILING の3つが設定可
stop_AF = 0.02             # 加速係数
stop_AF_add = 0.02         # 加速係数を増やす度合
stop_AF_max = 0.2          # 加速係数の上限

filter_VER = "A"           # OFFで無効


wait = 0                   #  ループの待機時間
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:
		flag["records"]["log"].append("データが存在しません")
		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 calculate_volatility( last_data ):

	high_sum = sum(i["high_price"] for i in last_data[-1 * volatility_term :])
	low_sum  = sum(i["low_price"]  for i in last_data[-1 * volatility_term :])
	volatility = round((high_sum - low_sum) / volatility_term)
	flag["records"]["log"].append("現在の{0}期間の平均ボラティリティは{1}円です\n".format( volatility_term, volatility ))
	return volatility


# 単純移動平均を計算する関数
def calculate_MA( value,before=None ):
	if before is None:
		MA = sum(i["close_price"] for i in last_data[-1*value:]) / value
	else:
		MA = sum(i["close_price"] for i in last_data[-1*value + before: before]) / value
	return round(MA)


# 指数移動平均を計算する関数
def calculate_EMA( value,before=None ):
	if before is not None:
		MA = sum(i["close_price"] for i in last_data[-2*value + before : -1*value + before]) / value
		EMA = (last_data[-1*value + before]["close_price"] * 2 / (value+1)) + (MA * (value-1) / (value+1))
		for i in range(value-1):
			EMA = (last_data[-1*value+before+1 + i]["close_price"] * 2 /(value+1)) + (EMA * (value-1) / (value+1))
	else:
		MA = sum(i["close_price"] for i in last_data[-2*value: -1*value]) / value
		EMA = (last_data[-1*value]["close_price"] * 2 / (value+1)) + (MA * (value-1) / (value+1))
		for i in range(value-1):
			EMA = (last_data[-1*value+1 + i]["close_price"] * 2 /(value+1)) + (EMA * (value-1) / (value+1))
	return round(EMA)



#-------------エントリーフィルターの関数--------------

# エントリーフィルターの関数
def filter( signal ):
	
	if filter_VER == "OFF":
		return True
	
	if filter_VER == "A":
		if len(last_data) < 200:
			return True
		if data["close_price"] > last_data[-200]["close_price"] and signal["side"] == "BUY":
			return True
		if data["close_price"] < last_data[-200]["close_price"] and signal["side"] == "SELL":
			return True
	
	if filter_VER == "B":
		if len(last_data) < 200:
			return True
		if data["close_price"] > calculate_MA(200) and signal["side"] == "BUY":
			return True
		if data["close_price"] < calculate_MA(200) and signal["side"] == "SELL":
			return True
		
	if filter_VER == "C":
		if len(last_data) < 20:
			return True
		if calculate_MA(20) > calculate_MA(20,-1) and signal["side"] == "BUY":
			return True
		if calculate_MA(20) < calculate_MA(20,-1) and signal["side"] == "SELL":
			return True
		
	if filter_VER == "D":
		if len(last_data) < 400:
			return True
		if calculate_EMA(200) < calculate_EMA(14) and signal["side"] == "BUY":
			return True
		if calculate_EMA(200) > calculate_EMA(14) and signal["side"] == "SELL":
			return True
		
	return False


#-------------資金管理の関数--------------

# 注文ロットを計算する関数
def calculate_lot( last_data,data,flag ):
	
	# 固定ロットでのテスト時
	if TEST_MODE_LOT == "fixed":
		flag["records"]["log"].append("固定ロット(1枚)でテスト中のため、1BTCを注文します\n")
		lot = 1
		volatility = calculate_volatility( last_data )
		stop = stop_range * volatility
		flag["position"]["ATR"] = round( volatility )
		return lot,stop,flag
	
	
	# 口座残高を取得する
	balance = flag["records"]["funds"]

	# 最初のエントリーの場合
	if flag["add-position"]["count"] == 0:
		
		# 1回の注文単位(ロット数)と、追加ポジの基準レンジを計算する
		volatility = calculate_volatility( last_data )
		stop = stop_range * volatility
		calc_lot = np.floor( balance * trade_risk / stop * 100 ) / 100
		
		flag["add-position"]["unit-size"] = np.floor( calc_lot / entry_times * 100 ) / 100
		flag["add-position"]["unit-range"] = round( volatility * entry_range )
		flag["add-position"]["stop"] = stop
		flag["position"]["ATR"] = round( volatility )
		
		flag["records"]["log"].append("\n現在のアカウント残高は{}円です\n".format( balance ))
		flag["records"]["log"].append("許容リスクから購入できる枚数は最大{}BTCまでです\n".format( calc_lot ))
		flag["records"]["log"].append("{0}回に分けて{1}BTCずつ注文します\n".format( entry_times, flag["add-position"]["unit-size"] ))
		
	# 2回目以降のエントリーの場合
	else:
		balance = round( balance - flag["position"]["price"] * flag["position"]["lot"] / levarage )
	
	# ストップ幅には、最初のエントリー時に計算したボラティリティを使う
	stop = flag["add-position"]["stop"]
	
	# 実際に購入可能な枚数を計算する
	able_lot = np.floor( balance * levarage / data["close_price"] * 100 ) / 100
	lot = min(able_lot, flag["add-position"]["unit-size"])
	flag["records"]["log"].append("証拠金から購入できる枚数は最大{}BTCまでです\n".format( able_lot ))
	
	return lot,stop,flag



# 複数回に分けて追加ポジションを取る関数
def add_position( data,flag ):
	
	# ポジションがない場合は何もしない
	if flag["position"]["exist"] == False:
		return flag
	
	# 固定ロット(1BTC)でのテスト時は何もしない
	if TEST_MODE_LOT == "fixed":
		return flag
	
	# 最初(1回目)のエントリー価格を記録
	if flag["add-position"]["count"] == 0:
		flag["add-position"]["first-entry-price"] = flag["position"]["price"]
		flag["add-position"]["last-entry-price"] = flag["position"]["price"]
		flag["add-position"]["count"] += 1
	
	while True:
		
		# 以下の場合は、追加ポジションを取らない
		if flag["add-position"]["count"] >= entry_times:
			return flag
		
		# この関数の中で使う変数を用意
		first_entry_price = flag["add-position"]["first-entry-price"]
		last_entry_price = flag["add-position"]["last-entry-price"]
		unit_range = flag["add-position"]["unit-range"]
		current_price = data["close_price"]
		
		
		# 価格がエントリー方向に基準レンジ分だけ進んだか判定する
		should_add_position = False
		if flag["position"]["side"] == "BUY" and (current_price - last_entry_price) > unit_range:
			should_add_position = True
		elif flag["position"]["side"] == "SELL" and (last_entry_price - current_price) > unit_range:
			should_add_position = True
		else:
			break
		
		# 基準レンジ分進んでいれば追加注文を出す
		if should_add_position == True:
			flag["records"]["log"].append("\n前回のエントリー価格{0}円からブレイクアウトの方向に{1}ATR({2}円)以上動きました\n".format( last_entry_price, entry_range, round( unit_range ) ))
			flag["records"]["log"].append("{0}/{1}回目の追加注文を出します\n".format(flag["add-position"]["count"] + 1, entry_times))
			
			# 注文サイズを計算
			lot,stop,flag = calculate_lot( last_data,data,flag )
			if lot < 0.01:
				flag["records"]["log"].append("注文可能枚数{}が、最低注文単位に満たなかったので注文を見送ります\n".format(lot))
				flag["add-position"]["count"] += 1
				return flag
			
			# 追加注文を出す
			if flag["position"]["side"] == "BUY":
				entry_price = first_entry_price + (flag["add-position"]["count"] * unit_range)
				#entry_price = round((1 + slippage) * entry_price)
				
				flag["records"]["log"].append("現在のポジションに追加して、{0}円で{1}BTCの買い注文を出します\n".format(entry_price,lot))
				
				# ここに買い注文のコードを入れる
				
				
			if flag["position"]["side"] == "SELL":
				entry_price = first_entry_price - (flag["add-position"]["count"] * unit_range)
				#entry_price = round((1 - slippage) * entry_price)
				
				flag["records"]["log"].append("現在のポジションに追加して、{0}円で{1}BTCの売り注文を出します\n".format(entry_price,lot))
				
				# ここに売り注文のコードを入れる
				
				
			# ポジション全体の情報を更新する
			flag["position"]["stop"] = stop
			flag["position"]["price"] = int(round(( flag["position"]["price"] * flag["position"]["lot"] + entry_price * lot ) / ( flag["position"]["lot"] + lot )))
			flag["position"]["lot"] = np.round( (flag["position"]["lot"] + lot) * 100 ) / 100
			
			if flag["position"]["side"] == "BUY":
				flag["records"]["log"].append("{0}円の位置にストップを更新します\n".format(flag["position"]["price"] - stop))
			elif flag["position"]["side"] == "SELL":
				flag["records"]["log"].append("{0}円の位置にストップを更新します\n".format(flag["position"]["price"] + stop))
			flag["records"]["log"].append("現在のポジションの取得単価は{}円です\n".format(flag["position"]["price"]))
			flag["records"]["log"].append("現在のポジションサイズは{}BTCです\n\n".format(flag["position"]["lot"]))
			
			flag["add-position"]["count"] += 1
			flag["add-position"]["last-entry-price"] = entry_price
	
	return flag


# トレイリングストップの関数
def trail_stop( data,flag ):

	# まだ追加ポジションの取得中であれば何もしない
	if flag["add-position"]["count"] < entry_times and TEST_MODE_LOT != "fixed":
		return flag
	
	# 高値/安値がエントリー価格からいくら離れたか計算
	if flag["position"]["side"] == "BUY":
		moved_range = round( data["high_price"] - flag["position"]["price"] )
	if flag["position"]["side"] == "SELL":
		moved_range = round( flag["position"]["price"] - data["low_price"] )
	
	# 最高値・最安値を更新したか調べる
	if moved_range < 0 or flag["position"]["stop-EP"] >= moved_range:
		return flag
	else:
		flag["position"]["stop-EP"] = moved_range
	
	# 加速係数に応じて損切りラインを動かす
	flag["position"]["stop"] = round(flag["position"]["stop"] - ( moved_range + flag["position"]["stop"] ) * flag["position"]["stop-AF"])
	
	
	# 加速係数を更新
	flag["position"]["stop-AF"] = round( flag["position"]["stop-AF"] + stop_AF_add ,2 )
	if flag["position"]["stop-AF"] >= stop_AF_max:
		flag["position"]["stop-AF"] = stop_AF_max
	
	# ログ出力
	if flag["position"]["side"] == "BUY":
		flag["records"]["log"].append("トレイリングストップの発動:ストップ位置を{}円に動かして、加速係数を{}に更新します\n".format( round(flag["position"]["price"] - flag["position"]["stop"]) , flag["position"]["stop-AF"] ))
	else:
		flag["records"]["log"].append("トレイリングストップの発動:ストップ位置を{}円に動かして、加速係数を{}に更新します\n".format( round(flag["position"]["price"] + flag["position"]["stop"]) , flag["position"]["stop-AF"] ))
	
	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"]]))
		
		# フィルター条件を確認
		if filter( signal ) == False:
			flag["records"]["log"].append("フィルターのエントリー条件を満たさなかったため、エントリーしません\n")
			return flag
		
		lot,stop,flag = calculate_lot( last_data,data,flag )
		if lot > 0.01:
			flag["records"]["log"].append("{0}円で{1}BTCの買い注文を出します\n".format(data["close_price"],lot))
			
			# ここに買い注文のコードを入れる
			
			flag["records"]["log"].append("{0}円にストップを入れます\n".format(data["close_price"] - stop))
			flag["position"]["lot"],flag["position"]["stop"] = lot,stop
			flag["position"]["exist"] = True
			flag["position"]["side"] = "BUY"
			flag["position"]["price"] = data["close_price"]
		else:
			flag["records"]["log"].append("注文可能枚数{}が、最低注文単位に満たなかったので注文を見送ります\n".format(lot))

	if signal["side"] == "SELL":
		flag["records"]["log"].append("過去{0}足の最安値{1}円を、直近の価格が{2}円でブレイクしました\n".format(sell_term,signal["price"],data[judge_price["SELL"]]))
		
		# フィルター条件を確認
		if filter( signal ) == False:
			flag["records"]["log"].append("フィルターのエントリー条件を満たさなかったため、エントリーしません\n")
			return flag
		
		lot,stop,flag = calculate_lot( last_data,data,flag )
		if lot > 0.01:
			flag["records"]["log"].append("{0}円で{1}BTCの売り注文を出します\n".format(data["close_price"],lot))
			
			# ここに売り注文のコードを入れる
			
			flag["records"]["log"].append("{0}円にストップを入れます\n".format(data["close_price"] + stop))
			flag["position"]["lot"],flag["position"]["stop"] = lot,stop
			flag["position"]["exist"] = True
			flag["position"]["side"] = "SELL"
			flag["position"]["price"] = data["close_price"]
		else:
			flag["records"]["log"].append("注文可能枚数{}が、最低注文単位に満たなかったので注文を見送ります\n".format(lot))

	return flag



# 手仕舞いのシグナルが出たら決済の成行注文 + ドテン注文 を出す関数
def close_position( data,last_data,flag ):
	
	if flag["position"]["exist"] == False:
		return 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,data["close_price"] )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			flag["position"]["stop-AF"] = stop_AF
			flag["position"]["stop-EP"] = 0
			flag["add-position"]["count"] = 0
			
			
			# ドテン注文の箇所
			if filter( signal ) == False:
				flag["records"]["log"].append("フィルターのエントリー条件を満たさなかったため、ドテンエントリーはしません\n")
				return flag
			
			lot,stop,flag = calculate_lot( last_data,data,flag )
			if lot > 0.01:
				flag["records"]["log"].append("\n{0}円で{1}BTCの売りの注文を入れてドテンします\n".format(data["close_price"],lot))
				
				# ここに売り注文のコードを入れる
				
				flag["records"]["log"].append("{0}円にストップを入れます\n".format(data["close_price"] + stop))
				flag["position"]["lot"],flag["position"]["stop"] = lot,stop
				flag["position"]["exist"] = True
				flag["position"]["side"] = "SELL"
				flag["position"]["price"] = data["close_price"]
			


	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,data["close_price"] )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			flag["position"]["stop-AF"] = stop_AF
			flag["position"]["stop-EP"] = 0
			flag["add-position"]["count"] = 0
			
			
			# ドテン注文の箇所
			if filter( signal ) == False:
				flag["records"]["log"].append("フィルターのエントリー条件を満たさなかったため、ドテンエントリーはしません\n")
				return flag
			
			lot,stop,flag = calculate_lot( last_data,data,flag )
			if lot > 0.01:
				flag["records"]["log"].append("\n{0}円で{1}BTCの買いの注文を入れてドテンします\n".format(data["close_price"],lot))
				
				# ここに買い注文のコードを入れる
				
				flag["records"]["log"].append("{0}円にストップを入れます\n".format(data["close_price"] - stop))
				flag["position"]["lot"],flag["position"]["stop"] = lot,stop
				flag["position"]["exist"] = True
				flag["position"]["side"] = "BUY"
				flag["position"]["price"] = data["close_price"]
			
	return flag



# 損切ラインにかかったら成行注文で決済する関数
def stop_position( data,flag ):
	
	# トレイリングストップを実行
	if stop_config == "TRAILING":
		flag = trail_stop( data,flag )

	if flag["position"]["side"] == "BUY":
		stop_price = flag["position"]["price"] - flag["position"]["stop"]
		if data["low_price"] < stop_price:
			flag["records"]["log"].append("{0}円の損切ラインに引っかかりました。\n".format( stop_price ))
			stop_price = round( stop_price - 2 * calculate_volatility(last_data) / ( chart_sec / 60) )
			flag["records"]["log"].append(str(stop_price) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data,stop_price,"STOP" )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			flag["position"]["stop-AF"] = stop_AF
			flag["position"]["stop-EP"] = 0
			flag["add-position"]["count"] = 0
	
	
	if flag["position"]["side"] == "SELL":
		stop_price = flag["position"]["price"] + flag["position"]["stop"]
		if data["high_price"] > stop_price:
			flag["records"]["log"].append("{0}円の損切ラインに引っかかりました。\n".format( stop_price ))
			stop_price = round( stop_price + 2 * calculate_volatility(last_data) / (chart_sec / 60) )
			flag["records"]["log"].append(str(stop_price) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data,stop_price,"STOP" )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			flag["position"]["stop-AF"] = stop_AF
			flag["position"]["stop-EP"] = 0
			flag["add-position"]["count"] = 0
			
	return flag


#------------バックテストの部分の関数--------------


# 各トレードのパフォーマンスを記録する関数
def records(flag,data,close_price,close_type=None):
	
	# 取引手数料等の計算
	entry_price = int(round(flag["position"]["price"] * flag["position"]["lot"]))
	exit_price = int(round(close_price * flag["position"]["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"] )
	
	# 損切りにかかった回数をカウント
	if close_type == "STOP":
		flag["records"]["stop-count"].append(1)
	else:
		flag["records"]["stop-count"].append(0)
	
	# 値幅の計算
	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 ))
		flag["records"]["funds"] = flag["records"]["funds"] + buy_profit
		if buy_profit  > 0:
			log = str(buy_profit) + "円の利益です\n\n"
			flag["records"]["log"].append(log)
		else:
			log = str(buy_profit) + "円の損失です\n\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 ))
		flag["records"]["funds"] = flag["records"]["funds"] + sell_profit
		if sell_profit > 0:
			log = str(sell_profit) + "円の利益です\n\n"
			flag["records"]["log"].append(log)
		else:
			log = str(sell_profit) + "円の損失です\n\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"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 連敗回数をカウントする
	consecutive_defeats = []
	defeats = 0
	for p in flag["records"]["profit"]:
		if p < 0:
			defeats += 1
		else:
			consecutive_defeats.append( defeats )
			defeats = 0
	
	# テスト日数を集計
	time_period = datetime.fromtimestamp(last_data[-1]["close_time"]) - datetime.fromtimestamp(last_data[0]["close_time"])
	time_period = int(time_period.days)
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 資産推移の列を追加する
	records["Funds"] = records.Gross + start_funds
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Funds.cummax().subtract(records.Funds)
	records["DrawdownRate"] = round(records.Drawdown / records.Funds.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(),
		"Funds"    :  grouped.Funds.last(),
		"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("損切りの回数       :  {}回".format( buy_records.Stop.sum() ))
	
	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("損切りの回数       :  {}回".format( sell_records.Stop.sum() ))
	
	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.Rate.std(),2)))
	print("平均利益率         :  {}%".format(round(records[records.Profit>0].Rate.mean(),2) ))
	print("平均損失率         :  {}%".format(round(records[records.Profit<0].Rate.mean(),2) ))
	print("平均保有期間       :  {}足分".format( round(records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( records.Stop.sum() ))
	print("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	print("最大連敗回数       :  {}回".format( max(consecutive_defeats) ))
	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("最終損益           :  {}円".format( records.Profit.sum() ))
	print("")
	print("初期資金           :  {}円".format( start_funds ))
	print("最終資金           :  {}円".format( records.Funds.iloc[-1] )) 
	print("運用成績           :  {}%".format( round(records.Funds.iloc[-1] / start_funds * 100),2 ))
	print("手数料合計         :  {}円".format( -1 * records.Slippage.sum() ))
	
	print("-----------------------------------")
	print("各成績指標")
	print("-----------------------------------")
	print("CAGR(年間成長率)         :  {}%".format( round((records.Funds.iloc[-1] / start_funds)**(  365 / time_period ) * 100 - 100,2)   ))
	print("MARレシオ                :  {}".format(round( (records.Funds.iloc[-1] / start_funds -1)*100 / records.DrawdownRate.max(),2 )))
	print("シャープレシオ           :  {}".format( round(records.Rate.mean()/records.Rate.std(),2) ))
	print("プロフィットファクター   :  {}".format( round(records[records.Profit>0].Profit.sum()/abs(records[records.Profit<0].Profit.sum()),2) ))
	print("損益レシオ               :  {}".format(round( records[records.Profit>0].Rate.mean()/abs(records[records.Profit<0].Rate.mean()) ,2)))
	
	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) ))
		print("月末資金           :  {}円".format( row.Funds.astype(int) ))
	
	
	# 際立った損益を表示
	n = 10
	print("------------------------------------------")
	print("+{}%を超えるトレードの回数  :  {}回".format(n,len(records[records.Rate>n]) ))
	print("------------------------------------------")
	for index,row in records[records.Rate>n].iterrows():
		print( "{0}  |  {1}%  |  {2}".format(row.Date,round(row.Rate,2),row.Side ))
	print("------------------------------------------")
	print("-{}%を下回るトレードの回数  :  {}回".format(n,len(records[records.Rate< n*-1]) ))
	print("------------------------------------------")
	for index,row in records[records.Rate < n*-1].iterrows():
		print( "{0}  |  {1}%  |  {2}".format(row.Date,round(row.Rate,2),row.Side  ))
	
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])
	
	
	# 損益曲線をプロット
	plt.subplot(1,2,1)
	plt.plot( records.Date, records.Funds )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	
	# リターン分布の相対度数表を作る
	plt.subplot(1,2,2)
	plt.hist( records.Rate,50,rwidth=0.9)
	plt.axvline( x=0,linestyle="dashed",label="Return = 0" )
	plt.axvline( records.Rate.mean(), color="orange", label="AverageReturn" )
	plt.legend() # 凡例を表示
	plt.show()
	


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

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

flag = {
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"stop":0,
		"stop-AF": stop_AF,
		"stop-EP":0,
		"ATR":0,
		"lot":0,
		"count":0
	},
	"add-position":{
		"count":0,
		"first-entry-price":0,
		"last-entry-price":0,
		"unit-range":0,
		"unit-size":0,
		"stop":0
	},
	"records":{
		"date":[],
		"profit":[],
		"return":[],
		"side":[],
		"stop-count":[],
		"funds" : start_funds,
		"holding-periods":[],
		"slippage":[],
		"log":[]
	}
}


last_data = []
need_term = max(buy_term,sell_term,volatility_term)
i = 0
while i < len(price):

	# ドンチャンの判定に使う期間分の安値・高値データを準備する
	if len(last_data) < need_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["position"]["exist"]:
		if stop_config != "OFF":
			flag = stop_position( data,flag )
		flag = close_position( data,last_data,flag )
		flag = add_position( 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)

BitflyerでBTCFXの単純移動平均線と指数移動平均線をpythonで実装しよう!

この記事では、Bitflyerの価格データからBTCFXの単純移動平均線(MA)と、指数平滑移動平均線(EMA)の作り方を勉強します!移動平均線の説明はいらないと思うので早速本題に入りましょう!

なお、今回は「移動平均線をグラフで描画する」といったあまり実践的でないことはしません。グラフはチャートで見れますからね。BOTの売買ルールで使うことを想定して「指定した足の移動平均の数値を返す」だけの関数を作りましょう!

Bitflyerから価格データを取得する

BTCFXの価格データはCryptowatchを使って取得します。
CryptowatchのAPIの使い方や仕様については以下の記事を読んでください。

Bitflyerのローソク足の情報をpythonで取得する
バックテストに必要なローソク足データを集める

▽ BitflyerFXのローソク足データを取得する関数


import requests
from datetime import datetime

# 使用する時間足
chart_sec = 3600

# CryptowatchでBTCFXの価格データを取得
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)]:
			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

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


特に引数を設定しなければ、直近の500件のローソク足データがセットされます。移動平均線を作るにはこれで十分なので、500件で進めます。

単純移動平均線の値を返す関数

単純移動平均線は、例えば、10期間の移動平均(10MA)であれば、直近の10足の終値の平均値を取るだけです。非常にシンプルで使いやすく、変な加工がされていないので統計的にも最も信頼できる平均値です。

Pythonコード


# 移動平均線の値を返す関数
def calculate_MA( value ):
	MA = sum(i["close_price"] for i in price[-1*value:]) / value
	return round(MA)

単純移動平均の関数は上記のように3行で書けます。
特別なライブラリも何も必要ありません。

では、計算結果を確認しておきましょう!


#---- ここからメインの実行処理----

price = get_price(chart_sec)
MA10 = calculate_MA(10)     # 現在の10期間移動平均の数値
MA21 = calculate_MA(21)     # 現在の21期間移動平均の数値
MA100 = calculate_MA(100)   # 現在の100期間移動平均の数値
print( MA10,MA21,MA100 )


最初のCrytowatchの関数と、さきほどの移動平均を計算する関数をまるまるコピペして、その下に上記のコードを加えて実行してみてください。そしてBitflyerFXの画面のSMA(単純移動平均線)と比較してみましょう。同じ数値になっているはずです。

実行結果

▽ チャート画面との一致を確認しよう

なお、一番新しいまだリアルタイムで変動中の足の移動平均を計算しているので、計算結果は実行するたびに変わります。

コードの解説

例えば、10期間の移動平均線の場合、pythonではprice[-10:] と記載することで直近の10期間の価格データを取りだすことができます(配列のスライスというテクニックです)。なので、for文で10期間の終値を合計してそれを10で割れば、単純移動平均を計算できます。

ここでは自由に期間を変更できるようにしたいので、例としてあげた数値の10を value という変数に置き換えて、price[-1*value:] としています。

さらに実践的な単純移動平均の関数

例えば、エントリー条件として「X期間の移動平均線がn日前よりも上がっている場合」などのフィルターをかけることがあります。そのため、現在の移動平均線の値だけでなく、n足前の移動平均線の値も取得できるようにしましょう。

以下のように変更するだけです。


# 単純移動平均を計算する関数
def calculate_MA( value,before=None ):
	if before is not None:
		MA = sum(i["close_price"] for i in price[-1*value + before: before]) / value
	else:
		MA = sum(i["close_price"] for i in price[-1*value:]) / value
	return round(MA)

何も引数を与えなければ、先ほどと同じ直近(まだ形成中)の足の移動平均線の数値を返します。「-10」などの数値を与えると、10期間前の移動平均線の値を返します。

ではやってみましょう!


#---- ここからメインの実行処理----

price = get_price(chart_sec)    # 価格データを取得
MA10 = calculate_MA(10,-10)     # 10期間前の10MAの数値
MA21 = calculate_MA(21,-10)     # 10期間前の21MAの数値
MA100 = calculate_MA(100,-10)   # 10期間前の100MAの数値
print( MA10,MA21,MA100 )

実行結果

さきほどと同じようにチャートでも確認しておきましょう。
10期間前というのは、リアルタイムで動いている足から遡って10期間なので、以下のことです。

少し目盛り幅がわかりにくいですが、ちゃんと計算できてるのがわかりますね。移動平均線の基本的な使い方(2本のクロス判定やMACDの計算など)は、全てこの関数をベースにできますので、ひとまずこれで完成でいいと思います。

では次に指数平滑移動平均線を計算してみましょう。

指数平滑移動平均線の値を返す関数

ご存知だと思いますが、指数平滑移動平均線は直近の価格データにより大きいウエイトを置いた移動平均線です。昔の価格が計算期間から外れても、その影響が出ないように特別に加工された平均値の計算方法です。

よりシンプルな売買ロジックを好む人は、単純移動平均を使うことが多い気がしますが、指数移動平均もかなり人気があると思うので一応、解説しておきましょう!

EMAの計算式

指数平滑移動平均(EMA)の計算は、以下の式になります。

こういうのは具体的な数字を入れた方がわかりやすいと思うので、10期間のEMAの計算式を見てみましょう!

これは何を計算しているのかというと、要するに直近の価格だけを2倍して2回分のデータとして扱って、平均値を取っているわけですね。だから分母が(10+1)になり、終値のところの分子だけ(2)になっています。

なお、計算式の中に「前回のEMA」が入っていますが、これは初回だけは単純移動平均(MA)の値を使います。

EMAは計算期間によって数値が変わる

最初にEMAの性質として理解して欲しいところですが、EMAは単純移動平均と違って完全に1つの数値に定まるわけではありません。

上記の計算式を見ればわかりますが、指数平滑移動平均(EMA)は、過去のEMAをずっと参照し続けています。そのため、古い価格データの割合はどんどん小さくなるものの、完全に無くなるわけではありません。

そのため、どこから計算を開始するかによって同じ期間のEMAでも若干値が変わります。例えば、同じ30日間の指数移動平均でも「今日から計算を始めた人」と「数カ月前から計算し続けている人」では数値が一致しません。

Pythonコード

では先ほどと同様、まずは直近の足の指数平滑移動平均(EMA)を計算するコードを書いてみましょう。


# 指数移動平均を計算する関数
def calculate_EMA( value ):
	MA = sum(i["close_price"] for i in price[-2*value: -1*value]) / value
	EMA = (price[-1*value]["close_price"] * 2 / (value+1)) + (MA * (value-1) / (value+1))
	for i in range(value-1):
		EMA = (price[-1*value+1+i]["close_price"] * 2 /(value+1)) + (EMA * (value-1) / (value+1))
	return round(EMA)

コードの解説

最初の2行では、「初回のEMA」の値を計算しています。 例えば、20期間EMAであれば、最初に過去の20期間の単純移動平均(MA)を計算し、そこに次の足の終値を加重して初回のEMA(指数平滑移動平均)を計算します。

これで完成でも別に構わないのですが、せっかくわざわざ指数平滑移動平均を使うわけですから、直近の価格データに重みづけされた平均値が欲しいはずです。そのため、さらに20期間多く遡って、直近の20期間のデータを使ってEMAを20回計算しています。

(例)20期間EMAの場合

・直近40期間のデータを使う
・一番古い20期間で単純移動平均(MA)を作り、EMAを計算する
・残りの20期間を使って指数平滑移動平均を20回計算する

(例)50期間EMAの場合

・直近100期間のデータを使う
・一番古い50期間で単純移動平均(MA)を作り、EMAを計算する
・残りの50期間を使って指数移動平均を50回計算する

これでどの時点からEMAの計算を開始しても、長い期間計測している人と同じくらい、しっかり直近の価格に重みづけされた平均値になっているはずです。

実行してみよう!

では先ほどと同様、テストしてみましょう。


#---- ここからメインの実行処理----

price = get_price(chart_sec)
EMA10 = calculate_EMA(10)
EMA21 = calculate_EMA(21)
EMA100 = calculate_EMA(100)
print( EMA10,EMA21,EMA100 )


実行結果

今回も実際のBitflyerのチャートに照らし合わせて確認してみましょう。

実用上問題のない程度にしっかり一致していますね! 何らかの判定に使う際でも、このくらいの精度なら問題ないでしょう。

より実践的な指数平滑移動平均の関数

では先ほどと全く同じように、過去の期間を指定してその時点のEMAの数値を取得できるように修正してみましょう。関数の中の数式のすべての期間を指定分ズラすだけなので、それほど難しくありません。


# 指数移動平均を計算する関数
def calculate_EMA( value,before=None ):
	if before is not None:
		MA = sum(i["close_price"] for i in price[-2*value + before : -1*value + before]) / value
		EMA = (price[-1*value + before]["close_price"] * 2 / (value+1)) + (MA * (value-1) / (value+1))
		for i in range(value-1):
			EMA = (price[-1*value+before+1 + i]["close_price"] * 2 /(value+1)) + (EMA * (value-1) / (value+1))
	else:
		MA = sum(i["close_price"] for i in price[-2*value: -1*value]) / value
		EMA = (price[-1*value]["close_price"] * 2 / (value+1)) + (MA * (value-1) / (value+1))
		for i in range(value-1):
			EMA = (price[-1*value+1 + i]["close_price"] * 2 /(value+1)) + (EMA * (value-1) / (value+1))
	return round(EMA)

先ほどと同様、何も引数を与えなければ直近の(リアルタイムで変動中の)足の指数移動平均の数値を返し、「-10」などの期間を与えると、10足前の時点での指数移動平均の数値を返します。

では、実際にこれでBitflyerFXの20期間足前の指数移動平均を確認してみましょう!


#---- ここからメインの実行処理----

price = get_price(chart_sec)     # 価格データを取得
EMA10 = calculate_EMA(10,-20)    # 20期間前の10EMAの数値を計算
EMA21 = calculate_EMA(21,-20)    # 20期間前の21EMAの数値を計算
EMA100 = calculate_EMA(100,-20)  # 20期間前の100EMAの数値を計算
print( EMA10,EMA21,EMA100 )

実行結果

20期間前の足というとこれですね。また少し目盛りの単位がわかりにくいですが、しっかり一致しています。

この関数だと、どの時点からでも正確なEMAを計算できますし、遡って過去の時点のEMAを計算することもできます。ただしそれなりにローソク足データが必要な点に注意してください。

例えば、100期間の指数移動平均(EMA)となると、直近の足でも計算に200期間のデータが必要です。さらに100足前の時点のEMAを計算するとなると、合計300期間遡ることになるため、最低300期間分のデータが必要です。

BOTで運用するとき

なお実際にBOTを運用するときは、コード内の変数に前回のEMAの値を記録しておいて、While文で新しいローソク足を処理するたびにEMAの値を更新することが一般的だと思います。その場合は、最初の起動時に1回だけ、上記の関数を使ってEMAを計算することになります。

ループのたびに過去の単純移動平均(MA)や指数平滑移動平均(EMA)を配列に記録しておくスタイルであれば、上記で解説した「過去の足のMAやEMAを取得する箇所」のコードは必要ありません。リアルタイムの足で計算したものを全部保存しておけばいいだけだからです。

BitflyerのEMAを実践で使う場合の注意点

先ほども述べたように、指数移動平滑平均(EMA)は「誰が計算しても1つの値に定まる」という性質の数値ではありません。どれだけの過去データを考慮に入れるかによって数値が変動します。

これについては、以下の英文記事を読んでいただくとよくわかります。以下の英文記事は、実際にBitflyerのチャート画面のボタンから参照されている解説ページです。

▽ 引用 (記事リンク

Therefore, the current EMA value will change depending on how much past data you use in your EMA calculation. Ideally, for a 100% accurate EMA, you should use every data point the stock has ever had in calculating the EMA, starting your calculations from the first day the stock existed. This is not always practical, but the more data points you use, the more accurate your EMA will be.

この記事によると、最も正確なEMAとは、「市場が開始した1日目からの価格をすべて考慮に入れた計算結果」だそうですが、そのような計算は実質的に不可能です。

例えば、私の解説した関数では200EMAの計算に過去400期間のデータを使っています。しかしBitflyerの画面に表示される200EMAの計算期間はわかりません。一般的に短期EMAの場合ほど計測期間によるズレは少ないですが、長期EMAになるほど計測期間の違いによって数値にズレが生じる可能性があります。

売買シグナルにBitflyer画面に表示されるEMAとピッタリ一致する数値を使いたい場合は、何らかの参考サイトをスクレイピングするしかないかもしれません。あるいは単純移動平均を使うかです。

今回使ったコード


import requests
from datetime import datetime

# 使用する時間足
chart_sec = 3600

# CryptowatchでBTCFXの価格データを取得
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)]:
			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


# 単純移動平均を計算する関数
def calculate_MA( value,before=None ):
	if before is not None:
		MA = sum(i["close_price"] for i in price[-1*value + before: before]) / value
	else:
		MA = sum(i["close_price"] for i in price[-1*value:]) / value
	return round(MA)

# 指数移動平均を計算する関数
def calculate_EMA( value,before=None ):
	if before is not None:
		MA = sum(i["close_price"] for i in price[-2*value + before : -1*value + before]) / value
		EMA = (price[-1*value + before]["close_price"] * 2 / (value+1)) + (MA * (value-1) / (value+1))
		for i in range(value-1):
			EMA = (price[-1*value+before+1 + i]["close_price"] * 2 /(value+1)) + (EMA * (value-1) / (value+1))
	else:
		MA = sum(i["close_price"] for i in price[-2*value: -1*value]) / value
		EMA = (price[-1*value]["close_price"] * 2 / (value+1)) + (MA * (value-1) / (value+1))
		for i in range(value-1):
			EMA = (price[-1*value+1 + i]["close_price"] * 2 /(value+1)) + (EMA * (value-1) / (value+1))
	return round(EMA)

#---- ここからメインの実行処理----

price = get_price(chart_sec)     # 価格データを取得
EMA10 = calculate_EMA(10,-20)
EMA21 = calculate_EMA(21,-20)
EMA100 = calculate_EMA(100,-20)
print( EMA10,EMA21,EMA100 )