CryptowatchのBitflyerFX価格取得APIの使用回数制限について

2020年9月20日頃より、CryptowatchAPIに使用回数制限がかかるようになり同時に一部有料化されました。
まず最初に要点をまとめます。

要点

(1)CryptowatchのAPIを使用して情報を取得するたびに『クレジット』が消費される。
(2)無料匿名アカウントでは24時間につき10クレジットまで付与され使用可能。
(3)BitflyerFX の OHLC(ローソク足)データの価格取得では、約0.015クレジットを消費する。
(4)無料で使いたい場合は計算上は1日約666回まで使用可。つまり使用は3分間に1回程度に留める必要あり。
(5)有料契約する場合は、1ドルで100クレジットを購入可能。

Crptowatch公式の説明ページ

以下、説明していきます。

 

1.無料で使い続けたい場合

公式ページのクレジット表から逆算すると、無料匿名アカウントの場合1日にAPIを使用できる回数は おそらく666回までになります。1時間で27回以下、1分間で0.45回以下です。つまり3分間(180秒)に1回程度の使用であれば、理論上は継続して使用できると思います。

クレジットが切れてしまうと、cyrptowatchのAPIを使用しても情報が返ってこなくなるため、BOTにエラーが発生します。(429 Too Many Requestsエラーが出ます)

当サイトで紹介しているBOTの作成に関してですが、1時間足以上のスイングトレードであれば、3分間に1回の情報取得でも実際のところそれほど深刻な影響はないと思います。この辺りの判断はご自身でお願いします。

2.代わりにCryptocompareを使用する場合

なお、Cryptowatchから他の価格取得APIに乗り換える場合は、以下の記事で Cryptocompare を紹介しています。

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

 
また以下はユーザー様のログから提供いただいた情報です。

cryptocompareも昔は使用回数制限はなかったのですが、2021年3月現在、以下のような使用制限があるみたいです。1分間に1回使用する程度なら、月間46300回なのでおそらく問題ありません。30秒に1回だと月間86400回で、おそらくオーバーします。

(cryptocompareの使用制限)

1カ月あたり 5万回まで
1日当たり7500回まで
1時間あたり3000回まで
1分あたり300回まで
1秒あたり 20回まで

 

3.Cryptowatch有料契約を検討する場合

有料契約をするには、Cryptowatchでアカウントを作成してクレジットカードを登録する必要があります。当然、当サイトとは何の関係もない外部サービスなのでご自身の判断でお願いします。勧めてるわけではありません。

例えば、30秒に1回APIを使用したい場合、1日で約33.2クレジットを消費する計算になるので、1カ月で約996クレジットの消費になります。課金は1ドル100クレジットなので、具体的にいえば月額1000円程度になると思います。

(計算例)
1日の消費クレジット = 0.015 × 2880 – 10(無料付与分) = 33.2

1分に1回APIを使用する場合は、1日で11.6クレジットなので1カ月分で348クレジット。月額500円程度になると思います。

(計算例)
1日の消費クレジット = 0.015 × 1440 – 10 = 11.6

 

有料APIの使い方

従来の価格取得APIを使用するコードは以下のようなものでした。

response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc", params = { "periods" : 60 })

有料契約した場合、専用のAPIキーが発行されます。
そのAPIキーを以下のようにURL末尾につけて、APIを使用します。

response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc?apikey=***************", params = { "periods" : 60 })

 

4.BitflyerのAPIを使う方法

外部サービスを使用せずに、BitflyerFXのAPIを使用して自分でローソク足データを形成する方法が、もしかすると今は一般的なのかもしれません。私はそれはやっていないのでわかりません。

Bitflyerのメンテナンス時間中はBOTの稼働を停止させたい場合

Bitflyerでは毎日午前4時00分~午前4時10分にかけて定期メンテナンスが実施されます。このメンテナンスの時間中は新規の注文ができないだけでなく、事前に受け付けられた注文も約定しないまま消滅します。

参考リンク:Bitflyer公式「メンテナンスについて教えてください」

問題

1時間足を基準にトレードするBOTの場合、午前4時ちょうどにエントリーや決済のシグナルが出ることもあります。

このときBOTの新規注文が拒否されれば通常の例外処理で対応できますが、ギリギリで注文が通ってしまった場合、サーバー側ではそのまま約定することなく消滅するため、BOT側は永遠に約定を待つことになり、トラブルの原因になります。

そのため、この記事ではメンテナンス時間中にBOTを停止する方法を解説します。

解決策

以下のようなコードをメインのwhile文の中の先頭の部分に追加しましょう。


wait = 60
maintenance_start = "03:59:30"
maintenance_end   = "04:10:30"


while True:
	
	# メンテナンス時間は稼働しない
	st = datetime.strptime(maintenance_start,"%H:%M:%S").strftime("%H:%M:%S")
	et = datetime.strptime(maintenance_end, "%H:%M:%S").strftime("%H:%M:%S")
	nt = datetime.now().strftime("%H:%M:%S")
		
	if nt>st and nt<et:
		print("メンテナンス時間中のため待機しています")
		time.sleep(wait)
		continue

現在の時刻が、メンテナンスの開始時刻(maintenance_start)と終了時刻(maintenance_end)の間に入っていれば、if文の中身が実行されます。continueは、それ以降の記述を実行せずに while文の先頭に戻る、という指示です。

このコード例では周期を60秒(wait=60)にしていますが、ここは各BOTに合わせて調整してください。

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

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

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

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

具体例

例)ある1時間足

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

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

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

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

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

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

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

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

実際のログ

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

▽ ログ

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

▽ 実際のBOTログ

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

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

解決策

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

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

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

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

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

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

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

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

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

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

バックテストコード


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

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

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

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

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

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

準備

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

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

1.固定額トレード

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

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

▽ 結果

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

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

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

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

2.定率トレード

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

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

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

▽ 結果

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

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

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

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

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

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

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

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

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

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

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

疑問に思うこと

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

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

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

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

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

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

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

1.扱うサンプルの違い

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

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

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

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

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

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

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

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

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

▽ 資産カーブ

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

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

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

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

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

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

▽ pythonコード


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

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

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

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

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

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

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

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


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


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

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

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

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



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

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

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

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

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

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

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

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

実行結果

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

▽ 資産カーブ

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

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

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

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

どう判断するかを考える

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

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

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

1.統計的な意思決定

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

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

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

(漠然とした気持ち)

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

(統計的意思決定)

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

2.損失関数をつくろう

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


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

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

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


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

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

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

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

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

実行結果

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

注意点

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

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

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

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

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

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

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

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

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

疑問

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

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

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

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

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

 

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

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

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

pythonコード

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

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

▽ csvファイルに集計

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

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

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

▽ pythonコード


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

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

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

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

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

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

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

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

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

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

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

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

	plt.grid()
	plt.legend()


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

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

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

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

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

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

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

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

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

実行結果

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

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

 

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

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

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

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

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

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

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

▽ pythonコード


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

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

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

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

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

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

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

▽ pythonコード


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

▽ 適当な対数正規分布


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

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

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

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

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

有益な情報

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

スリッページの計算方法

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

▽ コードの例


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

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

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

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

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

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

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

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

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

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

3)スリッページの集計

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


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

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

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

4)集計コードの作り方

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

▽ 分析用のコード


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


 

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

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

スリッページの影響

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

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

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

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

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

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

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

手順

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

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

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

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

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

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

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

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

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

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

▽ トークン発行画面

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

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

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

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

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

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

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

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


import requests

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

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

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

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

LINE通知の実装例

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


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

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


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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

全体の流れ

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

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

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

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

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

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

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

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

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


import time
while True:

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


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

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

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


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

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

while True:

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


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

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

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

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

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

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

ログ機能の説明の補足

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

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

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

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

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

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

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


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

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

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

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


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

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

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

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

実行手順

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

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

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

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

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

実行結果

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

6時間おきの実行方法

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

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

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

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

まとめ

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

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

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

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

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

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

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

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

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

Pythonコード

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


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

	# 略
	if data["result"][str(min)] is not None:
		for i in data["result"][str(min)]:
			price.append({ "close_time" : i[0],
				"close_time_dt" : datetime.fromtimestamp(i[0]).strftime('%Y/%m/%d %H:%M'),
				"open_price" : i[1],
				"high_price" : i[2],
				"low_price" : i[3],
				"close_price": i[4],
				"volume": i[5] })  # 追記
		return price

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


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

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

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

検証してみよう!

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

検証条件

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

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

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


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

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

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

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


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

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

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

比較表

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

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

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

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

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

ストップを使わない場合

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

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

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

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

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

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

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

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


import random

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


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

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

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

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

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

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

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

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

フィルターの罠

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

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

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

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

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

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

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

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

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

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

Pythonコード

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


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

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

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

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


# バックテストの集計用の関数
def backtest(flag):
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"],
		"Volume"   :  flag["records"]["volume"] # 追記
	})

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

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

	# (略)

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

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

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

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

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

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

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

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

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

▽ 通常のストップのみ

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

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

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

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

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

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

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

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

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

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

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

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

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

t検定とは

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

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

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

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

 

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

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

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

Pythonでt検定する方法

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

▽p値を計算するコード


from scipy import stats
import numpy as np

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

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

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

コードの解説

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

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

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

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

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

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

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


from scipy import stats
import numpy as np

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

	# (略)

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

簡単ですね。

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

実行結果

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

p値: 0.34626205887421146

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

p値 : 0.4575055281798647

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

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

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

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

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

p値 : 0.007921851375299075

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

p値 : 0.012742691982286171

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

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

補足

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

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

今回使ったコード

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

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

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


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


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

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

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

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

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

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

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

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



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

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



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

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

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


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



# 平均ボラティリティを計算する関数
def calculate_volatility( last_data ):

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


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


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



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

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

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



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


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

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


#-------------売買ロジックの部分の関数--------------

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


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

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

	return flag



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


	if flag["position"]["side"] == "SELL":
		if signal["side"] == "BUY":
			flag["records"]["log"].append("過去{0}足の最高値{1}円を、直近の価格が{2}円でブレイクしました\n".format(buy_term,signal["price"],data[judge_price["BUY"]]))
			flag["records"]["log"].append(str(data["close_price"]) + "円あたりで成行注文を出してポジションを決済します\n")
			
			# 決済の成行注文コードを入れる
			
			records( flag,data,data["close_price"] )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			flag["position"]["stop-AF"] = stop_AF
			flag["position"]["stop-EP"] = 0
			flag["add-position"]["count"] = 0
			
			
			# ドテン注文の箇所
			flag = filter( flag )
			
			lot,stop,flag = calculate_lot( last_data,data,flag )
			if lot > 0.01:
				flag["records"]["log"].append("\n{0}円で{1}BTCの買いの注文を入れてドテンします\n".format(data["close_price"],lot))
				
				# ここに買い注文のコードを入れる
				
				flag["records"]["log"].append("{0}円にストップを入れます\n".format(data["close_price"] - stop))
				flag["position"]["lot"],flag["position"]["stop"] = lot,stop
				flag["position"]["exist"] = True
				flag["position"]["side"] = "BUY"
				flag["position"]["price"] = data["close_price"]
			
	return flag



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

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


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


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



# バックテストの集計用の関数
def backtest(flag):
	
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"],
		"Volume"   :  flag["records"]["volume"]
	})
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 資産推移の列を追加する
	records["Funds"] = records.Gross + start_funds
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Funds.cummax().subtract(records.Funds)
	records["DrawdownRate"] = round(records.Drawdown / records.Funds.cummax() * 100,1)
	
	# フィルター有無別のトレードをそれぞれ抽出する
	high_vol_records = records[records.Volume.isin(["high_volume"])]
	low_vol_records = records[records.Volume.isin(["low_volume"])]
	
	
	
	print("バックテストの結果")
	print("-----------------------------------")
	print("出来高が多かったときの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(high_vol_records) ))
	print("勝率               :  {}%".format(round(len(high_vol_records[high_vol_records.Profit>0]) / len(high_vol_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(high_vol_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( high_vol_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(high_vol_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( high_vol_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("出来高が少なかったときの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(low_vol_records) ))
	print("勝率               :  {}%".format(round(len(low_vol_records[low_vol_records.Profit>0]) / len(low_vol_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(low_vol_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( low_vol_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(low_vol_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( low_vol_records.Stop.sum() ))
	
	
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])
	
	
	
	# T検定
	sample_a = high_vol_records.Rate.values
	sample_b = low_vol_records.Rate.values
	print("------------------------------------------")
	print("t検定を実行")
	print("------------------------------------------")
	p = stats.ttest_ind(sample_a, sample_b, equal_var = False)
	print("p値 : {}".format(p[1]))
	
	
	
	# 「出来高が多いとき」のリターン分布図
	plt.subplot(2,1,1)
	plt.hist( high_vol_records.Rate,50,rwidth=0.9)
	plt.xlim(-15,45)
	plt.axvline( x=0,linestyle="dashed",label="Return = 0" )
	plt.axvline( high_vol_records.Rate.mean(), color="orange", label="AverageReturn" )
	plt.legend()
	

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


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

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

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


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

	# ドンチャンの判定に使う期間分の安値・高値データを準備する
	if len(last_data) < need_term:
		last_data.append(price[i])
		flag = log_price(price[i],flag)
		time.sleep(wait)
		i += 1
		continue
	
	data = price[i]
	flag = log_price(data,flag)
	
	# ポジションがある場合
	if flag["position"]["exist"]:
		if stop_config != "OFF":
			flag = stop_position( data,flag )
		flag = close_position( data,last_data,flag )
		flag = add_position( data,flag )
	
	# ポジションがない場合
	else:
		flag = entry_signal( data,last_data,flag )
	
	last_data.append( data )
	i += 1
	time.sleep(wait)


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

backtest(flag)

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

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

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

基本的な仕様

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

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

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

Pythonコード

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


import requests
from datetime import datetime

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

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

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

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

APIの形式

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

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

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

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

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

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

期間の指定

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

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

1時間足の注意点

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

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

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

API使用回数の制限

昔はなかったのですが、現在はCryptocompareのAPIの無料版には使用回数の制限があるみたいです。
BOTユーザー様に提供しただいたAPIのログ情報によると、2021年3月時点では、以下のようになっているようです。

(cryptocompareの使用制限)

1カ月あたり 5万回まで
1日当たり7500回まで
1時間あたり3000回まで
1分あたり300回まで
1秒あたり 20回まで

 
1分間に1回使用する程度なら、月間46300回なのでおそらく問題ありません。30秒に1回だと月間86400回で、おそらくオーバーします。いずれも1日あたりの使用回数には問題ありません。

APIリミットのエラー

上記コードの data を print() してみるとわかりますが、APIの使用制限にかかっている場合、Cryptocompareからは以下のような返り値があります。

RateLimitに注目してください。
calls_made が、あなたが直近でAPIを使用した回数。
max_calls が、許容される最大の使用回数です。


{'Data': {},
 'HasWarning': False,
 'Message': 'You are over your rate limit please upgrade your account!',
 'RateLimit': {'calls_made': {'day': ***,
                              'hour': ***,
                              'minute': ***,
                              'month': ***,
                              'second': ***,
                              'total_calls': ***},
               'max_calls': {'day': 7500,
                             'hour': 3000,
                             'minute': 300,
                             'month': 50000,
                             'second': 20}},

APIの使用制限にかかっていない場合、RateLimit の戻り値は空データなので、あと何回までAPIが使用できるかはわかりません。
公式ページの説明によると、もし無料アカウントを開設すると、以下のようにダッシュボード形式でAPIの使用回数や残りの回数が見れるようになるようです。

▽ Cryptocompareアカウントの管理画面

 

(参考)

Cryptocompare公式 「APIのRateLimitとは何ですか?」
Cryptocompare公式「自身のAPIリミットをどうやって確認できますか?」
Cryptocompare公式ガイド「CryptocompareAPIの使い方」

API経由で残りの使用回数を確認する

以下のAPIを確認することで、残りのAPIの使用回数(APIリミット)を確認できるみたいです。
こちらのAPIを使って使用頻度を調整するようにBOTを作成してもいいかもしれません。残りの使用可能な回数(calls_left)を取得できるので使いやすそうです。

https://min-api.cryptocompare.com/stats/rate/limit