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

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

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

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

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

手順

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

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

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

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

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

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

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

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

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

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

▽ トークン発行画面

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

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

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

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

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

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

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

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


import requests

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

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

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

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

LINE通知の実装例

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


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

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


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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

全体の流れ

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

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

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

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

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

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

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

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

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


import time
while True:

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


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

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

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


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

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

while True:

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


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

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

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

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

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

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

ログ機能の説明の補足

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

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

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

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

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

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

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


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

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

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

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


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

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

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

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

実行手順

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

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

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

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

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

実行結果

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

6時間おきの実行方法

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

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

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

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

まとめ

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

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

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

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

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

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

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

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

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

Pythonコード

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


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

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

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


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

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

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

検証してみよう!

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

検証条件

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

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

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


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

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

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

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


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

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

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

比較表

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

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

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

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

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

ストップを使わない場合

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

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

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

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

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

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

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

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


import random

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


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

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

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

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

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

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

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

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

フィルターの罠

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

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

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

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

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

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

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

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

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

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

Pythonコード

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


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

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

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

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


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

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

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

	# (略)

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

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

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

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

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

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

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

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

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

▽ 通常のストップのみ

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

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

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

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

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

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

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

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

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

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

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

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

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

t検定とは

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

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

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

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

 

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

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

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

Pythonでt検定する方法

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

▽p値を計算するコード


from scipy import stats
import numpy as np

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

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

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

コードの解説

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

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

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

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

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

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

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


from scipy import stats
import numpy as np

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

	# (略)

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

簡単ですね。

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

実行結果

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

p値: 0.34626205887421146

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

p値 : 0.4575055281798647

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

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

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

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

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

p値 : 0.007921851375299075

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

p値 : 0.012742691982286171

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

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

補足

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

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

今回使ったコード

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

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

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


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


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

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

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

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

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

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

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

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



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

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



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

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

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


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



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

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


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


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



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

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

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



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


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

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


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

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


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

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

	return flag



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


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



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

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


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


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



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

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


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

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

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


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

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


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

backtest(flag)

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

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

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

基本的な仕様

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

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

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

Pythonコード

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


import requests
from datetime import datetime

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

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

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

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

APIの形式

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

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

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

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

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

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

期間の指定

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

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

1時間足の注意点

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

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

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

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

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

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

フィルターの目的

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

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

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

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

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

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

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

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

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

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

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

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

検証条件

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

フィルター無しの場合

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

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

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

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

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

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

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

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

Pythonコード

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


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

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

検証結果

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

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

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

比較

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

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

次にいきましょう。

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

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

Pythonコード

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


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

検証結果


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

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

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

比較

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

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

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

▽ 5月19日22時の確定足

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

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

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

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

参考情報

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

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

143項より

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

▽ 検証結果


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

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

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

比較

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

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

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

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

▽ 5月6日7時の確定足

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

Pythonコード


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

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

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

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

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

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

使用する移動平均線

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

▽ 検証結果

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

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

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

比較

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

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

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

Pythonコード


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

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

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

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

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

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

具体的なケース(1)

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

以下の場面です。

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

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

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

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

具体的なケース(2)

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

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

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

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

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

まとめ

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

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

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

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


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

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

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

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

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

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


# エントリー注文を出す関数
def entry_signal( data,last_data,flag ):
	signal = donchian( data,last_data )
	
	if signal["side"] == "BUY":
		flag["records"]["log"].append("過去{0}足の最高値{1}円を、直近の価格が{2}円でブレイクしました\n".format(buy_term,signal["price"],data[judge_price["BUY"]]))
		
		# フィルター条件を確認
		if filter( signal ) == False:
			flag["records"]["log"].append("フィルターのエントリー条件を満たさなかったため、エントリーしません\n")
			return flag

		# 以下同じ

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

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

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

BitflyerのEMAの注意点

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

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

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

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

今回勉強したコード

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



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


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

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

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

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

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

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

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

filter_VER = "A"           # OFFで無効


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


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

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

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


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



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

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


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


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



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

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


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

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

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



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


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

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


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

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


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

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

	return flag



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


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



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

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


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


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



# バックテストの集計用の関数
def backtest(flag):
	
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 連敗回数をカウントする
	consecutive_defeats = []
	defeats = 0
	for p in flag["records"]["profit"]:
		if p < 0:
			defeats += 1
		else:
			consecutive_defeats.append( defeats )
			defeats = 0
	
	# テスト日数を集計
	time_period = datetime.fromtimestamp(last_data[-1]["close_time"]) - datetime.fromtimestamp(last_data[0]["close_time"])
	time_period = int(time_period.days)
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 資産推移の列を追加する
	records["Funds"] = records.Gross + start_funds
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Funds.cummax().subtract(records.Funds)
	records["DrawdownRate"] = round(records.Drawdown / records.Funds.cummax() * 100,1)
	
	# 買いエントリーと売りエントリーだけをそれぞれ抽出する
	buy_records = records[records.Side.isin(["BUY"])]
	sell_records = records[records.Side.isin(["SELL"])]
	
	# 月別のデータを集計する
	records["月別集計"] = pd.to_datetime( records.Date.apply(lambda x: x.strftime('%Y/%m')))
	grouped = records.groupby("月別集計")
	
	month_records = pd.DataFrame({
		"Number"   :  grouped.Profit.count(),
		"Gross"    :  grouped.Profit.sum(),
		"Funds"    :  grouped.Funds.last(),
		"Rate"     :  round(grouped.Rate.mean(),2),
		"Drawdown" :  grouped.Drawdown.max(),
		"Periods"  :  grouped.Periods.mean()
		})
	
	print("バックテストの結果")
	print("-----------------------------------")
	print("買いエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(buy_records) ))
	print("勝率               :  {}%".format(round(len(buy_records[buy_records.Profit>0]) / len(buy_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(buy_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( buy_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(buy_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( buy_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("売りエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(sell_records) ))
	print("勝率               :  {}%".format(round(len(sell_records[sell_records.Profit>0]) / len(sell_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(sell_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( sell_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(sell_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( sell_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("総合の成績")
	print("-----------------------------------")
	print("全トレード数       :  {}回".format(len(records) ))
	print("勝率               :  {}%".format(round(len(records[records.Profit>0]) / len(records) * 100,1)))
	print("平均リターン       :  {}%".format(round(records.Rate.mean(),2)))
	print("標準偏差           :  {}%".format(round(records.Rate.std(),2)))
	print("平均利益率         :  {}%".format(round(records[records.Profit>0].Rate.mean(),2) ))
	print("平均損失率         :  {}%".format(round(records[records.Profit<0].Rate.mean(),2) ))
	print("平均保有期間       :  {}足分".format( round(records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( records.Stop.sum() ))
	print("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	print("最大連敗回数       :  {}回".format( max(consecutive_defeats) ))
	print("最大ドローダウン   :  {0}円 / {1}%".format(-1 * records.Drawdown.max(), -1 * records.DrawdownRate.loc[records.Drawdown.idxmax()]  ))
	print("利益合計           :  {}円".format( records[records.Profit>0].Profit.sum() ))
	print("損失合計           :  {}円".format( records[records.Profit<0].Profit.sum() ))
	print("最終損益           :  {}円".format( records.Profit.sum() ))
	print("")
	print("初期資金           :  {}円".format( start_funds ))
	print("最終資金           :  {}円".format( records.Funds.iloc[-1] )) 
	print("運用成績           :  {}%".format( round(records.Funds.iloc[-1] / start_funds * 100),2 ))
	print("手数料合計         :  {}円".format( -1 * records.Slippage.sum() ))
	
	print("-----------------------------------")
	print("各成績指標")
	print("-----------------------------------")
	print("CAGR(年間成長率)         :  {}%".format( round((records.Funds.iloc[-1] / start_funds)**(  365 / time_period ) * 100 - 100,2)   ))
	print("MARレシオ                :  {}".format(round( (records.Funds.iloc[-1] / start_funds -1)*100 / records.DrawdownRate.max(),2 )))
	print("シャープレシオ           :  {}".format( round(records.Rate.mean()/records.Rate.std(),2) ))
	print("プロフィットファクター   :  {}".format( round(records[records.Profit>0].Profit.sum()/abs(records[records.Profit<0].Profit.sum()),2) ))
	print("損益レシオ               :  {}".format(round( records[records.Profit>0].Rate.mean()/abs(records[records.Profit<0].Rate.mean()) ,2)))
	
	print("-----------------------------------")
	print("月別の成績")
	
	for index , row in month_records.iterrows():
		print("-----------------------------------")
		print( "{0}年{1}月の成績".format( index.year, index.month ) )
		print("-----------------------------------")
		print("トレード数         :  {}回".format( row.Number.astype(int) ))
		print("月間損益           :  {}円".format( row.Gross.astype(int) ))
		print("平均リターン       :  {}%".format( row.Rate ))
		print("継続ドローダウン   :  {}円".format( -1 * row.Drawdown.astype(int) ))
		print("月末資金           :  {}円".format( row.Funds.astype(int) ))
	
	
	# 際立った損益を表示
	n = 10
	print("------------------------------------------")
	print("+{}%を超えるトレードの回数  :  {}回".format(n,len(records[records.Rate>n]) ))
	print("------------------------------------------")
	for index,row in records[records.Rate>n].iterrows():
		print( "{0}  |  {1}%  |  {2}".format(row.Date,round(row.Rate,2),row.Side ))
	print("------------------------------------------")
	print("-{}%を下回るトレードの回数  :  {}回".format(n,len(records[records.Rate< n*-1]) ))
	print("------------------------------------------")
	for index,row in records[records.Rate < n*-1].iterrows():
		print( "{0}  |  {1}%  |  {2}".format(row.Date,round(row.Rate,2),row.Side  ))
	
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])
	
	
	# 損益曲線をプロット
	plt.subplot(1,2,1)
	plt.plot( records.Date, records.Funds )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	
	# リターン分布の相対度数表を作る
	plt.subplot(1,2,2)
	plt.hist( records.Rate,50,rwidth=0.9)
	plt.axvline( x=0,linestyle="dashed",label="Return = 0" )
	plt.axvline( records.Rate.mean(), color="orange", label="AverageReturn" )
	plt.legend() # 凡例を表示
	plt.show()
	


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

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

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


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

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


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

backtest(flag)

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

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

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

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

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

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

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


import requests
from datetime import datetime

# 使用する時間足
chart_sec = 3600

# CryptowatchでBTCFXの価格データを取得
def get_price(min, before=0, after=0):
	price = []
	params = {"periods" : min }
	if before != 0:
		params["before"] = before
	if after != 0:
		params["after"] = after
	response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc",params)
	data = response.json()
	
	if data["result"][str(min)] is not None:
		for i in data["result"][str(min)]:
			price.append({ "close_time" : i[0],
				"close_time_dt" : datetime.fromtimestamp(i[0]).strftime('%Y/%m/%d %H:%M'),
				"open_price" : i[1],
				"high_price" : i[2],
				"low_price" : i[3],
				"close_price": i[4] })
	return price

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


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

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

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

Pythonコード


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

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

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


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

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


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

実行結果

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

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

コードの解説

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

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

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

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

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


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

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

ではやってみましょう!


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

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

実行結果

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

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

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

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

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

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

EMAの計算式

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

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

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

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

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

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

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

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

Pythonコード

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


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

コードの解説

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

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

(例)20期間EMAの場合

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

(例)50期間EMAの場合

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

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

実行してみよう!

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


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

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


実行結果

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

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

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

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


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

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

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


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

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

実行結果

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

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

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

BOTで運用するとき

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

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

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

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

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

▽ 引用 (記事リンク

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

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

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

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

今回使ったコード


import requests
from datetime import datetime

# 使用する時間足
chart_sec = 3600

# CryptowatchでBTCFXの価格データを取得
def get_price(min, before=0, after=0):
	price = []
	params = {"periods" : min }
	if before != 0:
		params["before"] = before
	if after != 0:
		params["after"] = after
	response = requests.get("https://api.cryptowat.ch/markets/bitflyer/btcfxjpy/ohlc",params)
	data = response.json()
	
	if data["result"][str(min)] is not None:
		for i in data["result"][str(min)]:
			price.append({ "close_time" : i[0],
				"close_time_dt" : datetime.fromtimestamp(i[0]).strftime('%Y/%m/%d %H:%M'),
				"open_price" : i[1],
				"high_price" : i[2],
				"low_price" : i[3],
				"close_price": i[4] })
	return price


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

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

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

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

運用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で出力する方法を紹介して、最後の記事とする予定です。個人的にはそれが最も実用的な破産確率表の使い方ではないかと思います。