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 )

運用BOTのリターン分布や頻度をヒストグラム(相対度数表)で確認しよう!

この章ではバックテスト編で作成したBOTの売買ロジックに、さらに移動平均線などのフィルターを加えてエントリー条件を絞ることで、もっと精度を上げることを目指します。

しかしその前にそもそもBOTの売買ロジックの期待値が一体どこから来ているものなのか、その特徴を理解しておく必要があります。BOTの強みや特徴を理解しないまま、成績指標などの数値だけを見ながら無闇にフィルターを加えてしまうと、本来のBOTが持つ利益機会を削ってしまう可能性があるからです。

成績を評価する指標

成績を評価する指標には、例えば、以下のようなものがあります。

1)運用成績(≒CAGR)
2)プロフィットファクター
3)シャープレシオ
4)MARレシオ

運用成績については前章の「資金管理編」、プロフィットファクターについては「バックテスト編」のパラメーター探索の記事で解説しましたね。各々の指標の教科書的な説明は検索して調べてみてください。

フィルターの有効性を検証する上で、当然これらの指標も使います。しかしこれらの数値を確認する前に、もっと単純かつ原始的なレベルで把握しておくべきことがあります。それがリターン分布の形状です。

リターン分布の形状

ご存知の方からすれば当たり前の話ですが、各トレードのリターンは正規分布ではありません。例えば、トレンドフォローの典型例であるドンチアン・チャネルブレイクアウトBOTの場合、各トレードのリターン分布は以下のような形状をしています。

設定値

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

▽ 「常に1BTCだけを売買した場合」の損益グラフとリターン分布

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  130回
勝率               :  41.5%
平均リターン       :  1.8%
標準偏差           :  7.78%
平均利益率         :  8.49%
平均損失率         :  -2.96%
平均保有期間       :  31.3足分
損切りの回数       :  112回

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

初期資金           :  1000000円
最終資金           :  4007707円
運用成績           :  401.0%
手数料合計         :  -141393円
-----------------------------------
各成績指標
-----------------------------------
MARレシオ                :  18.71
シャープレシオ           :  0.23
プロフィットファクター   :  2.15
損益レシオ               :  2.81
-----------------------------------

※ このフィルター編では、資金管理の方法の違いによる影響を排除するため、資金量に関わらず、常に1BTCだけ売買するものとします。またリターン分布図の作り方は後半で解説します。

さて、もう1度リターン分布だけを拡大してみてみましょう。

▽ リターン分布図
(点線は損益±0、オレンジは期待値)

各トレードのリターン率の中で最も出現頻度が高い数値(最頻値)は-2%台です。かつ、ほとんどのトレードが -4~1%の範囲に集中しているのがわかります。

このようにトレンドフォロー型のブレイクアウトBOTは、もともとの勝率が低いため、ほとんどのトレードは僅かなマイナスの結果に終わります。しかし中心から離れたところをみると、右側にだけ末広がりに伸びているのがわかります。つまり左右非対称なファットテールの形状です。

極端に高いリターンと損失の割合

試しに検証結果のうち、10%以上の損失に終わった回数と、10%以上の利益に終わった回数を比較してみましょう。以下のようにpandasで集計して出力してみます。

▽ 極端に高いリターンと損失の回数
(2017年9月~2018年5月の検証期間)

----------------------------------------
+10%を超えるトレードの回数  :  17回
----------------------------------------
2017-10-15 18:00:00  |  17.21%  |  BUY
2017-11-02 21:00:00  |  12.62%  |  BUY
2017-11-12 18:00:00  |  10.82%  |  SELL
2017-11-17 17:00:00  |  18.72%  |  BUY
2017-11-30 01:00:00  |  25.13%  |  BUY
2017-12-08 08:00:00  |  40.7%   |  BUY
2017-12-17 08:00:00  |  15.39%  |  BUY
2017-12-23 04:00:00  |  18.6%   |  SELL
2018-01-20 15:00:00  |  19.61%  |  SELL
2018-01-31 18:00:00  |  15.08%  |  SELL
2018-02-03 00:00:00  |  10.26%  |  SELL
2018-02-06 06:00:00  |  15.44%  |  SELL
2018-02-10 19:00:00  |  17.69%  |  BUY
2018-02-16 16:00:00  |  16.34%  |  BUY
2018-03-10 01:00:00  |  20.34%  |  SELL
2018-03-30 15:00:00  |  19.34%  |  SELL
2018-05-06 06:00:00  |  10.56%  |  BUY
----------------------------------------
-10%を下回るトレードの回数  :  1回
----------------------------------------
2017-12-23 09:00:00  |  -10.1%  |  BUY

※ 左列は各ポジションを閉じたときの日時

10%を下回るトレードは1回しか存在しない一方、10%を超えるトレードは17回も存在することがわかります。また20%を超えるリターンは合計3回あり、そのうち1回はなんと40%ものリターンを生み出しています。

トレードの時期やエントリーの方向にも極端な偏りはありません。たしかに昨年の11月や12月の暴騰相場で40%という驚異のリターンを出していますが、3月以降にも20%近いリターンを2回記録しています。

この左右非対称性こそが、トレンドフォローBOTの利益の源泉です。

ストップを用いない場合

なお、この傾向について「ストップを入れてるんだから損小利大になるのは当たり前だろう」と思う方もいるかもしれません。しかしそうではありません。これはトレンドフォロー戦略そのものの特徴です。

確認のために、ストップを用いない場合もテストしてみましょう。

▽ ストップ(損切り)を全く使わない場合

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  99回
勝率               :  47.5%
平均リターン       :  1.81%
標準偏差           :  9.68%
平均利益率         :  8.86%
平均損失率         :  -4.57%
平均保有期間       :  60.1足分
損切りの回数       :  0回
-----------------------------------
+10%を超えるトレードの回数  :  14回
-----------------------------------
2017-10-15 18:00:00  |  30.37%  |  BUY
2017-11-06 14:00:00  |  23.84%  |  BUY
2017-11-18 10:00:00  |  16.15%  |  BUY
2017-11-30 05:00:00  |  19.85%  |  BUY
2017-12-09 20:00:00  |  42.49%  |  BUY
2018-01-13 22:00:00  |  10.13%  |  SELL
2018-01-20 15:00:00  |  19.93%  |  SELL
2018-02-04 00:00:00  |  25.94%  |  SELL
2018-02-07 06:00:00  |  13.34%  |  SELL
2018-02-11 12:00:00  |  13.07%  |  BUY
2018-02-18 18:00:00  |  24.97%  |  BUY
2018-03-12 03:00:00  |  15.81%  |  SELL
2018-04-02 17:00:00  |  19.64%  |  SELL
2018-04-25 20:00:00  |  15.27%  |  BUY
-----------------------------------
-10%を下回るトレードの回数  :  6回
-----------------------------------
2017-12-01 23:00:00  |  -13.9%  |  SELL
2017-12-11 11:00:00  |  -12.05% |  SELL
2017-12-24 20:00:00  |  -12.7%  |  BUY
2018-02-20 01:00:00  |  -11.54% |  SELL
2018-02-22 19:00:00  |  -10.65% |  BUY
2018-04-12 21:00:00  |  -13.81% |  SELL

損切りを全く用いない場合、さらにリターンのバラツキは大きくなりますが、基本的な傾向は変わりません。やはり左右非対称で右側に広がったファットテールの分布になっています。

-10%を下回るトレードは6回しかなく、-15%を下回るトレードは1回もありません。一方、+15%を上回るトレードは11回もあり、+20%を超えるトレードも5回に増えています。

各リターン率の頻度

では、最初の図を各リターン率の回数と頻度の表にしてみましょう。
集計してみると以下のようになります。

▽ 各トレードのリターン率と頻度(全130回)

リターン率 回数 頻度
-10%~-5% 10回 8%
-5%~0% 65回 50%
0%~5% 23回 18%
5%~10% 15回 12%
10%~15% 5回 3%
15%~20% 10回 7%
20%~ 2回 2%

全トレードのうち半分は-5%以内の範囲の損失となり、逆に10%以上の大勝ちは10回エントリーしたうちの1回程度しかないことがわかります。

フィルターを用いるときの注意点

このようにトレンドフォロー型は、「たまに来る大勝ち」に賭けるタイプのトレード戦略です。多少勝率が悪くても、大きなトレンドに確実に乗ることが保証されているからこそ、全体で見るとプラスの期待値が生まれることを知っておかなければなりません。

10~20%台の利益のトレンドを数回逃しただけでも全体のパフォーマンスは大きく悪化するため、逆張り型のBOTとは異なり、あまり無闇にエントリー条件を絞るべきではありません。

自身のBOTの利益機会がどこにあるのかを理解していないと、間違ったフィルターでエントリー機会を絞ってしまい、本来のBOTが持つ優位性が損なわれてしまう可能性があります。次回から具体的なフィルターを使ってテストを行っていきます。

2)収益指標の計算

さて、では準備として今回のフィルター編で必要になる各成績指標の計算方法を確認しておきましょう。フィルターの有効性を検証する指標として、期待値やトレード回数、運用成績、ドローダウンなどの通常の指標に加えて、以下の指標を使います。

なお、成績指標はバックテストの結果を pandas で集計して records という変数に格納している前提で解説します。詳しくはこちらの記事を参考にしてください。

1.MARレシオ

MARレシオは、運用成績を最大ドローダウン率で割った数字です。

違う言い方をすると、資産が1%減るリスク(覚悟)を受け入れる代わりに何%のリターンが期待できるか、という指標です。分子にリターンをとって分母にリスクをとっているので、大きいほど良い数字になります。

▽ MARレシオの計算例


# (運用成績 - 1)÷ 最大ドローダウン率
MAR_ratio = round( (records.Funds.iloc[-1] / start_funds -1)*100 / records.DrawdownRate.max(),2 )

なお、MARレシオに「いくつ以上なら良い」という数値目標はありません。これは分子に運用成績が含まれているからです。

運用成績は「初期資金」と「資金管理の方法」によって全く違う数字になります。これはすでに資金管理編で確認した通りです。そのため、同じ資金条件でテストした場合のみ、比較可能な数値である点に注意してください。

2.シャープレシオ

シャープレシオは、平均リターン(1回のトレードの期待値)をリターンの標準偏差で割った数字です。

標準偏差とは、数字が中心の平均値からどのくらいバラついているかを表す数値です。これは先ほどのドンチアン・ブレイクアウトBOTの例をみると、凄くわかりやすいでしょう。

このBOTの平均リターンは1.8%ですが、そのバラつき具合は-10%~40%にも及び、その標準偏差は 7.78%です。

(例)30期間のドンチアン・ブレイクアウトBOTの場合

・平均リターン(期待値) 1.8%
・標準偏差 7.78%
・平均利益率 8.49%
・平均損失率 -2.96%
・最大利益率 40.7%
・最大損失率 -10.1%

シャープレシオは、平均リターンを分子に、そのバラつき具合(標準偏差)を分母にとることで、リターンの安定性を図ります。高ければ高いほど毎回のトレードで安定したリターンが期待できます。

▽ シャープレシオの計算例


# 平均リターン率 ÷ リターン率の標準偏差
Sharp_ratio = round(records.Rate.mean()/records.Rate.std(),2)

ただし先ほども述べたように、標準偏差といっても、チャネルブレイクアウトBOTのリターンは左右対称にバラついているわけではありません。各回の損失は非常に安定していて、リターンだけが極端に右側にバラついた形状をしています。

このような場合、標準偏差が大きいことは必ずしも悪いことでないので、シャープレシオの数値にあまり拘る必要はありません。どちらかというと、勝率の高いカウンタートレード型のBOTに有効な指標だと思います。

3.プロフィットファクター

これは既にパラメーター最適化の記事で解説しましたが、総利益 を 総損失 で割った数値です。

非常にシンプルでわかりやすく、この数値の中に、勝率・損益レシオ・期待値などの要素が総合的に含まれているため、もっとも信頼できる指標の1つだと思います。

▽ プロフィットファクターの計算例


# 総利益 ÷ 総損失
PF = round(records[records.Profit>0].Profit.sum()/abs(records[records.Profit<0].Profit.sum()),2)

ただし弱点として、勝率の悪さから生じる「連敗する確率」を一切考慮していないので、途中過程でどのくらいのドローダウンに見舞われる可能性があるかは、この数値からは全く見当がつきません。

そのため、最初のMARレシオと併せて比較することが多いです。

4.CAGR(年間成長率)

これは単に「運用成績」を年率に換算しただけの数値です。

同じ期間を使ってテストするのであれば、いままでどおり運用成績を使って問題ありません。しかし1時間足と2時間足を比較する場合や、去年(~12月)と今年(~5月)を比較する場合など、前提のテスト期間が異なる場合は、年率に換算しないと比較できません。そこでCAGRを使います。

通常は数年間の成績を1年の成長率に換算するのですが、BTCFXはテスト期間が短いので、テスト日数を1年に換算しています。

▽ CAGRの計算例


# テスト期間(日数)を集計
time_period = datetime.fromtimestamp(last_data[-1]["close_time"]) - datetime.fromtimestamp(last_data[0]["close_time"])
time_period = int(time_period.days)

# CAGRを計算
CAGR = round((records.Funds.iloc[-1] / start_funds)**(  365 / time_period ) * 100 - 100,2)

なお、MARレシオの分子には本来、CAGRを使うことが多いです。しかしBTCFXではテスト期間が1年に満たない場合も多く、最大ドローダウン率を等倍して年率換算するのはおかしいので、分子にはドローダウンと同じ期間の運用成績を使うことにします。

以上で成績指標は完成です!

3)リターン分布の相対度数表の作り方

ではリターン分布図の作り方を確認しておきましょう。先ほどの図のように、どのリターン率がどのくらいの頻度で生じるか、という「頻度」を棒グラフにした統計図のことを相対度数表(ヒストグラム)といいます。

相対度数表は、matplotlibの hist() を使えば、以下のようなコードを2行書くだけで作れます。


# リターン分布の相対度数表を作る
plt.hist( records.Rate,50,rwidth=0.9)
plt.show()

2番目の引数の「50」で、データを何区間に分類して棒グラフにするかを指定することができます。例えば、ここを「10」に設定すれば、以下のようにもっとおおまかな相対度数表を作れます。

▽ リターン率の相対度数表(10区間に分類)

「100」に設定すれば、以下のようにさらに細かくなります。

▽ リターン率の相対度数表(100区間に分類)

なお、ヒストグラムの作り方や引数の設定等は以下のページを参考にさせてもらいました。

参考:「matplotlibでヒストグラムを作る

複数の表を並べてプロットする

ここでは「損益グラフ」と「リターン分布図」を並べて表示するようにしておきましょう。図を並べてプロットするには、subplot() を使います。


# 損益曲線をプロット

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" ) # 損益±0の点線
plt.axvline( records.Rate.mean(), color="orange", label="AverageReturn" ) # 期待値の線
plt.legend() # 説明枠(汎例)
plt.show()

subplot(1,2) は、1行2列で表を横に2つ並べる、という意味です。subplot(2,1)にすれば、縦に2つの表が並びますし、subplot(2,2)にすれば、2×2で表を並べることができます。

n%以上のリターンと損失の履歴を全て表示する

最後に先ほどの「10%以上の損失に終わった回数と-10%以上の利益に終わった回数」を表示するコードを記載しておきます。


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  ))

今回使用したコード

今回のフィルター編では、資金管理の方法による違いの影響を排除するため「初期資金100万円でずっと1BTCだけ売買する」という条件でテストします。そのため、前回までのコードを修正して、「固定ロット/可変ロット」を使い分けてテストできるようにしておきます。

設定項目に「TEST_MODE_LOT = ""」を作り、この値が fixed であれば、エントリーサイズは全て1BTCで計算します。分割エントリー(増し玉)の設定は無効になります。特に難しいコードではないと思うのでご自身で確認してください。


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 = "ON"         # ON / OFF / TRAILING の3つが設定可
stop_AF = 0.02             # 加速係数
stop_AF_add = 0.02         # 加速係数を増やす度合
stop_AF_max = 0.2          # 加速係数の上限

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_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"]]))
		
		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"]]))
		
		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
			
			
			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
			
			
			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)

BTCFXの破産確率とドローダウンの発生率を口座リスク別にpythonで計算しよう!

さて、前回の3記事で「破産確率の公式」については理解できたと思います!長かったので読むのが大変だったと思います。お疲れ様でした。

破産確率の公式を理解するにはどうしても数学が必要です。ここを面倒だからと飛ばそうとすると、つい何を計算しているのかわからないまま「破産確率は1%を超えてはいけない!」などの数値目標だけを鵜呑みにしがちになります。

そのため少し難しい内容でしたが、敢えて本編で丁寧に解説させていただきました。

1.高校数学で正しいFXの破産確率を理解しよう(1)
2.高校数学で正しいFXの破産確率を理解しよう(2)
3.高校数学で正しいFXの破産確率を理解しよう(3)

今回の記事では、いよいよ自作BOTの破産確率(ドローダウン発生率)を計算し、それを口座リスク別に集計して「実用的な破産確率表」を作成する方法を紹介します!

1.実用的な破産確率表の作り方

すでに説明したように、破産確率を計算するためには以下の情報が必要です。

1)損益レシオ
2)勝率
3)口座のリスク率
4)初期資金
5)撤退ライン(破産ライン)

破産確率表というと、一般的には、損益レシオと勝率で表にすることが多いです。しかしこれらの数字は、売買ロジックの検証の時点ですでに決定されています。

勝率や損益レシオの数値は、トレーダーの意思で自由に調整できる数字ではないので、表にしても具体的な意思決定にはあまり役に立ちません。「期待値は高ければ高いほどいい」という当たり前の事実を確認するだけで終わってしまいます。

売買ルールを決める ⇒ 資金管理方法を決める

△ 破産確率は後者の意思決定に関する問題

本来、期待値が0円を超えていれば、破産確率は単に資金管理上の問題にすぎないことを思い出してください。期待値がプラスの場合、ロット数を十分に下げれば破産することは絶対にありません。

そのため、ここでは「すでに損益レシオと勝率がわかっているBOT」を運用する際に「口座のリスク率を決定する」ための判断材料として、破産確率表を使うことを想定します。

具体的には、縦(行)に口座のリスク率(X%)、横(列)にドローダウン率(Y%)をとり、特定のドローダウンが生じる確率(=破産確率)を示した表を作ります。

2.損益レシオを計算するコード

損益レシオを計算するコードだけ、まだ過去記事では作ったことがなかったので、一応、作り方を紹介しておきます。やり方は、pandasの記事で紹介しています。

▽ 損益レシオの計算コード

# バックテストの集計用の関数
def backtest(flag):
	print("バックテストの結果")
	print("-----------------------------------")
	print("平均利益率         :  {}%".format(round(records[records.Profit>0].Rate.mean(),2)))
	print("平均損失率         :  {}%".format(round(records[records.Profit<0].Rate.mean(),2)))
	print("損益レシオ         :  {}".format(round( records[records.Profit>0].Rate.mean()/abs(records[records.Profit<0].Rate.mean()) ,2)))

試しに、前回の章で作ったドンチャン・チャネルブレイクアウトBOTの平均利益率と損益レシオを確認しておきましょう。

・1時間足を使用(2017/8~2018/5)
・上値ブレイクアウト判定期間 30期間
・下値ブレイクアウト判定期間 30期間
・平均ボラティリティ計算期間 30期間
・ブレイクアウトの判定基準(高値/安値)
・損切りレンジ 2ATR

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  111回
勝率               :  38.7%
平均リターン       :  1.7%
平均利益率         :  9.39%
平均損失率         :  -3.16%
損益レシオ         :  2.97
平均保有期間       :  45.0足分
損切りの回数       :  54回

なお、この破産確率の計算の元となる「損益レシオ」の計算には、分割エントリー(増し玉)を使わないように注意してください。

ポジションの積み増しを有りでシミュレーションすると、勝率や平均利益率などが歪むため、正しい破産確率を計算できなくなります。詳しくは分割エントリーの記事を参考にしてください。

3.破産確率表を作るコード

破産確率表を作るコードは、前回の記事の「破産確率を計算するコード」を応用して作成します。まずは最初にコードを確認しておきましょう。


import numpy as np
import pandas as pd
from datetime import datetime


# 設定値
winning_percentage = 0.387     # 勝率
payoff_ratio = 2.97            # 損益レシオ
funds = 1000000                # 初期資金

drawdown_rate_list = np.arange(10,100,10) # ドローダウン率 10~90%の配列
risk_rate_list = np.arange(0.5,10,0.5)    # 口座のリスク率 0.5~9.5%の配列


# 特性方程式の関数
def equation(x):

	k = payoff_ratio
	p = winning_percentage
	return p * x**(k+1) + (1-p) - x


# 特定方程式の解を探す
def solve_equation():
	
	R = 0
	while equation(R) > 0:
		R += 1e-4
	if R>=1:
		print("期待値が0を下回っています")
		R=1
	print("特性方程式の解は{}です".format(R))
	return R


# 破産確率を計算する公式
def calculate_ruin_rate( R, risk_rate, drawdown_rate ):
	
	bankrupt_line = int(round(funds * (1 - drawdown_rate / 100)))
	risk_rate = risk_rate / 100
	print("破産ライン : {}円".format(bankrupt_line))
	unit = (np.log(funds) - np.log(bankrupt_line)) / abs(np.log( 1-risk_rate ))
	unit = int(np.floor(unit))
	return R ** unit


# メイン処理
result = [] 

for risk_rate in risk_rate_list:
	temp = []
	for drawdown_rate in drawdown_rate_list:
		print("口座のリスク率:{}%".format(risk_rate))
		print("ドローダウン率:{}%".format(drawdown_rate))
		R = solve_equation()
		ruin_rate = calculate_ruin_rate(R,risk_rate,drawdown_rate)
		ruin_rate = round(ruin_rate * 100,2)
		
		print("破産確率は{}%です".format( ruin_rate ))
		temp.append(ruin_rate)
		print("------------------------------------------")
	result.append(temp)

# pandasで表に集計する
df = pd.DataFrame(result)
df.index = [str(i)+"%" for i in risk_rate_list]
df.columns = [str(i)+"%" for i in drawdown_rate_list]
print(df)

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

縦(行)に口座のリスク率を0.5%刻みで配列にし、横(列)にドローダウン率を10%刻みで配列にします。基本的な手順は、以前にバックテスト編で解説した「for文の総当たり探索の方法」と同じです。

・参考記事
for文での総当たりで最適なパラメーターを探索する方法

それでは実行結果を確認しておきましょう!

実行結果

以下のようなCSVファイルが出力されます。

検証結果

上記のチャネルブレイクアウトBOTの破産確率表(ドローダウンの発生確率表)は以下のようになりました。この表は左の「口座のリスク率」を取った場合に、右の「ドローダウン率」がおこる確率を示しています。

このBOTはそれなりにバックテスト上の成績がいいため、口座資金が0円になるという意味での「破産確率」はほとんどありません。

しかし例えば、このBOTを「口座のリスク5%」で運用した場合、13%の確率で30%以上のドローダウンに見舞われ、1.2%の確率で50%以上のドローダウンに見舞われることになります。それが許容できる確率であれば、5%での運用は十分に選択肢に入ります。

一方、50%のドローダウンがおこる確率(破産確率)を完全に0%におさえたいなら、口座のリスク率は2%で運用するのが適切ということになります。

確率の考え方の注意点

「5%以上の確率」は実現する可能性があると覚悟しておいた方がいいでしょう。絶対におこって欲しくない水準のドローダウン確率は、1~2%以下になるように、口座のリスク率を設定しましょう。

また上記の破産確率表は、あくまで過去のバックテスト上の「勝率」「損益レシオ」を将来にも再現できた場合の破産確率である点に注意してください。もし勝率や損益レシオが悪化すれば、この表よりも実際の破産確率は高くなります。

またここで使った勝率や損益レシオはあくまで「平均値」であることも忘れないでください。月単位などの局所的なドローダウン発生率にはもっとバラつきがあるため、余裕を持ってリスク率を決めてください。

途中時点での再計算

また「破産確率は資金量の関数である」ことを忘れないようにしましょう。資金の量が変われば破産確率は常に変動します。

例えば、口座リスク5%で運用した場合、上記の表では100万円の時点で資金が50万円にまで減る確率(50%のドローダウンがおこるリスク)は1.3%しかありません。しかし資金が70万円まで目減りした時点での「資金が50万円にまで減る確率」は13%と飛躍的に高くなります。

▽ 初期資金からのドローダウン確率を、資金70万円(-30%)の時点で再計算した場合

そのため、「初期資金を絶対に50%以下に減らしたくない」のであれば、資金が70万円まで減った時点で「口座のリスク率」を2.5%以下に再調整しなければなりません。

繰り返しますが、破産確率(ドローダウン確率)は資金量の関数です。そのため、常に破産確率を一定水準以下におさえるためには、資金が減るたびに破産確率を再計算して、それに合わせて口座のリスク率を引き下げる必要があります。

興味がある方は、現在の証拠金残高と許容できるドローダウン率から、注文前に自動的に「口座リスク率」を再計算して調整するようなBOTを作ってもいいでしょう。

練習問題

本文の途中に出てきた、「初期資金からのドローダウンが生じる確率を途中資金の時点で再計算した場合」の破産確率表のコードを作ってみましょう! 以下にそのまま正解例のコードを記載しておきます。


import numpy as np
import pandas as pd
from datetime import datetime


# 設定値
winning_percentage = 0.387     # 勝率
payoff_ratio = 2.97            # 損益レシオ
funds = 1000000                # 初期資金
funds2 = 700000                # 途中経過時点での資金

drawdown_rate_list = np.arange(10,100,10) # ドローダウン率 10~90%
risk_rate_list = np.arange(0.5,10,0.5)    # 口座のリスク率 0.5~9.5%


# 特性方程式の関数
def equation(x):

	k = payoff_ratio
	p = winning_percentage
	return p * x**(k+1) + (1-p) - x


# 特定方程式の解を探す
def solve_equation():
	
	R = 0
	while equation(R) > 0:
		R += 1e-4
	if R>=1:
		R=1
	return R


# 破産確率を計算する公式
def calculate_ruin_rate( R, risk_rate, bankrupt_line ):
	
	risk_rate = risk_rate / 100
	unit = (np.log(funds2) - np.log(bankrupt_line)) / abs(np.log( 1-risk_rate ))
	unit = int(np.floor(unit))
	return R ** unit


# メイン処理

result = []

bankrupt_line_list = []
for drawdown_rate in drawdown_rate_list:
	bankrupt_line_list.append(int(round(funds * (1 - drawdown_rate / 100))))

for risk_rate in risk_rate_list:
	temp = []
	for bankrupt_line in bankrupt_line_list:
		R = solve_equation()
		ruin_rate = calculate_ruin_rate(R,risk_rate,bankrupt_line)
		ruin_rate = round(ruin_rate * 100,2)
		if ruin_rate > 100:
			ruin_rate = 100.0
		temp.append(ruin_rate)
	result.append(temp)

df = pd.DataFrame(result)
df.index = [str(i)+"%" for i in risk_rate_list]
df.columns = [str(i)+"%" for i in drawdown_rate_list]
print("初期資金{}円からのドローダウン確率を、{}円の時点で再計算した表\n".format(funds,funds2))
print(df)

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

Windowsのタスクスケジューラを使ってpythonスクリプトを定期的に自動実行しよう!

前回の記事では、CryptowatchのAPIでBTCFXの価格情報を収集し、差分だけをJSONファイルに上書きして保存するpythonスクリプトの作り方を紹介しました。

Cryptowatchで期間外の価格データを自前で保存する方法

今回は、Windowsのタスクスケジューラを使って、この価格APIの収集スクリプトを毎日自動で実行する方法を紹介します! 一般的なサーバーでいうCRONのようなことをWindowsでも実行してみましょう!

タスク設定の手順

まずはWindowsのスタートメニューから「タスクスケジューラ」を探します。

タスクスケジューラを選択すると、以下のようなアプリが起動します。

「操作」から「基本タスクの作成」を選択します。

わかりやすい名前をつけましょう。ここでは「価格データ収集スクリプト(1分足)」とします。名前をつけたら「次へ」をクリックします。

スクリプトの実行頻度を選択します。前の記事でも説明しましたが、1分足データの収集であれば、毎日1回実行した方がいいでしょう。1時間足のデータ収集であれば、1週間~1カ月に1回で十分です。

ここでは1分足を保存したいので「毎日」にします。

毎日何時にタスクを実行するかを設定します。
ここでは朝9時にしておきます。

タスクを実行する方法を聞かれます。
ここではpythonスクリプトを実行したいので「プログラムの開始」を選択します。

次に具体的なプログラムファイルを指定します。
ここの設定方法だけ少し難しいので注意してください。

「プログラム/スクリプト(P)」の項目には、実行したいpythonスクリプトではなく、「python.exe」を設定します。pythonを実行するための大元となるファイルです。

「参照」からpython.exeを探して指定してください。Anacondaを使ってインストールした方であれば、python.exeは Anacondaのディレクトリ直下にあります。

次に「引数の追加(オプション)」のところに、実行したいpythonファイル名を指定して、「開始(オプション)」のところにそのファイルの存在するパスを指定します。

例えば、以下のような具合です。

・ファイル名 … test.py
・ファイルのパス … C:\Pydoc

最後に以下の画面で確認して「完了」をクリックします。
これで設定は終わりです!

なおファイルは何個でも登録できますので、1分足・1時間足あたりをセットで保存しておくと便利です。

これで価格データを収集するスクリプトを自動的に、定期実行することができるようになりました! 同じ方法をWindowsVPSで設定すれば、自宅のパソコンをオフにしていても自動的に価格データを収集できます!

FXトレードの破産確率を文系でもわかる高校数学で理解してみよう!(3)

前回の記事では、以下のような破産確率の公式を紹介しました。また実際にpythonを使ってこの方程式を解いて、破産確率を計算する方法を解説しました。

前回学習した破産確率の公式

・勝率をP、損益レシオをkとするとき、

$$\begin{array}{l}
Px^{k+1} +( 1-P) -x=0\ \ \ の方程式の解で\ 、\\
\ 0< x< 1\ の範囲の\ x\ を\ x=R\ とする。 \end{array}$$ $${\Large 破産確率\ =\ R^{\frac{資金額}{1回あたりの損失} \ }}$$

上記の方程式の解Rは、トレードの期待値が0円を上回るとき、必ず0<R<1の範囲に1つ存在し、かつ勝率や損益レシオが高ければ高いほど小さい数字になることも学習しました。詳しくは、前回の2記事を確認してください。

FXトレードの破産確率を高校数学で理解しよう!(1)
FXトレードの破産確率を高校数学で理解しよう!(2)

さて今回はこの公式を前提として、最終的に実践のトレードで使える破産確率の公式を作り、実際にpythonでBOTの破産確率を計算してみます!

1回の損失額が定まらない場合

前回の最後でも説明しましたが、上記の公式のままでは残念ながらまだ実践のトレードでは使えません。なぜなら、実践では1回のトレードの損失額を一定額に固定できないからです。

例えば、第7回の「資金管理編」では、毎回、口座残高のX%だけリスクを取り、そのリスクの範囲内でポジションサイズを計算する方法を紹介しました。この方法の場合、毎回の賭け金(トレードでリスクに晒す金額)は、そのときの口座残高に応じて毎回変動します。

「毎回1万円を賭ける」と決まっているゲームであれば、上記の公式をそのまま使うことができます。ですが、トレードのように毎回の損失額を固定できないゲームでは、上記の公式を使って破産確率を計算することはできません。

1.「資金量」の意味

では、もう少し踏み込んで考えてみましょう。前々回の記事で紹介した一番簡単な基本公式を思い出してください。以下のような公式でしたね。

基本の公式

・勝ったら1円の利益、負けたら1円の損失、というトレードで、勝率と資金量の2つの値から破産確率を計算する公式

$${\Large 破産確率\ =\ \left( \ \frac{負ける確率}{勝つ確率} \ \right)^{資金の量}}$$

ここでいう「資金量」とは何でしょうか?

「勝ったら1円貰える」「負けたら1円失う」という前提のゲームですから、資金量とは、言い換えれば「何回まで負けることができるか?」という最大負け回数に置き換えて考えることができます。必ずしも単位は円でなくてもいいのです。

この回数のことをトレードの世界では「ユニット数」と表現することがあります。要するに、資金量から考えて「何回トレードできるのか?」という回数がユニット数です。

$${\Large 破産確率\ =\ \left( \ \frac{負ける確率}{勝つ確率} \ \right)^{ユニット数}}$$

また前回の記事で解説しましたが、破産確率の公式はすべてこの基本公式から派生しています。損益レシオが異なる場合や、1回の損失額が大きい場合も、すべて「1円ゲームに換算して考える」と言ったのを思い出してください。

つまり最初の公式も全く同じように資金量のところを「ユニット数」に置き換えることができます。

2.トレードできる最大回数がわかる場合の公式

・勝率をP、損益レシオをkとし、
・資金からトレードできる最大回数を「ユニット数」とするとき、

$$\begin{array}{l}
Px^{k+1} +( 1-P) -x=0\ \ \ の方程式の解で\ 、\\
\ 0< x< 1\ の範囲の\ x\ を\ x=R\ とする。 \end{array}$$ $${\Large 破産確率\ =\ R^{ユニット数}}$$

例えば、初期資金が100万円で、「毎回のトレードでは1万円のリスクと取る」と決めて、そこから逆算して損切り幅やポジションサイズを設計している方であれば、ユニット数は100とすれば、上記の公式で破産確率を計算できることになります。

(例)BOTの成績と破産確率

・勝率 31.5%
・損益レシオ 2.99
・初期資金 100万円
・1回のトレードリスク 5万円
・ユニット数 20回

破産確率 = 3.76%

※ 計算方法は、前回の記事 を参照

要するに「資金からトレードできる最大回数」さえわかれば、そこから上記の公式で破産確率を計算できるのです。

3.口座の一定率のリスクを取る場合

ここで私たちが知りたい問題は、「毎回のトレードで口座の3%のリスクを取った場合の最大トレード回数(ユニット数)はいくつか?」という数学の問題だということに気付くことができました。

ではこのユニット数はいくつでしょうか?

鋭い方はすぐにわかるかもしれませんが、この問題は「いくらを撤退のラインとするか?」を決めないと決まりません。なぜなら「口座のX%のリスクでトレードする」というルールだけだと、口座資金がどれだけ減っても永久にトレードを続けることができるからです。

(例)口座の50%のリスクでトレード

100万円 ⇒ 50万円 ⇒ 25万円 ⇒ 12.5万円 ⇒ 6.25万円 ⇒ 3.125万円 ⇒ 1.5625万円 ⇒ 7812円 ⇒ 3906円・・・

そこで、まず先に「n円以下を破産とみなす」という実質的な撤退ラインを決めなければなりません。今回は以下のような例題を考えましょう。

例題

以下の場合のユニット数はいくつか?

1)初期資金100万円
2)資金10万円以下になったら撤退(破産とする)
3)口座の3%を賭けてトレードする

この問題は、実は対数(log)という考え方を使うと簡単に解くことができます。対数がわかる方は読み飛ばして構いませんが、対数を忘れている方もいるかもしれないので、対数について解説します。

4.対数(log)って何だっけ?

対数をズバリ一言で説明すると、「掛け算を足し算に変換するための数学ツール」です。

私と同じように高校数学が苦手だった方でも、FXを始めてから「対数チャート」というのを見たことがあると思います。例えば、以下はビットコイン現物価格の対数チャートです。

わかりやすい例なので、BTC現物価格の2013年~2018年3月の価格チャートと対数チャートを見てみましょう!

▽ 価格チャート

▽ 対数チャート

出典「CoinMarketCap

上記の2つのチャートは全く同じ期間のものですが、全く別物のように見えます。その理由は以下です。

BTC価格が10万円から20万円になるのと100万円から200万円になるのとでは、同じ2倍でも価格チャート上の目盛りは大きく異なります。前者は10万円しか上がっていない一方、後者は100万円上がっています。そのため、価格チャートでは直近に極端に大きな値動きがあったように見えてしまいます。

しかし対数チャートでは、「2倍は同じ2倍」として扱います。つまり10万円が20万円になった場合と、100万円が200万円になった場合とを、y軸上で同じ目盛り幅(間隔)で表示します。そのため、「何倍になったか?」という視点で長い期間のチャートを見るときには、対数チャートを見たほうがわかりやすい、と言われます。

ここまでが一般的な説明です。

1)数式で理解してみよう!

では、これを数学的に理解してみましょう!
上記の説明をそのまま数式にすると以下のようになります。

▽ 価格チャートの場合

$$\begin{array}{l}
値動きA\ =\ 20万円-10万円\ =\ 10万円\\
値動きB\ =\ 200万円-\ 100万円\ =\ 100万円\\
\\
値動きB\ >\ 値動きA
\end{array}$$

▽ 対数チャートの場合

$$\begin{array}{l}
対数価格の差A\ =\ log20万-log10万\ =\ log( \ 20万\ /\ 10万) \ =\ log2\\
対数価格の差B\ =\ log200万-\ log100万\ =\ log( \ 200万/100万) \ =\ log2\\
\\
対数価格の差B\ =\ 対数価格の差A
\end{array}$$

後者の場合は、どちらも対数差分が log2 になるのがわかります。ちなみに、このことは高校で勉強する対数の「基本公式」から導き出せます。詳しくは以下のリンクを参考にしてください。

▽ 対数の基本公式

$$\begin{array}{l}
log_{a} M\ +\ log_{a} N\ =\ log_{a} MN\\
log_{a} M\ -\ log_{a} N\ =\ log_{a}\frac{M}{N} \
\end{array}$$

参考:「対数の基本的な性質と証明

2)対数は掛け算を足し算に、割り算を引き算にする

上記の公式を見るとわかりますが、これは逆にいうと「対数を取れば掛け算を足し算に(割り算を引き算に)できる」ということだとわかります。例えば、「価格が2倍になった」という数式の対数を取ってみましょう。

$$\begin{array}{l}
100000\ \times \ 2\ =\ 200000\\
\\
両辺の対数を取ると\\
\\
log( 100000\ \times 2) \ =\ log200000\ \\
\\
左辺に公式を使うと\\
\\
log100000\ +\ log2\ =\ log200000=\ 12.2060726…
\end{array}$$

これが、「掛け算は対数を取ると足し算にできる」という意味です。なお、上記の式が本当にそうなるのか気になる方は、pythonで以下を実行してみてください。

▽ pythonで計算して確認してみよう!

import numpy as np

print(np.log(2) + np.log(100000))
print(np.log(2 * 100000))
print(np.log(200000))

3)口座の一定率をリスクに晒す場合のトレード回数

本題に戻りましょう!

この考え方を利用すると「1回のトレードで口座の3%を失う」という状態を引き算(または足し算)で表すことができます。
以下のような具合です。

$$\begin{array}{l}
口座残高\ =\ 100万\ \times \ 0.97\ \times \ 0.97\ \times \ 0.97\ \times \ 0.97\ …..\\
口座残高の対数\ =\ log100万\ +\ log0.97\ +\ log0.97\ +\ log0.97…..
\end{array}$$

これが成り立つということが、あまりピンと来ない方は、ぜひpythonでlog0.97を計算してみてください。log0.97 = -0.0304592…となり、マイナスの数字であることがわかります。

ということは、口座の残高の対数を log0.97 で割れば、定率3%で何回トレードできるか数えることができそうですね! 実際にやってみましょう!

定率の場合の公式

$${\large ユニット数U\ =\frac{log資金\ -\ log撤退ライン}{| log( 1-リスク率)| }}$$

$${\large 破産確率\ =\ R^{U}}$$

できました!
実際の数字を入れてみた方がイメージが湧きやすいので、本当にこれでユニット数が計算できるのかどうか、やってみましょう!

4)定率のユニット数を計算するコード


import numpy as np

funds = 1000000           # 初期資金
risk_rate = 0.1           # 1回のトレードで取るリスク率
bankrupt_line = 200000    # 撤退ライン(破産)

unit = (np.log(funds) - np.log(bankrupt_line)) / abs(np.log( 1-risk_rate ))
unit = int(np.floor(unit)) # 切り捨て

print("最大{}回までトレードできます".format(unit))


先ほども説明したように、口座の一定率のリスクを取る場合は撤退ラインを決めておかないと、永遠にトレードができてしまいます。必ず撤退ラインを入力するようにしてください。

では、試しに以下のようなテストしやすい例を考えてみましょう。

(テスト条件)

・初期資金 100万円
・撤退ライン 20万円
・口座のリスク 10%

100万円から始めて、毎回のトレードで口座の10%までリスクを取った場合です。これは実際に電卓で計算してみると以下のようになります。

100万円 ⇒ 90万円 ⇒ 81万円 ⇒ 72.9万円 ⇒ 65.6万円 ⇒ 59万円 ⇒ 53万円 ⇒ 47.8万円 ⇒ 43万円 ⇒ 38.7万 ⇒ 34.8万円 ⇒ 31.3万円 ⇒ 28.2万円 ⇒ 25.4万円 ⇒ 22.8万円 ⇒ 20.5万円 ⇒ 18.5万円

つまり撤退ラインに達する(破産する)までに最大15回トレードできることがわかります。ユニット数は15です。

では上記のコードを実行してみましょう。

ちゃんと最大トレード回数を計算できています。
これで「口座のX%のリスクを取ってトレードする場合」でもユニット数を計算して、破産確率を算出できるようになりました。

4.破産確率を計算するpythonコード

では、前回の記事で作成した「特性方程式を解いて破産確率を計算するpythonコード」に、今回のユニット数を計算する箇所を付け足しましょう!

これで破産確率の計算コードは完成です!


import numpy as np

# 設定値
winning_percentage = 0.375     # 勝率
payoff_ratio = 2.09            # 損益レシオ
funds = 1000000                # 初期資金
risk_rate = 0.05               # 1回のトレードで取るリスク率
bankrupt_line = 200000         # 撤退ライン(破産)


# 特性方程式の関数
def equation(x):

	k = payoff_ratio
	p = winning_percentage
	return p * x**(k+1) + (1-p) - x


# 特定方程式の解を探す
def solve_equation():
	
	R = 0
	while equation(R) > 0:
		R += 1e-4
	if R>=1:
		print("期待値が0を下回っています")
		R=1
	print("特性方程式の解は{}です".format(R))
	return R


# 破産確率を計算する公式
def calculate_ruin_rate( R ):
	
	unit = (np.log(funds) - np.log(bankrupt_line)) / abs(np.log( 1-risk_rate ))
	unit = int(np.floor(unit))
	return R ** unit


# メイン処理
R = solve_equation()
ruin_rate = calculate_ruin_rate(R)
print("破産確率は{}%です".format( round(ruin_rate * 100,2) ))


5.練習~破産確率からリスク率を考えよう

では試しに以下のような、チャネルブレイクアウトBOTを考えてみましょう。

BOTの成績と資金条件

・勝率 37.5%
・平均利益率 8.21%
・平均損失率 3.92%
・損益レシオ 2.09
・初期資金 100万円
・撤退ライン 20万円

一応、期待値はプラスなので運用すること自体は可能なBOTです。では1トレードで取る口座のリスクは何%で運用するのが適切でしょうか?

試しに口座のリスク率を1%から順番に計算してみると以下のようになります。

口座のリスク率 破産確率
1% 0.0%
2% 0.0%
3% 0.04%
4% 0.31%
5% 1.01%
6% 2.11%
7% 3.82%
8% 5.96%
9% 8.03%
10% 10.8%

 
上記のBOTはそれほど成績が優秀なわけではありませんが、それでも教科書通り、口座のリスクを1~3%の範囲におさえて運用していれば、破産リスクはほとんどないことがわかります。

逆に口座リスクが5%を超えたあたりから、急激に破産リスクが上がっています。実際に上記の破産確率の公式で、0.1%刻みで破産確率を計算した結果をプロットすると以下のようになりました。

以前にこちらの記事でも解説したように、口座の5%以上のリスクを取ると、うまくいけば高い運用パフォーマンスを手にすることができます。しかし逆に失敗したときの破産確率も、急速に「現実におこりうる数字」に近づくのがわかります。

どの程度までリスクを許容するかは、運用資金の大きさにもよりますが、教科書的には1~2%未満におさえるのが良いとされています。

6.許容ドローダウンから考える

「破産確率」という名前こそ付いていますが、実際には上記の計算式は「許容ドローダウン」を考えるのに活用した方が実用的です。

例えば、さきほどは撤退ライン(破産ライン)を20万円としましたが、数百万円以上の資金を運用している人からすると、実際には50%すら失いたくない人が大半でしょう。つまり撤退ライン(破産ライン)を50万円で計算する必要がありますが、この場合の計算結果はもっとシビアになります。

許容ドローダウンが50%の場合

・勝率 37.5%
・平均利益率 8.21%
・平均損失率 3.92%
・損益レシオ 2.09
・初期資金 100万円
・破産ライン 50万円

口座のリスク率 破産確率
1% 0.0%
2% 0.64%
3% 3.82%
4% 9.31%
5% 14.53%
6% 19.55%
7% 26.3%
8% 30.51%
9% 35.39%
10% 41.05%

この成績のBOTで1回のトレードで口座の5%のリスクを取った場合、約1/10の確率で50%のドローダウンに見舞われることになります。口座の10%のリスクを取ると、なんと40%の確率で半分の資金を失うことになるのです。これを許容できる人は少ないでしょう。

一方、こちらの場合でも口座リスク2%までであれば、50%のドローダウンがおこる確率はほとんど無いことがわかります。多くのトレードの教科書で「2%ルール」が推奨されるのは、この辺りの特徴にも理由があるのかもしれません。

なお、ここまでの数式を追っていただいた方ならわかると思いますが、上記の破産確率の公式は、初期資金200万円、撤退ライン100万円で試しても、初期資金2000万円、撤退ライン1000万円で試しても全く同じです。要するに、ドローダウン率50%の確率表です。

次回

次回は、前章までで作成した「すでに損益レシオと勝率がわかっているBOT」について、許容ドローダウン率と口座のリスク率をそれぞれ縦軸と横軸に取った破産確率表をCSVで出力する方法を紹介して、最後の記事とする予定です。個人的にはそれが最も実用的な破産確率表の使い方ではないかと思います。

FXトレードの破産確率を文系でもわかる高校数学で理解してみよう!(2)

前回の記事の続きです。

今回はより実践的なFXトレードでの破産確率の公式を紹介し、さらにpythonを使って実際に破産確率を計算します。 なお今回の内容は、前回の「基本公式」が前提となっているため、なぜ以下の式が導き出せるのかわからない方は、先に前回の記事を読むことを推奨します。

基本の公式

・勝ったら1円の利益、負けたら1円の損失、というトレードで、勝率と資金量の2つの値から破産確率を計算する公式

$${\Large 破産確率\ =\ \left( \ \frac{負ける確率}{勝つ確率} \ \right)^{資金の量}}$$

 
前回の記事:
FXの破産確率を高校数学で理解しよう!(1)

では始めましょう!

1回の賭け金を増やした場合の公式

前回の記事では、「勝ったら1円の利益」「負けたら1円の損失」というトレードを無限に繰り返した場合に、勝率(P)と資金(N)がどのように破産確率に影響を与えるか、を確認しました。

しかし実際のトレードでは、当然、賭け金は1円ではありません。そこで、まずは以下のような条件での破産確率の公式を考えてみましょう。

前提条件

1)勝ったら1000円の利益、負けたら1000円の損失というトレード
2)市場の資金は無限と仮定する
3)勝率は50%を超えていると仮定する

実はこの場合の破産確率の公式は「めちゃめちゃ簡単」です。
以下のように変形するだけでいいのです。

$${\Large 破産確率\ =\ \left(\frac{負ける確率}{勝つ確率} \ \right)^{\frac{資金の額\ }{1000円\ }}}$$

要するに、右上の指数の「資金量」を「1トレードあたりの損失額」で割ればOKです。なぜそうなるかは後述します。

公式からわかること

上記の公式から、同じ勝率でも1トレードあたりの損失額(=賭け金)が大きければ大きいほど、破産確率は大きくなることがわかります。

勝率が50%を超えるという前提条件があるため、()の中身は必ず1未満の数字になります。そのため、右上の指数は大きければ大きいほど、破産確率は小さくなります。例えば、資金量が多ければ多いほど、同じ勝率でも破産確率は低くなります。

逆に右上の指数が小さくなると、破産確率は大きくなってしまいます。そのため、1回のトレードの賭け金の額を増やすと、破産確率は大きくなります。

賭け金の定義

ちなみにトレードにおける「賭け金」の定義をここで整理しておいてください。賭け金はエントリー価格やサイズのことではありません。最終的に1回のトレードで失う証拠金(例えば、損切り幅)のことです。「ポジションを持つために投入した金額」ではないので注意してください。

上記の式になる理由

初期資金10万円で「勝ったら1000円の利益」「負けたら1000円の損失」のトレードをすることは、初期資金100円で「勝ったら1円の利益」「負けたら1円の損失」のトレードの破産確率を計算するのと全く同じです。

つまり賭け金の額が大きい場合は、その分だけ口座資金を小さくして考えれば、前回に学習したのと同じ「勝ったら+1円」「負けたら-1円」のゲームに変換することができます。この考え方は、これから先の破産確率の公式でも全て同じです。

どんな条件でも、基本的には「資金額」の方をうまく調整して、すべて「1円ゲーム」に換算して考えます。例えば、以下のような条件を考えてみてください。

・初期資金100万円
・1回のトレードでの損失 1万円
・破産ラインを0円ではなく20万円と定義する

この場合は、以下のような式を計算します。

$${\Large 破産確率\ =\ \left(\frac{負ける確率}{勝つ確率} \ \right)^{\frac{資金額\ -\ 撤退ライン\ }{1回あたりの損失\ }}}$$

つまり以下です。

$${\Large 破産確率\ =\ \left(\frac{負ける確率}{勝つ確率} \ \right)^{\frac{100万円\ -\ 20万円\ }{1万円\ }}}$$

要するに、破産のラインを20万円と考えたい(資金が20万円に減る前には撤退したい)という場合は、最初の初期資金を 100万円 – 20万円 = 80万円 で考えればいいだけです。

2.利益と損失の割合が違う場合の公式

もちろん実際のトレードでは、1回の利益と損失の額は同じではありません。そこで、以下のような利益と損失の割合が異なる場合を考えてみましょう。

1)平均利益2万円 / 平均損失1万円
2)平均利益率 3.8% / 平均損失率 1.9%

これはどちらの数字を使っても構いません。さきほども説明したように、どちらにしても「1円ゲーム」に換算して考えるので同じです。つまり、以下のような条件で破産確率を考えます。

1)前提条件

1)損益レシオK = 平均利益 ÷ 平均損失
2)勝ったら K円の利益、負けたら1円の損失というトレード
3)市場の資金は無限と仮定する
4)期待値が0円を超えている

最初の2つのどちらを使っても、損益レシオKは同じになります。
なので、どちらを使っても構いません。

2)「平均利益」と「損益レシオ」に注意

ここでの平均利益を平均リターンと混同しないよう注意してください。一般的にいう平均リターン(期待値)は、すべての平均利益と平均損失を通算した平均値です。一方、ここでいう「平均利益」は、純粋に利益が出た場合のみの平均値です。

また損益レシオとプロフィットファクター(PF)を混同しないでください。PFは「総利益 ÷ 総損失」ですが、この数字にはすでに勝率が含まれています。損益レシオは、勝率とは関係のない数字です。

3)具体的な公式

「1円ゲームに換算するのは同じ」といいましたが、公式はかなり違った姿になります。結論からいうと、この場合の公式は以下になります。

・勝率 P
・損益レシオ k のとき

 
$$\begin{array}{l}
Px^{k+1} +( 1-P) -x=0\ \ \ を満たすような\\
\ 0< x< 1\ の範囲の\ x\ を\ x=R\ とすると \end{array}$$ $${\Large 破産確率\ =\ R^{\frac{資金額}{1回あたりの損失} \ }}$$  

この記事から読み始めてくださった方は、「いきなり難しそうな方程式が….」と思ったかもしれませんね(笑)

しかし前回の記事を読んでくださった方であれば、何となく見覚えがあるのではないかと思います。この式は、実は前回の記事で出てきた「特性方程式」と(k+1)の部分以外は全く同じです。

注意

なお、上記の公式の「1回あたりの損失額」は、実際のトレードではなかなか定義できません。具体的な損失額はそのときのBTC価格やロット数によって変動するからです。ですが、ここは後ほど説明するので今は気にしないでください。

4)上記の式になる理由

では本当に上記のような公式になるのか確認していきましょう。なお、途中までは前回の記事と全く同じ流れなので、重複する箇所は説明を端折ります。わからない方は、前回の記事を読んでください。

まず、ある資金額n円で破産する確率をQ(n)とします。そして現在の資金額を100円とします。このとき、現在の破産確率 Q(100)は以下のような確率の合計で表すことができます。

1)資金100円で将来的に破産する確率 Q(100)
2)次に勝って資金がn+k円 になり、将来的に破産する確率 P × Q(n+k)
3)次に負けて資金がn-1円 になり、将来的に破産する確率 (1-P) × Q(n-1)

$${\small Q( 100) \ =\ P\times Q( 100-k) \ +\ ( 1-P) \times Q( 100-1) \ }$$

これを前回と同様に一般化して漸化式にします。

(漸化式)
$${\small Q( n) \ =\ P\cdotp Q( n+k) \ +\ ( 1-P) \cdotp Q( n-1) \ }$$

さらに特性方程式を作ります。

(特性方程式)
$${\small \begin{array}{l}
x\ =\ Px^{k+1} +( 1-P)\\
\\
より、Px^{k+1} +( 1-P) -x\ =0\
\end{array}}$$

前回はこの方程式を解くと、たまたま綺麗に解が X=1,(1-P)/P になりました。だから (勝つ確率 ÷ 負ける確率)をベースにした公式になったわけですね。

・前回は特性方程式の解が P(1-P) だったので
$${\large 破産確率\ =\ \left(\frac{1-P}{P}\right)^{資金量\ }}$$

なぜ特性方程式の解からこの式が導き出せるのかは(しつこいですが)前回の記事で長々と説明しているので、そちらを参考にしてください><

全く同じ理由で、この方程式を解いてその解をRとしたときも、「Rの資金乗」が、そのまま破産確率になります。

・上記の特性方程式の解がRのとき、

$${\Large 破産確率\ =\ R^{資金量\ }}$$

4)この方程式の解き方

ただし前回の式は、x の2次方程式だったので、解は2つしかありませんでした。今回はk+1次の方程式なので解は2つとは限りません。そこで、以下のような2つの前提条件を思い出してください。

1)破産確率は、確率の関数である
2)「期待値は0を超えている」という前提がある

Q(n)は、破産する確率を計算する関数です。

確率は0~1までの範囲に必ず収まらなければなりません。そのため、Rは必ず 0≦R≦1の範囲でなければなりません。Rがマイナスの値や1を超える値を取ると、上記の公式では確率が0%を下回ったり、100%を超えてしまうからです。

また「期待値が0を超えている」という条件を満たす場合、解のRは必ず0~1の範囲に1つ存在します。これは色々説明するよりも、実際に上記の方程式をpythonで描画してみた方がイメージが湧きやすいでしょう。

このブログはpythonの実践ブログなので、実際に上記の方程式をpythonで解いてみましょう!

3.Pythonコードで方程式を解こう!

今回は以下のようなBOTの破産確率を考えます。
「1回あたりの損失額」以外は、すべて実際のドンチャン・チャネルブレイクアウトBOTの成績をそのまま使います。

成績

・勝率 41.5%
・平均利益率 8.52%
・平均損失率 2.85%
・損益レシオ 2.99

仮定条件

・初期資金 100万円
・1回の負けトレードの損失額1万円

まずは先ほどの漸化式から導いた特性方程式をpythonで関数にしてみましょう。


#---設定値---

winning_percentage = 0.415
payoff_ratio = 2.99

# 特定方程式の関数
def equation(x):

	p = winning_percentage
	k = payoff_ratio
	return p * x**(k+1) + (1-p) - x

これは先ほどの特性方程式の左辺をそのまま書いただけです。この関数に適当なxの値を渡せば、以下の式の計算結果を返します。

$$Px^{k+1} +( 1-P) -x$$

特性方程式を解くためには、この関数から「0」が返ってくるような0≦x≦1の範囲内のxを探し出せばいいわけです。しかしその前に、本当に0~1の間に解が1つだけ存在するのか、この関数をプロットして確認してみましょう。

2)グラフで描画する

pythonでは、matplotlibというライブラリを使って関数を描画することができます。以下のようなコードを書いて、上記の特性方程式の関数を描画してみましょう。


import matplotlib.pyplot as plt
import numpy as np

# 設定値
winning_percentage = 0.415
payoff_ratio = 2.99


# 特性方程式の関数
def equation(x):
	k = payoff_ratio
	p = winning_percentage
	return p * x**(k+1) + (1-p) - x

# グラフで描画する
x = np.linspace(0, 1.2, 100) 
plt.plot( x,equation(x) )
plt.axhline( y=0,linestyle="dashed" )
plt.axvline( x=1,linestyle="dashed" )
plt.show()


上記のコードでやっていることは、以下です。

1)np.linespace()で、0~1.2の範囲で等間隔な100個の数字を作る
2)それをxとして片っ端から特性方程式の関数に与える
3)返ってきた値をyとして、plt.plot(x,y)をグラフに描画する

つまり0~1.2の範囲の100コの数字をxとし、それに対応する100コの計算結果をyとし、xとyを描画したらグラフの形状がわかるわけです。さらに見やすいように、axvhline()でy=1の水平線、axvline()でx=1の垂直線をセットで描画しています。

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

実行結果

勝率41.5%、損益レシオ2.99 の場合の特性方程式は、上記のような形状になることがわかりました。

この方程式を解くということは、y=0と交わるxを探すということです。ちゃんと0≦x≦1の範囲に、X=1と0<R<1を満たす2つの解が存在していることがわかります。

期待値が0円を下回る場合

では、次に期待値が0円を下回る場合を考えてみましょう。
ちなみに期待値が0を下回るかどうかは、以下のような式を満たすかどうかで確認できます。

 
$${\large \frac{平均損失率}{平均利益率\ +\ 平均損失率} \ > \ 勝率}$$

 

これは以下の期待値の計算式を展開すればわかります。気になる方は自身で確認してみてください。

$${\small 1\times 平均利益率\times P\ -\ 1\times 平均損失率\times ( 1-P) < 0}$$

では、以下のような期待値が0を下回るケースを考えてみましょう。

・勝率 31.0%
・平均利益率 8.52%
・平均損失率 3.85%
・損益レシオ 2.21

このケースは、計算してみるとわかりますが、ぎりぎり期待値が0を下回ります。これで特性方程式の関数のグラフの形状を確認してみましょう。

解Rがかなり1に接近して0<R<1の範囲におさまるか怪しくなってきました。実際にこの方程式を解いてみると、解Rは、R=1.0044999 となり、1以下におさまっていません(解き方は後述します)。

では、もっと成績を悪化させてみるとどうでしょうか?

・勝率 28.7%
・平均利益率 8.52%
・平均損失率 4.86%
・損益レシオ 1.75

これは明らかに期待値がマイナスですが、グラフを確認してみましょう。

このように解Rは1とは反対側にいってしまい、0<R<1を満たす解Rは存在しないことがわかります。ちなみにこのときの解R=1.2851999です。これで、さきほどの確率の公式に「期待値が0を上回るとき」という条件が付いている理由が理解できたと思います。

特定方程式の意味

これで特性方程式の解が何を意味する数字なのか、少しイメージできたのではないでしょうか?

要するに特定方程式の解Rは、損益レシオと勝率という2つの成績指標を加味して0~1の範囲の数値に換算したものなのです(小さければ小さいほど良い数値で、1を超えると期待値が0円を下回ります)。そこに資金量を乗じることで破産確率が計算できます。

3)方程式を解く

では次に実際に、この特性方程式を解いて破産確率を計算してみましょう!
さきほどのコードに2つほど関数を付け加えます。


import matplotlib.pyplot as plt
import numpy as np

# 設定値
winning_percentage = 0.415
payoff_ratio = 2.99
loss_per_trade = 10000 # トレード1回の損失(仮定)
funds = 1000000


# 特性方程式の関数
def equation(x):

	k = payoff_ratio
	p = winning_percentage
	return p * x**(k+1) + (1-p) - x


# 特定方程式の解を探す
def solve_equation():
	
	R = 0
	while equation(R) > 0:
		R += 1e-4
	if R>=1:
		print("期待値が0を下回っています")
		R=1
	print("特性方程式の解は{}です".format(R))
	return R


# 破産確率を計算する公式
def calculate_ruin_rate( R ):
	
	e = funds / loss_per_trade
	return R ** e

pythonのようなプログラムが方程式を解く場合、人間のように因数分解や微分をしてスマートに解くよりも、ゴリゴリと片っ端から数字を代入しまくって近い数字を探すほうが得意です。

特に今回のように、解が0<R<1の範囲とわかっているような場合は尚更です。そのため上記のプログラムでは、0から順番に0.0001単位で数字を代入しまくって、最もy=0に近い数を返すようにしています。

このときのxは、ピッタリ厳密なy=0ではありませんが、どのみち、実際にトレードで何らかの指標として使う際には四捨五入しますので、近似値で問題ありません。無理数(ルートなど)で答えを返されても使えないからです。

▽ 解を探す箇所

R = 0
while equation(R) > 0:
	R += 1e-4   #「1e-4」は「0.0001」と同じ意味

さきほどの説明の通り、0≦x≦1 の範囲に必ず解は存在することがわかっています。そのため、0からスタートして +0.0001 ずつ加算していき、計算結(y)が0を下回った時点でループを止めます。

もし期待値が0を下回っている場合は、解Rに辿り着くよりも前に x=1 にぶつかります。そのときは「期待値が0以下です」と表示して、R=1 を返します。

※ R=1 のときの破産確率は必ず100%です。1は何乗してもずっと1だからです。

実行結果

では以下のようなメイン処理を書いて実行してみましょう!
今度は確認のために、解Rもグラフ上にプロットしてみます。


# メイン処理
R = solve_equation()
ruin_rate = calculate_ruin_rate(R)
print("破産確率は{}%です".format( round(ruin_rate * 100,2) ))

最初の勝率・損益レシオの条件では以下のような結果になりました。

破産確率は0%です。

念のため、以下のようなコードを追記して解Rが正しいかどうかグラフにプロットしてみましょう。


# グラフを描画
x = np.linspace(0, 1.2, 100)
plt.plot( x,equation(x) )
plt.axhline( y=0,linestyle="dashed" )
plt.axvline( x=1,linestyle="dashed" )
plt.axvline( R, color="gray" )
plt.show()

以下のグレーの線が、x=Rの位置を垂直線でプロットしたものです。ちゃんと正しい箇所を求めることができています。

資金の条件を変えてみた場合

ではBOTの成績は同じまま、初期資金を10万円に変えてテストしてみましょう。

成績

・勝率 41.5%
・平均利益率 8.52%
・平均損失率 2.85%
・損益レシオ 2.99

仮定条件

・初期資金 10万円
・1回の負けトレードの損失額1万円

この場合の破産確率は以下のようになります。

特性方程式の解Rは変わっていませんが、破産確率は 1.77%に上昇しています。

同じように1回の負けトレードの損失額(=1回の賭け金)を上げた場合も、破産確率は上昇します。全く同じ損益レシオや勝率のBOTを使っても、初期資金や毎回のトレードで取るリスク額によって、破産確率が変動することがわかります。

これが前回の記事で、「損益レシオと勝率だけのバルサラ破産確率表を使ってもあまり意味がない」と説明した理由です。

次回

さて、今回はより実践的なトレードで「平均利益と平均損失の割合が異なる場合」の破産確率の計算方法を解説しました!

しかし残念ながら、あと1つだけどうしても簡単に設定できない数値があります。それが「1回の負けトレードの損失額」(=賭け金)です。

1)1回の損失額の定義

トレードにおける賭け金とは、この記事の定義でも確認したように1回のトレードで実際に損する金額のことです。しかし実践のトレードでは、この「1回のトレードの損失額」は一定額に固定することができません。

例えば、第7回の「資金管理編」では、毎回、口座残高のX%だけリスクを取り、そのリスクの範囲内でポジションサイズを計算する方法を紹介しました。この方法の場合、毎回の賭け金(トレードでリスクに晒す金額)は、そのときの口座残高に応じて毎回変動します。

「毎回1万円を賭ける」と決まっているゲームであれば、上記の公式をそのまま使うことができます。ですが、トレードのように毎回の損失額を固定できないゲームでは、上記の公式を使って破産確率を計算することはできません。

2)対数(log)に変換する

最後の検証で確認したように、上記の公式の「1回のトレードの損失額」は、それ自体が破産確率に影響します。そのため、平均損失率(%)などの割合ではなく、何らかの具体的な金額(絶対値)を使わなければなりません。

しかしこれを割合で置き換える方法が1つあります。それが、初期資金や損失額などのお金の単位を「円」ではなく「log(対数)」に変換する方法です。この方法を使えば、「毎回のトレードで口座の一定率(3%等)のリスクを取った場合」などの破産確率をシミュレーションできるようになります。

次回の記事は、高校数学のlogを忘れた方でもわかるように、これについて詳しく解説します。

次回:
FXトレードの破産確率を高校数学で理解しよう(3)

FXトレードの破産確率を文系でもわかる高校数学で理解してみよう!

はじめにお断りしておくと、私自身が私大文系の出身で数学はもともと苦手です。

社会人になってから興味をもって少しだけ勉強するようになりましたが、それでも高校数学の範囲も復習しないと自信がありません。この記事は、同じレベルの方でも何となく破産確率を理解できることを目指します。

バルサラの破産確率表

よく「FX 破産確率」などで検索すると、バルサラ破産確率表なるものがズラっと出てきます。列に「損益レシオ」、行に「勝率」が記載された表で、見たことがある方もいるかもしれません。

▽ Google検索の「バルサラ破産確率表」

検索結果のリンク

しかしその多くは前提条件や計算根拠が不明なまま転載されている表で、あまり役に立ちません。破産確率は「資金の量」によって決まる関数なので、資金の量を聞かれてないのに破産確率だけわかる、ということは基本的にありえません。

おそらく出回っているバルサラ破産確率表は、「口座のn%を毎回トレードで賭ける」「口座がk%まで減ったら破産とみなす」という前提条件があるはずで、それこそが知りたい数字なのですが、そこまで記載されていることも少ないです。

前提

私は以下のページを参考にさせて貰い、熟読してやっと破産確率の正しい計算方法を何となく理解することができました。数学が得意な方は、おそらく直接以下のページを読んだ方が理解が早いと思います。

破産確率について

「チンプンカンプンだ….」という方は、私と同じなので安心してください。私もわざわざ「漸化式って何….?」「等比数列の和の公式って何だっけ?」というレベルから復習してやっとわかりました(笑)

この記事では、最低限、高校数学Bの確率と数列(等比数列の公式など)が登場します。ただそれがわからない方でも、破産確率の式が何を意味しているのか、何となくわかるように説明したいと思います。

勝率についての公式

まず最初にもっとも簡単な公式から確認していきましょう。
以下の前提条件を満たすとき、この公式が成り立ちます。

前提条件

1)勝ったら1円の利益、負けたら1円の損失というトレード
2)市場の資金は無限と仮定する
3)勝率は50%を超えていると仮定する

公式

$${\Large 破産確率\ =\ \left( \ \frac{負ける確率}{勝つ確率} \ \right)^{資金の量}}$$

 

この条件では、勝率が50%以下のときに期待値が0円を下回ります。そのため、半永久的にトレードを繰り返すといつか必ず破産します。この「期待値が0円を下回る場合、ずっとトレードを繰り返すといつか必ず破産する」という考え方は、今後も使うので覚えておいてください。

この公式からわかること

この時点でわかることは、「言われなくても知ってるよ!」という当たり前のことばかりですが、一応、数式で確認しておきましょう。

勝率が50%を超えるという前提ですから、()の中の数字は必ず1未満になります。そして勝率が大きければ大きいほど、中身は小さな数字になります。つまり勝率が高いほど、破産確率は小さくなります。

また「勝つと1円儲かる」「負けると1円損する」というのが前提ルールですから、資金が多ければ多いほど破産確率は低くなるはずですし、実際、そうなっています。()の中の数字は必ず1未満の数字ですから、乗数は大きければ大きいほど、破産確率は小さくなります。

例)
$$0.8\ >\ 0.8^{2} \ >\ 0.8^{3} \ >\ 0.8^{4} \ ….$$

この最初の公式だけでも、「資金量を聞かれてないのに破産確率がわかるのはおかしい」と判断できますね。この公式は、今後のさらに実践的な公式の基礎となる部分なので、ぜひ押さえておいてください。

1)高校数学の「確率」から考える

では、なぜ上記のような式になるのか確認しておきましょう。まずはスタートとして、高校数学の「確率」から考えます。資金がn円のときに破産する確率をQ(n)とします。

例えば、現在の所持金を100円だと仮定しましょう。今が何回目のトレードかはわかりません。わかりませんが、とにかく何回目かのトレードの時点で所持金が100円になりました。所持金100円なので、もちろんまだ破産していません。

所持金100円の時点から将来的に破産する確率は、Q(100)です。ここまでは問題ないと思います。

では、さらに所持金100円の次に取りうる状態を考えてみましょう。「勝ったら1円儲かる」「負けたら1円失う」というトレードですから、次の所持金は、勝って101円になるか、負けて99円になるか、の2通りしかありません。

つまり「所持金100円で破産する確率」というのは、以下の2つの確率の合計で表すことができます。(Pは勝率)

1)所持金100円で将来的に破産する確率 Q(100)
2)次に所持金101円になって将来的に破産する確率 Q(101) × P
3)次に所持金99円になって将来的に破産する確率 Q(99) × (1-P)

(数式)
$${\small Q( 100) \ =\ Q( 101) \times P\ +\ Q( 99) \times ( 1-P) \ }$$

ここまでは大丈夫でしょうか?

2)漸化式にする

上記の式は、どの資金額の時点でも同じですから、以下のように一般化することができます。

 
$$Q( n) \ =\ Q( n+1) \times P\ +\ Q( n-1) \times ( 1-P) \ $$

 
これは私と違って高校時代にちゃんと数学を勉強していた方なら、見覚えがあるかもしれません(笑)。「漸化式」というやつですね。もっと具体的にいうと、3項間漸化式(特性方程式)というジャンルの問題です。

さすがにここで漸化式の解き方を0から解説すると終わらなくなるので、もし漸化式がわからない方は以下のYouTube講義動画を見てください。めちゃくちゃわかりやすいです。私の高校時代もこうやって教えて欲しかった…。

とある男が授業してみた 【高校数学】数B-93漸化式7

では同じやり方で解いてみましょう。

3)特性方程式を解く

$${\small Q( n) \ =\ P\cdotp Q( n+1) \ +\ ( 1-P) \cdotp Q( n-1)}$$

まずは高校数学の教科書と同じ感じになるように、Q(n-1) を an に置き換えましょう。

$$a_{n+1} \ =\ P\cdotp a_{n+2} \ +\ ( 1-P) \cdotp a_{n}$$

するとこれは高校で習う三項間漸化式の問題なので、以下のような特性方程式を考えます。この特性方程式の解を使うと、上記の式を綺麗に変形することができるからです。

(特性方程式)
$$x\ =\ P\cdotp x^{2} \ +\ ( 1-P)$$

この方程式を因数分解すると以下のようになります。

$${\small P( x-1)\left( x-\frac{1-P}{P}\right) \ =\ 0}$$
$$よって解は、\ x\ =\ \ 1,\ \frac{1-P}{P}$$

このことから、特性方程式の性質(二次方程式の「解と係数の関係」)を使って、最初の式は以下のように変形できます。

$$a_{n+2} \ -\ a_{n+1} \ =\ \frac{1-P\ }{P}( a_{n+1} \ -\ a_{n})$$

これは、公比が (1-P)/P の階差数列です。

階差数列というのは、各項目の間隔が等比数列や等差数列になっている順列のことです。つまり、破産確率の関数Q(n)は、資金が1円ずつ増えるたびに、どんどん破産の可能性が低くなるものの、その減り幅は ×(1-p)/P ずつ狭くなる関数だということがわかります。

さて、上記は階差数列なので、以下の公式を使うと a(n) の一般項を求めることができます。(階差数列の公式

$$\begin{array}{l}
a_{n+1} -a_{n} \ の部分を\ b_{n} とおくと、\\
\\
b_{n+1} =\left(\frac{1-P}{P}\right) b_{n} \ \ となるので、\ b_{n} は公比数列です。\\
\\
公比数列の公式により、b_{n} \ の一般項は、\\
\\
b_{n} =b_{1} \ \cdotp \left(\frac{1-P}{P}\right)^{n-1} となります。\\
\\
\\
そのため、n\ \geq 2\ のときの\ a_{n} の一般項は、\\
\\
a_{n} \ =\ a_{1} +{\displaystyle \sum ^{n-1}_{k=1} b_{k}}\\
\\
=a_{1} +{\displaystyle \sum ^{n-1}_{k=1} \ ( a_{2} \ -\ a_{1}) \cdotp \ \left(\frac{1-P}{P}\right)^{k-1} \ }\\
\\
=a_{1} +( a_{2} -a_{1}){\displaystyle \sum\limits ^{n-1}_{k=1}\left(\frac{1-P}{P}\right)^{k-1}}\\
\\
となります\\
\
\end{array}$$

では、これを最初の破産確率の Q(n) に戻してみましょう。
すると以下のようになりますね。

$$Q( n) =Q( 0) +( Q( 1) -Q( 0)) \cdotp {\displaystyle \sum ^{n}_{k=1}} \ \left(\frac{1-P}{P}\right)^{k-1}$$

また資金が0円のときの破産確率 Q(0) は当然、100%(=1)です。そのため、Q(0) には1を代入していきます。ついでに見やすいように、(1-P)/P はいったん、rと置き換えましょう。

$$Q( n) =1+( Q( 1) -1) \cdotp {\displaystyle \sum ^{n}_{k=1}} \ r^{k-1}$$

数列の和の公式」を使えば、シグマも外すことができますね。
これも外しておきましょう。

$$Q( n) =1+( Q( 1) -1) \cdotp \left(\frac{r^{n} -1\ }{r-1}\right)$$

これでいったん完成です。

ですが、まだ最初のシンプルな公式からは程遠いですね。破産確率 Q(1)の正体がわかれば、一気に公式に辿り着けるのですが、この時点ではまだQ(1)が何かはわかりません。

・Q(1) ⇒ 資金が1円のときに将来破産する確率

例えば、資金が1円で勝率が80%の場合、破産確率 Q(1) は、パッと考えると 20%のような気もしますが、そう簡単ではありません。もし次のトレードで勝って破産を免れたとしても、その後に2連敗すれば破産します。もし次とその次のトレードに勝っても、その後に3連敗すれば破産します。

つまり勝率80%の中に、まだ将来破産する確率 Q(2) が含まれていて、資金2円の時点での勝率には 将来破産する確率Q(3)が含まれているので、結局、堂々巡りになるわけです。

実は、この資金が1円のときの破産確率 Q(1) は、負けのオッズ比 、つまり (1-P)/P にピッタリ一致するのですが、そのことは違うアプローチから証明することになります。

4)相手の資金量から破産確率を考える

現実的には、FXのトレードで世界中の資金を奪いつくすことはできません。ですが、数学はただの想像の世界なので、自由な仮定を置くことができます。例えば、相対取引でトレードの相手方の資金がX円であるような場合を考えましょう

「勝ったら1円貰える」「負けたら1円失う」というトレード勝負は、前提として勝率が50%以上という偏りがある以上、半永久的に繰り返せば必ずいつか終わります。つまり、相手の資金を奪いつくしたら終わるわけです。

この相手の資金を奪いつくした時点での自分の資金量を、仮に m円 とします。すると、m円の時点での破産確率は、Q(m)=0 となります。もう破産する可能性が無くなったからです。

 
$${\large Q( m) \ \ =\ 0\ }$$

 
これを先ほどの公式に当てはめると、以下のような方程式を作ることができます。

(方程式)
$$Q( m) =1+( Q( 1) -1) \cdotp \left(\frac{r^{m} -1\ }{r-1}\right) \ =0$$

これを頑張って展開すると、さっき未知数だった Q(1) を 相手の資金量 m円 を使って式に表すことができます。具体的にやってみるとわかりますが、以下のような式にできます。

※ 展開した後、分母と分子の両方に-1を掛けています。

$$Q( 1) \ =\ \frac{r-r^{m}}{1-r^{m}} \ $$

 
さて、現実の世界に戻りましょう。

実際のトレードの世界では、私のような普通の一般人が取引所や市場の資金を奪いつくすことはありえません。つまり、「相手の資金を奪いつくした時点での資金 m円」というのは、現実には辿り着けるはずのない、非常に大きい数字だと考えることができます。

さらに、r というのは何だったかを思い出してみましょう。

rで置き換えたのは、(1-P)/P で、これは「負ける確率 ÷ 勝つ確率」でしたね。これは勝率が50%を超えるとき、必ず1より小さい数になります。

以下の式を見てみると、rのm乗というのは、この1よりも小さい数をとてつもなく大きい指数で乗じたものです。

そのため、こちらの自己資金よりも相手の資金(市場の資金)がとてつもなく大きいときには、この「rのm乗」は無視していいほど小さな数字になります。

結果、さきほどわからないといった 資金1円のときの破産確率 Q(1) は、「市場の資金がとてつもなく大きい」という前提のもとでは、以下のようになるのです。

$$Q( 1) =\frac{r-r^{m}}{1-r^{m}} =r=\ \left(\frac{1-P}{P}\right)$$

例えば、勝率80%、負率20%のトレードを資金1円でスタートしたら、1/4の確率(25%)で破産するということですね。

では、ここで Q(1)=r とわかったので、さきほど「いったん完成」と言っていた式に代入してみましょう。

$$\begin{array}{l}
Q( n) =1+( Q( 1) -1) \cdotp \left(\frac{r^{n} -1\ }{r-1}\right)\\
\\
=\ 1+( r-1) \cdotp \left(\frac{r^{n} -1}{r-1}\right)\\
=1+\left( r^{n} -1\right)\\
=r^{n}\\
\\
=\left(\frac{1-P}{P}\right)^{n}
\end{array}$$

驚くほど気持ちよく消えてシンプルな式になります。
これで最初の公式に辿り着くことができました!
お疲れ様でした!

$${\large 破産する確率\ Q( n) \ =\ \left(\frac{1-P}{P}\right)^{n}}$$

次回

この公式だけでは、実際のトレードではあまり役に立ちません。実際のトレードでは、勝率が50%以上とは限りませんし、平均利益と平均損失の割合も違いますし、さらに毎回のエントリー価格やロット数(賭け金)も異なります。

次回の記事は、この続きで「平均利益と平均損失が異なる場合」について解説します。さらに「口座の一定率をリスクに晒す場合(毎回の賭け金が異なる場合)」について解説し、最終的にはpythonを使って、口座のリスク割合ごとの破産確率をシミュレーションできるようにする予定です。

すべての指標やツールは、何らかの具体的な意思決定に役立ってはじめて意味があります。「へぇ、私のBOTの破産確率は〇%なんだ」という感想で終わったら実用性はありません。実際に口座のリスク率を決定するときの判断材料として役立つようなpythonコードを作っていきましょう。

次回の記事:
FXの破産確率を高校数学で理解しよう!(2)

追記

その後さらに調べたところ、巷に出回っているバルサラ破産確率表は、ナウザー氏の著書から転載されていたようです。原著を確認したところ、前提条件は「所持金1円の状態で1円を賭けてトレードした場合」と「所持金2円の状態で1円を賭けてトレードした場合」の破産確率でした。

▽ 原著の破産確率表

出典「Money Management Strategies for Futures Traders

これは「1回のトレードですべての資金を失うようなリスクを取った場合」の破産確率なので、実際のトレードでは少し考えにくい資金条件ですが、これを正しく転載していれば、数字自体は一致することが確認できました。
 

BTCFXでパラボリックSARを使って加速するトレイリングストップを作ろう!

前回の記事では、含み益を取り逃がさないように、価格の上昇にあわせて一定の比率でストップ位置を移動させていくトレイリングストップの実装方法を解説しました。

1.固定比率のトレイリングストップの問題

しかし固定の比率で損切りラインを動かすトレイリングストップは、実際の運用上は少し問題があります。

例えば、価格が1レンジ動くたびに1レンジ追従する同じ比率のトレイリングストップだと、かなり序盤で損切りにかかってしまう可能性が高いです。それだと、チャネル・ブレイクアウトが持つ本来の期待値を「邪魔」してしまうことになります。

一方で、価格が1レンジ動くたびに1/2レンジずつ追従する低い比率のトレイリングストップだと、時間が経つにつれてどんどん価格と損切りラインが乖離してしまい、損切りにかかる可能性が低くなってしまいます。これだと「大きな含み益を逃がさない」という本来の目的を達成できません。

 

2.加速係数付きのトレイリングストップ

そこで理想的なトレイリングストップとして、時間の経過とともにトレイリングの比率がだんだん上昇していくようなトレイリングストップを考えます。

例えば、最初は価格が動いてもストップ位置を2%しか動かさず、次に価格が動いたらストップ位置を4%動かし、次に価格が動いたらストップ位置を6%動かし….、といった具合に、徐々にトレイリングの比率を加速させていきます。

この手法のなかでも有名なのは、「投資苑」の著者としてお馴染みのアレキサンダー・エルダー氏が「利食いと損切りのテクニック」という本で紹介しているパラボリックSARを使ったトレイリングストップです。

3.パラボリックSARの定義

まずはパラボリックSARを使ったトレイリングストップを明確に定義しておきましょう。

買いエントリーの場合

1)最初の加速係数を0.02とする
2)エントリー後に新しい高値を更新したら、( 高値 - 損切りライン )× 加速係数 を計算し、その額分だけ損切りラインを上に動かす
3)損切りラインが動くたびに、加速係数に 0.02 を足す
4)加速係数の上限は 0.2 までとする

売りエントリーの場合

1)最初の加速係数を0.02とする
2)エントリー後に新しい安値を更新したら、( 損切りライン - 安値 )× 加速係数 を計算し、その額分だけ損切りラインを下に動かす
3)損切りラインが動くたびに、加速係数に 0.02 を足す
4)加速係数の上限は 0.2 までとする

例題

例えば、BTC価格が100万円のときに買いエントリーし、損切りラインを98万円の位置に置いたとします。その1時間後、BTC価格の高値が102万円まで上昇した場合のストップ位置は以下です。

・加速係数 0.02
・98万円 + (102万円 - 98万円) × 0.02 = 98万800円

もし次の1時間で高値が101万円まで下落したとします。この場合は何もしません。前回の記事でも説明したように、原則としてトレイリングストップを下に動かすことはありえません。

さらに次の1時間で高値が103万円まで上昇したとします。この場合のストップ位置は以下です。

・加速係数 0.04
・98万800円 + (103万円 - 98万800円) × 0.04 = 98万2768円

さらに次の1時間で高値が105万円まで上昇したとします。この場合のストップ位置は以下です。

・加速係数 0.06
・98万2768円 + (105万円 - 98万2768円) × 0.06 = 98万6802円

4.Pythonコードを実装しよう!

では、前回の記事で作成したトレイリングストップの関数を修正して、上記のロジックを実装してみましょう!

なぜかパラボリックSARでは、加速係数の初期値を 0.02、足すのも0.02ずつ、上限は0.2まで、というのが、教科書的な常識とされています。しかし理由がわからないまま、自分で検証していない数値を信じるべきではないので、ここでは自由に変更できるようにしておきます。

▽ pythonコード


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

stop_AF = 0.02             # 加速係数
stop_AF_add = 0.02         # 加速係数を増やす度合
stop_AF_max = 0.2          # 加速係数の上限



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

	# まだ追加ポジション(増し玉)の取得中であれば何もしない
	if flag["add-position"]["count"] < entry_times:
		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


#--------記録用の変数--------

flag = {
	"position":{
		"stop-AF": stop_AF, # 加速係数を記録
		"stop-EP":0,        # 最高値/最安値 と エントリー価格の値幅を記録
		(略)

stop-AF は加速係数です。
加速係数は英語でAccelerationFactorなのでAFと略します。

stop-EP は直近の最高値・最安値とエントリー価格からの値幅です。
最高値や最安値のことをExtremePoint(EP)と略すことがあります。

コードの解説

パラボリックSARによるトレイリングストップでは、損切りラインを動かすタイミングは、買いエントリーなら最高値を更新したとき、売りエントリーなら最安値を更新したときだけです。

そのため、エントリーして以来の最高値・最安値とエントリー価格との値幅を flag[position][stop-EP] に記録しておきます。そして、現在のローソク足の高値(安値)とエントリー価格の値幅を計算して比較し、最高値を更新したかどうかを判断しています。

ストップ幅の計算と更新

前回と同様、損切りラインの計算や記録には、価格の絶対値ではなくエントリー価格からの相対的な値幅(ストップ幅)を利用しています。この方が計算が簡単だからです。


# 加速係数に応じて損切りラインを動かす
flag["position"]["stop"] = round(flag["position"]["stop"] - ( moved_range + flag["position"]["stop"] ) * flag["position"]["stop-AF"])

上記の計算式で何を計算しているのかは、以下の図を見るとイメージしやすいと思います。

パラボリックSARによるストップ価格の計算は、「前回のストップ価格 + 加速係数 × (高値or安値 - 前回のストップ価格)」で計算することは、すでに定義のところで説明しましたが、これは以下を計算するのと同じです。

・ストップ幅 = 前回のストップ幅 - (エントリー価格からの動きの幅 + 前回のストップ幅) × 加速係数

この式だと、売りエントリーか買いエントリーかで場合分けしなくていいので楽ですが、混乱するようであれば、価格ベースで計算するプログラムに書き直してみてください。

手仕舞いの関数

今回は新たに損切りに使う変数を2つ追加しました。ポジションを閉じるたびにこれらの変数を初期化するのを忘れないようにしましょう。


# 手仕舞いや損切の関数
def close_position( data,last_data,flag ):

	# 決済の成行注文コード
	flag["position"]["stop-AF"] = stop_AF
	flag["position"]["stop-EP"] = 0

5.実行結果

このプログラムを前回のコードに入れて実行してみましょう!
以下のようになります。

なお、このような計算を含むコードは思った通りの計算結果になっているかどうか、必ず自分で確認した方がいいです。私の計算やプログラムが間違っている可能性もあります。自分で検証せずに鵜呑みにしていい情報はありません。

▽ 何カ所か検算してみて一致するか確認する

検算の結果が問題なさそうであれば、いつものようにパフォーマンスを検証してみましょう!

6.パフォーマンスの検証

検証には前回と同じ条件を使います。

売買ロジック

・1時間足を使用(2017/8~2018/5)
・上値ブレイクアウト判定期間 30期間
・下値ブレイクアウト判定期間 30期間
・平均ボラティリティ計算期間 30期間
・ブレイクアウトの判定基準(高値/安値)

資金管理

・初期資金30万円
・レバレッジ3倍
・初期ストップのレンジ幅 2ATR
・1トレードで許容するリスク 3%
・分割エントリー2回

まずはトレイリングストップを使用しない場合を見ておきましょう。
損切りの関数から、トレイリングストップの関数を読み込む部分をコメントアウトするだけですね。

1)トレイリングストップを使わない場合

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  111回
勝率               :  34.2%
平均リターン       :  1.26%
平均保有期間       :  45.4足分
損切りの回数       :  57回

最大の勝ちトレード :  650219円
最大の負けトレード :  -109947円
最大連敗回数       :  10回
最大ドローダウン   :  -689533円 / -20.0%
利益合計           :  5796568円
損失合計           :  -2452422円
最終損益           :  3344146円

初期資金           :  300000円
最終資金           :  3644146円
運用成績           :  1215.0%

運用成績1215%は前回と同じ結果ですね。では次に、パラボリックSARを用いた加速度付きのトレイリングストップを見てみましょう。

2)パラボリックSARのストップを使った場合

-設定値

・加速係数の初期値(stop_AF) 0.02
・加速係数の増分(stop_AF_add)  0.02
・加速係数の上限値(stop_AF_max) 0.2

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  123回
勝率               :  38.2%
平均リターン       :  1.37%
平均保有期間       :  35.0足分
損切りの回数       :  102回

最大の勝ちトレード :  1133908円
最大の負けトレード :  -144024円
最大連敗回数       :  10回
最大ドローダウン   :  -878122円 / -18.3%
利益合計           :  8510061円
損失合計           :  -3218870円
最終損益           :  5291191円

初期資金           :  300000円
最終資金           :  5591191円
運用成績           :  1864.0%

運用成績がかなり大幅に向上しましたね。特にパラボリックSARを用いたトレイリングストップでは、「最大勝ちトレード」が大きく伸びているのがわかります。損切りの回数は40回ほど増えていました。

では実際に両者の「最大勝ちトレード」のログを比較してみましょう。

▽ トレイリングストップ無しのログ

・2018/4/19 06:00
30期間上値ブレイクアウトで買いエントリー(建値 908167円)
・2018/4/25 20:00
30期間下値ブレイクアウトで決済(決済 1043000円)

▽ パラボリックSARのトレイリングストップ

・2018/4/19 06:00
30期間上値ブレイクアウトで買いエントリー(建値 908167円)
・2018/4/25 13:00
トレイリングストップにかかって損切り(決済 1133908円)

同じ場面でエントリーしても、パラボリックSARを用いたトレイリングストップの方が、一歩早く有利な価格で退出しているのがわかります。

他の係数の検証

ではもう少しだけトレイリング率の加速度を上げてみた場合はどうなるでしょうか? 2つほど追加で検証してみましょう。

加速度と上限を1.5倍にした場合

-設定値

・加速係数の初期値(stop_AF) 0.03
・加速係数の増分(stop_AF_add)  0.03
・加速係数の上限値(stop_AF_max) 0.3


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  134回
勝率               :  40.3%
平均リターン       :  1.43%
平均保有期間       :  30.3足分
損切りの回数       :  117回

最大の勝ちトレード :  1692475円
最大の負けトレード :  -188440円
最大連敗回数       :  10回
最大ドローダウン   :  -1148317円 / -18.0%
利益合計           :  11095214円
損失合計           :  -4075694円
最終損益           :  7019520円

初期資金           :  300000円
最終資金           :  7319520円
運用成績           :  2440.0%

こちらの方がパフォーマンスは絶大に見えます。運用成績は2440%でトレイリングストップを用いなかった場合の2倍以上の成績となりました。平均保有期間も、元々の2/3程度にまで縮んでいます。

加速度と上限を2倍にした場合

-設定値

・加速係数の初期値(stop_AF) 0.04
・加速係数の増分(stop_AF_add)  0.04
・加速係数の上限値(stop_AF_max) 0.4


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  154回
勝率               :  35.1%
平均リターン       :  0.44%
平均保有期間       :  24.3足分
損切りの回数       :  143回

最大の勝ちトレード :  212680円
最大の負けトレード :  -62663円
最大連敗回数       :  10回
最大ドローダウン   :  -397158円 / -18.6%
利益合計           :  3998226円
損失合計           :  -2124429円
最終損益           :  1873797円

初期資金           :  300000円
最終資金           :  2173797円
運用成績           :  725.0%

こちらはやり過ぎてしまったようですね。さきほどの加速係数に比べると、運用成績が極端に悪化しています。最大勝ちトレードが21万円しかなく、手仕舞いが早すぎた可能性が高いです。

5.今回勉強したコード



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

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

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

stop_AF = 0.02             # 加速係数
stop_AF_add = 0.02         # 加速係数を増やす度合
stop_AF_max = 0.2          # 加速係数の上限

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_lot( last_data,data,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
	
	# 最初(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:
		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"]]))
		
		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"]]))
		
		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
			
			
			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
			
			
			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 ):
	
	# トレイリングストップを実行
	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
	
	# 総損益の列を追加する
	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.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() ))
	
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])

	# 損益曲線をプロット
	plt.plot( records.Date, records.Funds )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	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"]:
		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)

Cryptowatchで過去6000件以上のローソク足を保存して自前で価格データを蓄積する

以前の記事でも解説したように、CryptowatchのAPIから直接に取得できるBitflyerFXのローソク足は原則として最大6000件までです。

1)問題点

6000件のローソク足データは、1分足だと約4日分、1時間足でも8カ月分程度にしかなりません。より広範なバックテストをしたいとき(例えば、フォーワードテストやローリングウィンドウなどをしたい場合)は、少し物足りなく感じるでしょう。

また複数の時間軸を併用する売買ロジックをテストするときや、他の時間軸と成績を比較したいときに、期間が揃わないという問題もあります。そこで、自前でローソク足データを定期的に収集して保存するスクリプトを作成してみましょう。

※ 収集した価格データは個人利用の目的でのみ使用できます。

2)普通に価格を取得して保存するスクリプト

最終的には、自動的に価格データを収集し、新しく増えた価格データ(差分)だけを上書きで追加していくスクリプトを作ります。

まずは普通にCryptowatchのAPIで取得したJSONデータをそのままファイルに保存するコードを作成してみましょう。

pythonコード


import requests
from datetime import datetime
import time
import json

chart_sec = 60         # 保存したいローソク足の時間軸
file = "./test.json"   # 保存するファイル名

# Cryptowatchのデータを単に保存するだけの関数
def accumulate_data(min, path, before=0, after=0):
	
	# APIで価格データを取得
	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()
	
	#ファイルに書き込む
	file = open( path,"w",encoding="utf-8")
	json.dump(data,file)
	
	return data

# メイン処理
accumulate_data(chart_sec, file ,after=1483228800)

ここまでは、以前に勉強した内容そのままです。

CryptowatchのAPIの基本的な仕様やパラメーターをよく理解していない方は、まず以下の記事を参考にしてください。

・バックテストに必要な過去の価格データを集めよう

これで、まずは上記のコードで直近6000件の価格データをファイルに保存することができました。試しにこちらのコードを1分足で実行してみましょう。

実行結果

▽以下のようにファイルに保存されます

▽ APIで取得する内容がそのまま保存されます

3)差分だけを取得して追加保存するスクリプト

では次に、さきほど作成したファイルに新しく増えた価格データ(差分)だけを追加取得するスクリプトを作ってみましょう。

仮にさきほどのファイルを file1、新しく保存するファイルを file2 とします。もちろん実際のスクリプト運用時には、両者は同じファイル名になります。


import requests
from datetime import datetime
from pprint import pprint
import time
import json

chart_sec = 60          # ローソク足の時間軸
file1 = "./test.json"   # 読み込み元のファイル名
file2 = "./test2.json"  # 追加保存するファイル名


# Cryptowatchから差分だけを追加して保存する関数
def accumulate_diff_data(min, read_path, save_path, before=0, after=0):
	
	# APIで価格データを取得
	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)
	web_data = response.json()
	web_data_set = set( map( tuple, web_data["result"][str(min)] ))
	
	# ファイルから価格データを取得
	file = open( read_path,"r",encoding="utf-8")
	file_data = json.load(file)
	del file_data["result"][str(min)][-1] # 末尾は被るので削除
	file_data_set = set( map( tuple, file_data["result"][str(min)] ))

	
	# 差分を取得
	diff_data_set = web_data_set - file_data_set
	diff_data = list(diff_data_set)
		
	# 差分を追加する
	if len(diff_data) != 0:
		print("{}件の追加データがありました".format( len(diff_data) ))
		diff_data.sort( key=lambda x:x[0] )
		file_data["result"][str(min)].extend( diff_data )
		pprint(diff_data)
	
	#ファイルに書き込む
	file = open( save_path,"w",encoding="utf-8")
	json.dump(file_data,file)
	
	return file_data

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

# 差分の価格データを保存する
accumulate_diff_data(chart_sec, file1, file2, after=1483228800)

1分足の価格データをCryptowatchのAPIで取得し、さらに同じ1分足の価格データをファイルから読み込み、差分を取得して、増えた価格データだけを配列の末尾に追加して再保存しています。

file1 と file2 に同じファイルを指定すると上書き保存になります。今回は、説明しやすいように敢えて別のファイル名にしています。

実行結果

試しに2時間後に上記のコードを同じ1分足で実行してみました。

▽以下のようにファイルに保存されます

▽差分だけが順番通りに追加されています

コードの解説

pythonで配列同士の差分を取得したい場合には、集合型(set型)を使います。set()で、配列を集合に変換することができます。

差分をpythonで取得する方法


a = [1,2,3,4,5]
b = [3,4,5,6,7]

# 集合に変換
set_a = set(a)
set_b = set(b)

# 差分を取得
diff = set_b - set_a

# 結果:{6,7} を取得

# 配列に戻す
diff = list(diff)

# 結果:[6,7] を取得

このとき、引く順番に注意してください。
集合B に新しく増えたデータを取得したい場合には、(集合B – 集合A)を計算する必要があります。

今回の例でいえば、知りたいのは、(Cryptowath の価格データの集合) - (ローカルファイルの価格データの集合) です。

多次元配列を集合に変換する方法

ただしCryptowatchの価格データは、[ 日時, 始値, 高値, 安値, 終値,… ] の配列が、[[],[],[],[],[],[],[]… ] と何個も並んだ多次元配列の構造になっています。

多次元配列はそのままでは集合に変換できませんので、一度、タプル型に変換する必要があります。そのため、以下のように map( tuple,
配列 ) で囲っています。


web_data_set = set( map( tuple, web_data["result"][str(min)] ))
file_data_set = set( map( tuple, file_data["result"][str(min)] ))

これについては、以下の記事を参考にさせて貰いました。

・集合型で2次元リストの中の重複した行を削除する

差分データの順番を時系列順に直す

2つの集合の差分を取った時点では、新しく増えた差分の価格データは順番がぐちゃぐちゃです。そのため、これを時系列順に直してから元のファイルの配列の末尾にくっつけます。

多次元配列の順番は、以下のように修正できます。


# ぐちゃぐちゃの順番の多次元配列
number = [[ 12,314356 ],[ 13,351243 ],[ 15,334324 ],[ 11,304324 ],[ 10,313243 ],[ 14,332343 ]]

# 1番目を基準にソートしたい場合
number.sort( key=lambda x:x[0] )

# 実行結果
# [[10, 313243], [11, 304324], [12, 314356], [13, 351243], [14, 332343], [15, 334324]]

# 2番目を基準にソートしたい場合
number.sort( key=lambda x:x[1] )

# 実行結果
# [[11, 304324], [10, 313243], [12, 314356], [14, 332343], [15, 334324], [13, 351243]]

Cryptowatchの価格データは、配列の先頭の要素 [0] が日時データなので、こちらを基準にソートすればOKです。なお、pythonでの多次元配列のソートについては以下の記事が参考になります。

・参考:pythonでのソート

ファイルデータの末尾を削除する

Cryptowatchから取得した価格データの末尾には、まだ確定していないリアルタイムに形成中の足が含まれています。

こちらをそのまま放置すると、その時間の足が確定したときに「差分」として検出されてしまうため、出力後のファイルに同じ時間の足が2つ存在してしまうことになります。そのため、差分を取得する前にあらかじめ、元ファイルの末尾のデータを削除しておきます。

del file_data["result"][str(min)][-1] # 末尾は被るので削除

以上で完成です!

4)ファイルから価格データを読み込むスクリプト

最後にファイルから価格データを読み込むスクリプトを作っておきましょう。

今回の記事では、CryptowatchのAPIで取得したデータを加工せずにそのままJSON形式で保存しています。そのため、基本的にはCryptowatchのAPIをパースする関数を、そのまま流用することができます。

せっかくなので期間も指定できるようにしてみましょう。


import requests
from datetime import datetime
import time
import json
from pprint import pprint


chart_sec = 60      # ローソク足の時間軸
file = "./test2.json"    # 読み込む価格ファイル
start_period = "2018/05/07 00:00"
end_period = "2018/05/08 00:00"


# 価格ファイルからローソク足データを読み込む関数
def get_price_from_file( min,path,start_period = None, end_period = None ):
	file = open(path,"r",encoding="utf-8")
	data = json.load(file)
	
	start_unix = 0
	end_unix = 9999999999
	
	if start_period:
		start_period = datetime.strptime(start_period,"%Y/%m/%d %H:%M")
		start_unix = int(start_period.timestamp())
	if end_period:
		end_period = datetime.strptime( end_period,"%Y/%m/%d %H:%M")
		end_unix = int(end_period.timestamp())
	
	price = []
	for i in data["result"][str(min)]:
		if i[0] >= start_unix and i[0] <= end_unix:
			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

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

#ファイルから価格データを読み込む
price = get_price_from_file(chart_sec, file)


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

# 先頭50個だけプリントする
pprint( price[:50] )

仕様

最初に指定した期間をunix時間に変換して、開始時点(start_period)~終了時点(end_period)までのローソク足データを返します。

開始時点と終了時点を指定しなかった場合は、ファイルに存在する全てのローソク足を返します。また期間を指定した場合でも、その期間のデータが存在しない場合は、存在する範囲のローソク足データを返します。

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

実行結果

まずは以下のように何も引数を指定しなかった場合の実行結果です。

price = get_price_from_file(chart_sec, file)

▽ 実行結果

無事、6000件を超えるデータを取得できていますね。

では、次に期間を指定してみましょう。
期間を指定する場合は以下のように引数を設定します。


start_period = "2018/05/07 00:00"
end_period = "2018/05/08 00:00"

price = get_price_from_file(chart_sec, file, start_period, end_period)

▽ 実行結果

このように、指定期間のデータだけを抽出することができました!

5)データの欠損を調べる

ちなみに上記のデータには一部に欠損があります。
1分足で24時間の範囲を指定したのに、ローソク足データが1429件しかないのはおかしいですね。どのデータが欠損しているのか調べてみましょう。

さきほどのコードの末尾に以下のようなプログラムを足してみてください。


# どの時間のデータが抜けてるか調べる
num = int( datetime.strptime(start_period,"%Y/%m/%d %H:%M").timestamp() )
for i in range( len(price) ):
	match = False
	for p in price:
		if num == p["close_time"]:
			match = True
	if match == False:
		print("{} の価格データが存在しません".format(datetime.fromtimestamp(num)))
	num += chart_sec

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

▽ 実行結果

午前4時0分~12分までの1分足データがごっそり欠落しています。直接、ブラウザでAPIを叩いてみても該当する時間帯のローソク足のデータは存在しないことがわかります。

Bitflyerは毎朝4時にサーバーの定期メンテナンスがあります。そのため、他の日時で試しても、朝4時0分~10分頃のデータが存在しないことが多いようです。

それ以外にデータの欠損はありませんが、一応、ときどきこのようなデータの欠損がないかは確認してみるといいかもしれません。

6)データ収集の頻度

上記のコードの実行は、蓄積したいのが1時間足であれば1カ月に1度程度、1分足でも1~2日に1回実行すれば十分です。実行頻度がそれほど高くないため、常時起動してWhile文でループするのはあまりに効率が悪いです。

もちろん手動で実行しても構わないのですが、手動での実行だと忘れてしまう可能性もあります。そこで、Windowsのタスクスケジューラにpythonのファイルを登録して、OSに定期実行して貰うよう設定するのがお勧めです。

次の記事では、その具体的な方法を解説します!

参考:Windowsのタスクスケジューラでpythonを定期実行する