この章ではバックテスト編で作成した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)