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

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

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

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

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

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

 

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

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

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

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

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

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

買いエントリーの場合

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

売りエントリーの場合

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

例題

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

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

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

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

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

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

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

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

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

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

▽ pythonコード


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

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



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

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

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


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

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

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

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

コードの解説

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

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

ストップ幅の計算と更新

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


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

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

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

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

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

手仕舞いの関数

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


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

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

5.実行結果

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

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

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

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

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

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

売買ロジック

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

資金管理

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

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

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

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

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

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

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

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

-設定値

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

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

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

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

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

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

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

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

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

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

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

他の係数の検証

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

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

-設定値

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


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

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

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

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

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

-設定値

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


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

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

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

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

5.今回勉強したコード



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


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

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

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

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

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

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

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


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

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

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


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



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

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



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

# 注文ロットを計算する関数
def calculate_lot( last_data,data,flag ):

	# 口座残高を取得する
	balance = flag["records"]["funds"]

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



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


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

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


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

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


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

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

	return flag



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


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



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

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


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


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



# バックテストの集計用の関数
def backtest(flag):
	
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 連敗回数をカウントする
	consecutive_defeats = []
	defeats = 0
	for p in flag["records"]["profit"]:
		if p < 0:
			defeats += 1
		else:
			consecutive_defeats.append( defeats )
			defeats = 0
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 資産推移の列を追加する
	records["Funds"] = records.Gross + start_funds
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Funds.cummax().subtract(records.Funds)
	records["DrawdownRate"] = round(records.Drawdown / records.Funds.cummax() * 100,1)
	
	# 買いエントリーと売りエントリーだけをそれぞれ抽出する
	buy_records = records[records.Side.isin(["BUY"])]
	sell_records = records[records.Side.isin(["SELL"])]
	
	# 月別のデータを集計する
	records["月別集計"] = pd.to_datetime( records.Date.apply(lambda x: x.strftime('%Y/%m')))
	grouped = records.groupby("月別集計")
	
	month_records = pd.DataFrame({
		"Number"   :  grouped.Profit.count(),
		"Gross"    :  grouped.Profit.sum(),
		"Funds"    :  grouped.Funds.last(),
		"Rate"     :  round(grouped.Rate.mean(),2),
		"Drawdown" :  grouped.Drawdown.max(),
		"Periods"  :  grouped.Periods.mean()
		})
	
	print("バックテストの結果")
	print("-----------------------------------")
	print("買いエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(buy_records) ))
	print("勝率               :  {}%".format(round(len(buy_records[buy_records.Profit>0]) / len(buy_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(buy_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( buy_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(buy_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( buy_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("売りエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(sell_records) ))
	print("勝率               :  {}%".format(round(len(sell_records[sell_records.Profit>0]) / len(sell_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(sell_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( sell_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(sell_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( sell_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("総合の成績")
	print("-----------------------------------")
	print("全トレード数       :  {}回".format(len(records) ))
	print("勝率               :  {}%".format(round(len(records[records.Profit>0]) / len(records) * 100,1)))
	print("平均リターン       :  {}%".format(round(records.Rate.mean(),2)))
	print("平均保有期間       :  {}足分".format( round(records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( records.Stop.sum() ))
	print("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	print("最大連敗回数       :  {}回".format( max(consecutive_defeats) ))
	print("最大ドローダウン   :  {0}円 / {1}%".format(-1 * records.Drawdown.max(), -1 * records.DrawdownRate.loc[records.Drawdown.idxmax()]  ))
	print("利益合計           :  {}円".format( records[records.Profit>0].Profit.sum() ))
	print("損失合計           :  {}円".format( records[records.Profit<0].Profit.sum() ))
	print("最終損益           :  {}円".format( records.Profit.sum() ))
	print("")
	print("初期資金           :  {}円".format( start_funds ))
	print("最終資金           :  {}円".format( records.Funds.iloc[-1] )) 
	print("運用成績           :  {}%".format( round(records.Funds.iloc[-1] / start_funds * 100),2 )) 
	print("手数料合計         :  {}円".format( -1 * records.Slippage.sum() ))
	
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])

	# 損益曲線をプロット
	plt.plot( records.Date, records.Funds )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	plt.show()
	

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

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

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


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

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


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

backtest(flag)

BitflyerFXの自動売買BOTにトレイリングストップを実装してみよう!

ブレイクアウト戦略では、通常、勝率の悪さをカバーするために出来るだけ大きな利益を狙うため、利益目標(利確ライン)を設定しません。その代わりに利益を取り逃がさないようにトレイリングストップを設定することがあります。

1.トレイリングストップとは

トレイリングストップとは、エントリー後の含み益を逃がさないように、利益が乗るにつれて、最初に設定した損切ラインを少しずつエントリーした方向に動かしていくテクニックのことをいいます。

例えば、上値ブレイクアウトで買いエントリーし、当初はエントリー価格の2レンジ(ATR)幅下に損切りを設定していたとします。この場合、ブレイクアウトに成功して1レンジ上に値が動くたびに、ストップ位置も1レンジずつ上にずらしていきます。

トレイリング(trailing)は英語で「引きずる」という意味です。価格に引きずられるようにストップ位置が動いていくため、トレイリングストップといいます。

2.具体的なロジックの実装

今回は以下のようなルールを定義します。

1)損切りラインはエントリーと同じ方向にのみ動かせる
2)価格(終値)がエントリー方向に1レンジ進むごとに、損切りラインをmレンジ動かす(0≦m≦1)
3)どこまで価格を追いかけるかを選択できるようにする

一般論としてトレイリングストップには、トレンドによる含み益を逃がさない効果があります。

ただしあまりにストップの位置を価格に近づけすぎると、一時的な押し目で退出させられてしまい、本来の売買ロジックが持つパフォーマンスを発揮できなくなります。売買ロジックの期待値を最大限に活かすためには、できるだけ余計な条件を足さない方が得策です。

そのため、価格と損切ラインを同じだけ動かすのではなく、価格が1レンジ動くたびに損切ラインは1/4~1/2レンジだけ動かすなど、より緩いトレイリングの設定ができるようにしておきます(この設定値が上記の2です)。

またどこまでも価格を追従するのではなく、最初のエントリー価格の位置(損益が±0になるライン)までだけストップを動かし、それ以降は追従しないような設定もできるようにしておきましょう。

3.トレイリング・ストップのPythonコード

では実際にpythonコードを作りましょう。


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

stop_range = 2                # 最初に何レンジ幅にストップを入れるか
trail_ratio = 0.5             # 価格が1レンジ動くたびに何レンジ損切り位置をトレイルするか
trail_until_breakeven = True  # 損益ゼロの位置までしかトレイルしない

# trail_ratio は 0~1 の範囲でのみ設定可
# trail_ratio を 0 に設定するとトレイリングストップを無効にできます


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

	# ポジションの追加取得(増し玉)が終わるまでは何もしない
	if flag["add-position"]["count"] < entry_times:
		return flag

	last_stop = flag["position"]["stop"] # 前回のストップ幅
	first_stop = flag["position"]["ATR"] * stop_range # 最初のストップ幅
	
	# 終値がエントリー価格からいくら離れたか計算する
	if flag["position"]["side"] == "BUY" and data["close_price"] > flag["position"]["price"]:
		moved_range = round(data["close_price"] - flag["position"]["price"])
	elif flag["position"]["side"] == "SELL" and data["close_price"] < flag["position"]["price"]:
		moved_range = round(flag["position"]["price"] - data["close_price"])
	else:
		moved_range = 0
	

	# 動いたレンジ幅に合わせてストップ位置を更新する
	if moved_range > flag["position"]["ATR"]:
		number = int(np.floor(moved_range / flag["position"]["ATR"]))
		flag["position"]["stop"] = round(first_stop - ( number * flag["position"]["ATR"] * trail_ratio ))
		
	# 損益0ラインまでしかトレイルしない場合
	if trail_until_breakeven and flag["position"]["stop"] < 0:
		flag["position"]["stop"] = 0
	
	# ストップがエントリー方向と逆に動いたら更新しない
	if flag["position"]["stop"] > last_stop:
		flag["position"]["stop"] = last_stop
	
	# ストップが動いた場合のみログ出力
	if flag["position"]["stop"] != last_stop:
		if flag["position"]["side"] == "BUY":
			flag["records"]["log"].append("トレイリングストップの発動:ストップ位置を{}円に動かします\n".format( round(flag["position"]["price"] - flag["position"]["stop"]) ))
		else:
			flag["records"]["log"].append("トレイリングストップの発動:ストップ位置を{}円に動かします\n".format( round(flag["position"]["price"] + flag["position"]["stop"]) ))
	
	return flag

新しいローソク足(終値)を取得するたびに、上記の関数でトレイリングストップの値幅を計算します。トレイリングストップの値幅は、以下の式で計算します。

1)最初のストップ幅 = エントリー時の平均ボラティリティ(ATR) × ストップレンジ
2)トレイリング・ストップの値幅 = 最初のストップ幅 - (動いたレンジ数 × ATR × トレイリング率)

1)トレイリング率

トレイリング率(trail_ratio)とは、さきほどの定義ルールのところで説明したm値のことです。「1」に設定すると、動いた価格と同じ値幅分だけストップ位置を動かします。「0.5」を設定すると、動いた価格の半分だけストップ位置を動かします。

1以上を設定すると価格をストップ位置が追い越してしまうので、1以上を設定することはありえません。そのため、メイン処理に以下のようなコードを書いておきましょう。

# トレイリングの比率に0~1以上の数値を設定できないようにする
if trail_ratio > 1:
	trail_ratio = 1
elif trail_ratio < 0:
	trail_ratio = 0

2)損益ゼロのラインの計算

上記の計算式でトレイリングストップの値幅を計算すると、買いエントリーでトレイリングストップの位置がエントリー価格より上になった場合(または売りエントリーでトレイリングストップの位置がエントリー価格より下になった場合)に、ストップ幅がマイナスの値になります。

そのため、損益ゼロのラインまでしかトレイリングしたくない場合には、ストップ幅が0未満かどうかを判定して、0未満であれば0を代入します。


# 設定項目
trail_until_breakeven = True 

# 損益0ラインまでしかトレイルしない場合
if trail_until_breakeven and flag["position"]["stop"] < 0:
	flag["position"]["stop"] = 0

これを応用すると、含み益の最大値の50%を守る位置にトレイリングストップを置くこともできます。この方法は後述します。

3)ストップ位置をエントリー方向にのみ動かす

一般論として、トレイリングストップには片方向(エントリーと同じ方向)にしか動かしてはいけないというルールがあります。逆向きに動かしていいのであれば、いつまでたっても損切りラインにかからず、損切ラインを動かす意味がないからです。

そのため、最初に前回のストップ幅を記録しておき、新しく計算したストップ幅と比較します。ストップ幅はどんどん小さくならないといけないので、前回の数値よりも大きければ、前回のストップ幅をそのまま採用します。


# 前回のストップ幅
last_stop = flag["position"]["stop"]

# ストップがエントリー方向と逆に動いたら更新しない
if flag["position"]["stop"] > last_stop:
	flag["position"]["stop"] = last_stop

損切りの関数

トレイリングストップの関数が完成したら、あとは以前の記事で作成した「損切りの関数」の先頭で、このトレイリングストップの関数を呼ぶだけです。


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

これで基本となるコードは完成です!

4.実行結果

これを実行してみると、以下のようにストップ位置が移動しているのがわかります。

含み益の50%を守るトレイリングストップ

上記のコードをカスタマイズすると、常に含み益の50%を守る位置にトレイリングストップを置くこともできます。

買いエントリーの場合は直近の高値、売りエントリーの場合は直近の安値をベースに moved_range を計算して、その半分(moved_range × 0.5)をトレイリングストップ幅に代入します。


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

	# 高値/安値 がエントリー価格からいくら離れたか計算
	if flag["position"]["side"] == "BUY" and data["high_price"] > flag["position"]["price"]:
		moved_range = round(data["high_price"] - flag["position"]["price"])
	elif flag["position"]["side"] == "SELL" and data["low_price"] < flag["position"]["price"]:
		moved_range = round(flag["position"]["price"] - data["low_price"])
	else:
		moved_range = 0

	# 動いたレンジ幅に合わせてストップ位置を更新する
	if moved_range > flag["position"]["ATR"]:
		number = int(np.floor(moved_range / flag["position"]["ATR"]))
		flag["position"]["stop"] = round(first_stop - ( number * flag["position"]["ATR"] * trail_ratio ))

	# 損益ゼロラインを超えたら、含み益の1/2を守る位置にストップを置く
	if flag["position"]["stop"] < 0:
		flag["position"]["stop"] = round( moved_range * 0.5 ) * -1
	
	# ストップがエントリー方向と逆に動いたら更新しない
	if flag["position"]["stop"] > last_stop:
		flag["position"]["stop"] = last_stop
	# 以下略

エントリー価格を超えた位置にストップを置く場合は、ストップ幅がマイナスの値になりますので、-1を掛けるのを忘れないようにしましょう。

これを実行すると、買いエントリーの場合は高値を更新するたびに、売りエントリーの場合は安値を更新するたびに、トレイリングストップをピーク時の含み益の50%の位置に置くことができます。

5.検証してみよう!

ではトレイリングストップを採用した場合に、運用成績がどうなるか検証してみましょう。検証には前回の記事と同じ条件を使います。

売買ロジック

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

資金管理

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

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


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

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

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

2)価格が1レンジ動くたびにストップ位置を1レンジ動かした場合

平均ボラティリティ(ATR)を基準に、価格がエントリー方向に1レンジ動くたびにストップ位置も同じだけ動かし、損切りにかかるかドテンするまでトレイリングし続けた場合の成績です。

-設定値
trail_ratio = 1
trail_until_breakeven = False


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  180回
勝率               :  36.7%
平均リターン       :  -0.25%
平均保有期間       :  18.9足分
損切りの回数       :  169回

最大の勝ちトレード :  109787円
最大の負けトレード :  -20211円
最大連敗回数       :  9回
最大ドローダウン   :  -95210円 / -15.0%
利益合計           :  1233897円
損失合計           :  -807878円
最終損益           :  426019円

初期資金           :  300000円
最終資金           :  726019円
運用成績           :  242.0%

3)含み益の50%を保護するようにストップを動かした場合

損益ゼロのラインに達するまでは、価格が1レンジ動くごとにストップを1レンジ動かし、損益ゼロラインに達した後は、ピーク時の含み益の半分を保護する位置に常にトレイリングストップを置いた場合の成績です。


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  187回
勝率               :  36.4%
平均リターン       :  -0.36%
平均保有期間       :  18.1足分
損切りの回数       :  177回

最大の勝ちトレード :  140071円
最大の負けトレード :  -20211円
最大連敗回数       :  9回
最大ドローダウン   :  -95108円 / -14.8%
利益合計           :  1185148円
損失合計           :  -848540円
最終損益           :  336608円

初期資金           :  300000円
最終資金           :  636608円
運用成績           :  212.0%

4)価格が1レンジ動くたびにストップ位置を1/2レンジ動かした場合

価格が1レンジ動くたびに、ストップ位置は1/2ずつだけ同じ方向に動かした場合の成績です。

-設定値
trail_ratio = 0.5
trail_until_breakeven = False


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  112回
勝率               :  34.8%
平均リターン       :  1.52%
平均保有期間       :  41.4足分
損切りの回数       :  78回

最大の勝ちトレード :  722466円
最大の負けトレード :  -116576円
最大連敗回数       :  10回
最大ドローダウン   :  -624599円 / -16.9%
利益合計           :  5924759円
損失合計           :  -2146820円
最終損益           :  3777939円

初期資金           :  300000円
最終資金           :  4077939円
運用成績           :  1359.0%

5)損益±0のラインまでだけトレイリングした場合

価格が1レンジ動くたびにストップ位置は1/2ずつだけ同じ方向に動かし、損切りラインがエントリー価格と同じライン(損益分岐点)に達したラインでトレイリングを辞めた場合の成績です。

-設定値
trail_ratio = 0.5
trail_until_breakeven = True


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

最大の勝ちトレード :  636840円
最大の負けトレード :  -102862円
最大連敗回数       :  10回
最大ドローダウン   :  -550789円 / -16.9%
利益合計           :  5266126円
損失合計           :  -1970726円
最終損益           :  3295400円

初期資金           :  300000円
最終資金           :  3595400円
運用成績           :  1198.0%

結論

結果が良かった順に並べると以下のようになりました。

1)トレイリング率 1/2 で損切りラインを動かした場合
2)何もしなかった場合(トレイリングストップ無)
3)トレイリング率 1/2 で損益±0ラインまで動かした場合
4)価格と同じ比率で損切りラインを動かした場合
5)含み益の50%を保護するよう損切りラインを動かした場合

トレイリングの条件を厳しくしすぎると、パフォーマンスは悪化する結果となりました。やはり損切りのラインを厳しくすることで、本来の売買ロジックが持つ期待値や優位性(エッジ)が失われてしまうのかもしれません。

ただしトレイリング比率によっては、「何もしなかった場合」を上回る成績を残したパターンもありました。

練習問題

今回の記事では、価格の動きに従って一定の比率で損切りラインを動かすトレイリングストップの作り方を勉強しました。しかしこの方法は実際のところあまり実践的ではありません。

トレイリングの比率を上げすぎると序盤で損切りにかかってしまい、退出させられてしまう可能性が高くなりますし、比率を下げ過ぎると、今度は価格と損切りラインがどんどん乖離してしまい、「トレンドの含み益を逃がさない」という本来の役割を果たせなくなってしまうからです。

そこで以下の記事では、より実践的なアイデアとして、「長くポジションを保有すればするほど、どんどんトレイリング比率が加速していく」ようなトレイリングストップを紹介します。ぜひ定義を読んで自分でも実装してみてください。

次回記事:パラボリックSARを使って加速するトレイリングストップを作ろう!

5.今回勉強したコード


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


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

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

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

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

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

trail_ratio = 0.5          # 価格が1レンジ動くたびに何レンジ損切り位置をトレイルするか
trail_until_breakeven = True  # 損益ゼロの位置までしかトレイルしない

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



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

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

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


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



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

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



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

# 注文ロットを計算する関数
def calculate_lot( last_data,data,flag ):

	# 口座残高を取得する
	balance = flag["records"]["funds"]

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



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


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

	# まだ追加ポジションの取得中であれば何もしない
	if flag["add-position"]["count"] < entry_times:
		return flag

	last_stop = flag["position"]["stop"] # 前回のストップ幅
	first_stop = flag["position"]["ATR"] * stop_range # 最初のストップ幅
	
	# エントリー価格からいくら離れたか計算する
	if flag["position"]["side"] == "BUY" and data["close_price"] > flag["position"]["price"]:
		moved_range = round(data["close_price"] - flag["position"]["price"])
	elif flag["position"]["side"] == "SELL" and data["close_price"] < flag["position"]["price"]:
		moved_range = round(flag["position"]["price"] - data["close_price"])
	else:
		moved_range = 0
	
	
	# 動いたレンジ幅に合わせてストップ位置を更新する
	if moved_range > flag["position"]["ATR"]:
		number = int(np.floor(moved_range / flag["position"]["ATR"]))
		flag["position"]["stop"] = round(first_stop - ( number * flag["position"]["ATR"] * trail_ratio ))
		
	# 損益0ラインまでしかトレイルしない場合
	if trail_until_breakeven and flag["position"]["stop"] < 0:
		flag["position"]["stop"] = 0
	
	# ストップがエントリー方向と逆に動いたら更新しない
	if flag["position"]["stop"] > last_stop:
		flag["position"]["stop"] = last_stop
	
	# ログ出力
	if flag["position"]["stop"] != last_stop:
		if flag["position"]["side"] == "BUY":
			flag["records"]["log"].append("トレイリングストップの発動:ストップ位置を{}円に動かします\n".format( round(flag["position"]["price"] - flag["position"]["stop"]) ))
		else:
			flag["records"]["log"].append("トレイリングストップの発動:ストップ位置を{}円に動かします\n".format( round(flag["position"]["price"] + flag["position"]["stop"]) ))
	
	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["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["add-position"]["count"] = 0
			
			
			lot,stop,flag = calculate_lot( last_data,data,flag )
			if lot > 0.01:
				flag["records"]["log"].append("\n{0}円で{1}BTCの買いの注文を入れてドテンします\n".format(data["close_price"],lot))
				
				# ここに買い注文のコードを入れる
				
				flag["records"]["log"].append("{0}円にストップを入れます\n".format(data["close_price"] - stop))
				flag["position"]["lot"],flag["position"]["stop"] = lot,stop
				flag["position"]["exist"] = True
				flag["position"]["side"] = "BUY"
				flag["position"]["price"] = data["close_price"]
			
	return flag



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

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


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


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



# バックテストの集計用の関数
def backtest(flag):
	
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 連敗回数をカウントする
	consecutive_defeats = []
	defeats = 0
	for p in flag["records"]["profit"]:
		if p < 0:
			defeats += 1
		else:
			consecutive_defeats.append( defeats )
			defeats = 0
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 資産推移の列を追加する
	records["Funds"] = records.Gross + start_funds
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Funds.cummax().subtract(records.Funds)
	records["DrawdownRate"] = round(records.Drawdown / records.Funds.cummax() * 100,1)
	
	# 買いエントリーと売りエントリーだけをそれぞれ抽出する
	buy_records = records[records.Side.isin(["BUY"])]
	sell_records = records[records.Side.isin(["SELL"])]
	
	# 月別のデータを集計する
	records["月別集計"] = pd.to_datetime( records.Date.apply(lambda x: x.strftime('%Y/%m')))
	grouped = records.groupby("月別集計")
	
	month_records = pd.DataFrame({
		"Number"   :  grouped.Profit.count(),
		"Gross"    :  grouped.Profit.sum(),
		"Funds"    :  grouped.Funds.last(),
		"Rate"     :  round(grouped.Rate.mean(),2),
		"Drawdown" :  grouped.Drawdown.max(),
		"Periods"  :  grouped.Periods.mean()
		})
	
	print("バックテストの結果")
	print("-----------------------------------")
	print("買いエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(buy_records) ))
	print("勝率               :  {}%".format(round(len(buy_records[buy_records.Profit>0]) / len(buy_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(buy_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( buy_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(buy_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( buy_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("売りエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(sell_records) ))
	print("勝率               :  {}%".format(round(len(sell_records[sell_records.Profit>0]) / len(sell_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(sell_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( sell_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(sell_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( sell_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("総合の成績")
	print("-----------------------------------")
	print("全トレード数       :  {}回".format(len(records) ))
	print("勝率               :  {}%".format(round(len(records[records.Profit>0]) / len(records) * 100,1)))
	print("平均リターン       :  {}%".format(round(records.Rate.mean(),2)))
	print("平均保有期間       :  {}足分".format( round(records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( records.Stop.sum() ))
	print("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	print("最大連敗回数       :  {}回".format( max(consecutive_defeats) ))
	print("最大ドローダウン   :  {0}円 / {1}%".format(-1 * records.Drawdown.max(), -1 * records.DrawdownRate.loc[records.Drawdown.idxmax()]  ))
	print("利益合計           :  {}円".format( records[records.Profit>0].Profit.sum() ))
	print("損失合計           :  {}円".format( records[records.Profit<0].Profit.sum() ))
	print("最終損益           :  {}円".format( records.Profit.sum() ))
	print("")
	print("初期資金           :  {}円".format( start_funds ))
	print("最終資金           :  {}円".format( records.Funds.iloc[-1] )) 
	print("運用成績           :  {}%".format( round(records.Funds.iloc[-1] / start_funds * 100),2 )) 
	print("手数料合計         :  {}円".format( -1 * records.Slippage.sum() ))
	
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])

	# 損益曲線をプロット
	plt.plot( records.Date, records.Funds )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	plt.show()
	

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

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

flag = {
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"stop":0,
		"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":[]
	}
}


# トレイリングの比率に0~1以上の数値を設定できないようにする
if trail_ratio > 1:
	trail_ratio = 1
elif trail_ratio < 0:
	trail_ratio = 0

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

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


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

backtest(flag)

1回のトレードの許容資金を使って分割エントリー(増し玉)してみよう!

前回の記事では、「1回のトレードで口座の何%までの損失(リスク)を許容できるか?」から逆算して、ポジションサイズを計算する方法を紹介しました。

これを応用すると、1回のトレードで取れるリスクの範囲から逆算して数回に分けてポジションを取得することもできます。いわゆる「増し玉」(ピラミッティング)のことですね。

今回は、1度に許容範囲の全額でポジションを取るのではなく、数回に分けてポジションを取った場合のパフォーマンスを検証してみましょう。

1.今回検証すること

今回はドンチャン・チャネルブレイクアウト戦略で以下の3つのエントリー方法をテストしてみましょう。

1)ブレイクアウトの時点で許容リスク全額分の注文を出す
2)ブレイクアウトの時点で半分だけポジションを取り、その後、ブレイクアウトの方向に1ATR進んだらもう半分のポジションを取る
3)ブレイクアウトの時点では1/4だけポジションを取り、その後、ブレイクアウトの方向に1/2ATR進むたびに、1/4ずつ追加ポジションを取る

2)と3)を図にすると以下のような感じです。

チャネルブレイクアウトについて詳しく解説している「タートル流投資の魔術」という有名な本のトレーディング規則では、1トレードの資金を4つのユニットに分割して、基準レンジ(1/2ATR)ごとにポジションを追加する方法(3のパターン)が、実際に紹介されています。

今回の検証では、ブレイクアウトの地点から2レンジ以上離れる前に全ポジションを取得しますが、何レンジ離れるごとに追加ポジションを取得するかは、あとから自由に変更できるように変数にしておきましょう。

2.実装の仕組みの考え方

複数回に分けてエントリーする場合でも、前回の記事と同じように「理論上のサイズ」と「実際に購入可能なサイズ」の両方を計算する必要があります。

1)理論サイズの計算

理論サイズの方は、簡単に計算できます。口座資金のうち1トレードで許容できる損失の割合(n%)を、分割でエントリーしたい回数で割ればいいだけだからです。

例えば、1トレードで失ってもいい口座資金の割合が2%で、2回に分けてエントリーしたいなら、各トレードのリスク量を1%にして計算します。4回に分けてエントリーするなら0.5%で計算します。

こちらの計算式の中にBTC価格は含まれていません。そのため、何回に分けてエントリーしたとしても、こちらの「理論上の計算サイズ」はすべて同じになります。

例えば、以下の条件を仮定しましょう。

・口座資金50万円
・平均ボラティリティ(ATR)1万円
・損切レンジ2ATR
・1トレードでの許容リスク 口座の4%
・分割エントリーの回数 4回

この場合、4回のエントリーの各ポジションサイズは、以下の式で決まります。

・(50万円 × 0.04 ÷ 4) ÷ 2万円 = 0.25枚

2)実際に購入可能なサイズの計算

一方、「実際に購入可能なサイズ」の方は、ブレイクアウト後の価格の変動によっては、最初と同じ枚数を取得できなくなる可能性があります。特にレバレッジの比率が低い場合はそうです。

例えば、先ほどと同じ条件で1/2ATRごとに4回に分けてポジションを取得するケースを考えてみましょう。レバレッジは2倍、ブレイクアウト時のBTC価格は100万円とします。

・口座資金50万円
・レバレッジ2倍
・平均ボラティリティ(ATR)1万円
・BTC価格 100万円
・分割エントリーの回数 4回

この場合、各ポジションの取得時の価格は以下のようになるはずです。

・1回目・・・100万円(0.25枚)
・2回目・・・100万5000円(0.25枚)
・3回目・・・101万円(0.25枚)
・4回目・・・101万5000円(0.24枚)

このとき、各ポジション取得時の計算式は以下のようになります。

・(証拠金残高 – 既存のポジションの必要証拠金) × レバレッジ倍率 ÷ BTC価格

前回の記事と同様に、各ポジションを追加取得する際に、この「理論上の注文サイズ」と「実際に注文可能なサイズ」の両方を計算して、より小さい方のサイズで発注します。

3.バックテスト用のコード

ではバックテスト用のコードを作ってみましょう。

1)設定項目

今回のコードでは、以下の2つの項目を設定できるようにします。

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

この例であれば、1ATRごとに2回に分けてポジションを取ります。
つまりブレイクアウト時に半分だけポジションを取り、さらに価格がブレイクアウト方向に1ATR進んだら、もう半分のポジションを取ります。

2)pythonコード

では実際にpythonのコードを書いていきましょう。

前回までに作成してきた関数やコードをあまり修正しなくていいように、追加ポジションの取得に関しては、全く別の変数と関数を用意することにします。

まずは以下のような変数を作りましょう。


# ["add-position"]という項目を追加
flag = {
	"add-position":{
		"count":0, # 何回目のエントリーかを管理
		"first-entry-price":0, # 最初のエントリー価格を記録
		"last-entry-price":0,  # 前回(n-1)回目のエントリー価格を記録
		"unit-range":0, # 何円ごとに買い増し(ポジション追加)するかを記録
		"unit-size":0,  # 1回あたりの理論上のポジションサイズを記録
		"stop":0        # 最初のエントリー時のストップ幅を記録
	},
	(略)
}

前述のように、各追加ポジションの理論上のサイズ(unit-size)と、何円幅ごとに追加ポジションを取得するかを示す基準レンジ(unit-range)の2つは、最初のブレイクアウトの時点で計算できます。

そのため、最初にこの2つを計算して上記の変数にセットするようにします。

あとは、新しいローソク足の終値を取得するたびに、最初のエントリー価格(first-entry-price)からどれだけ離れたかを計算して、基準レンジ(unit-range)以上に離れていたら、追加ポジションを取得する、という流れで実装していきます。

ストップ位置の更新

なお、ストップ(損切り)のラインは、追加ポジションを取得するたびに、その平均取得単価を基準にして、全ポジションの損切りラインを移動させていくことにします。例えば、以下のような感じです。

・最初のエントリー価格 100万円
・平均ボラティリティ(ATR) 1万円
・損切レンジ 2ATR

・1回目のエントリー 100万円
・2回目のエントリー 100万5000円
・3回目のエントリー 101万円
・4回目のエントリー 101万5000円

最初に設定した許容リスクを超えないように、どの時点でも平均取得単価の2レンジ下で損切りが入るようストップ位置を更新していきます。

・1回目の平均取得単価 100万円 / ストップ 98万円
・2回目の平均取得単価 100万2500円 / ストップ 98万2500円
・3回目の平均取得単価 100万5000円 / ストップ 98万5000円
・4回目の平均取得単価 100万7500円 / ストップ 98万7500円

損切りの計算に使う平均ボラティリティ(ATR)は、追加ポジションの取得のたびに再計算するのではなく、ブレイクアウト時に計算したものを使い回します。そのため、上記の[add-position]には、ストップ幅を記録する変数も用意しておきます。

約定価格の問題

今回のバックテストの検証では、すべて理想通りの価格で約定したものと仮定します。

例えば、上記の例なら、1時間足の終値が101万5000円を超えた時点で、各ポジションは 100万円/100万5000円/101万円/101万5000円でそれぞれ順調に約定したと仮定して検証します。

ただし実際の運用では、リアルタイムで形成される足(または短い時間軸の足)を使って成行注文を出すことになるので、もう少し不利な価格で約定することになります。そのため、バックテストでも約定価格にスリッページを考慮できるようにしておきます。

※ 逆指値注文で運用する場合は、前回までのコードは使えず、根本からプログラムの設計を作り直す必要があります。その方法は、この記事では解説しません。

3)注文サイズを計算する関数

# 注文ロットを計算する関数
def calculate_lot( last_data,data,flag ):

	# 口座残高を取得する
	balance = flag["records"]["funds"]

	# 最初のエントリーの場合
	if flag["add-position"]["count"] == 0:
		
		# 1回の注文単位と基準レンジを計算する
		volatility = calculate_volatility( last_data )
		stop = stop_range * volatility
		calc_lot = np.floor( balance * trade_risk / stop * 100 ) / 100
		
		flag["add-position"]["unit-size"] = np.floor( calc_lot / entry_times * 100 ) / 100
		flag["add-position"]["unit-range"] = round( volatility * entry_range )
		flag["add-position"]["stop"] = stop
		
		flag["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

まずは注文サイズを計算する関数です。
前回の記事で作成したコードを、複数回の分割エントリーに対応できるよう修正しました。

3行目

ブレイクアウトの判定がされた場合、実際にエントリー注文を出す前に必ずこの「注文ロットを計算する関数」が呼ばれます。そのため、ここで最初に基準となる平均ボラティリティ(ATR)を1度だけ計算します。

さらに先ほど準備したflag変数に「理論上の注文サイズ」「基準レンジ」「ストップ幅」をまとめてセットします。最初のエントリーかどうかは、flag[add-position][count]で判定します。

13行目

# 2回目以降のエントリーの場合
	else:
		balance = round( balance - flag["position"]["price"] * flag["position"]["lot"] / levarage )

2回目以降のエントリーの場合は、注文に利用可能な証拠金の額を再計算しなければなりません。

注文に利用可能な証拠金とは、現在の証拠金残高から、すでに持っているポジションの必要証拠金を引いた金額です。この数字を balance に上書きしてから、「実際に購入可能なサイズ(able_lot)」を計算します。

あとは前回と同じように、「理論上の注文サイズ(unit-size)」と「実際に購入可能なサイズ(able_lot)」を比較して、より小さい方を返します。

4)複数回に分けてポジションを取る関数


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

			if flag["position"]["side"] == "BUY":
				flag["records"]["log"].append("{0}円の位置にストップを更新します\n".format(flag["position"]["price"] - stop))
			elif flag["position"]["side"] == "SELL":
				flag["records"]["log"].append("{0}円の位置にストップを更新します\n".format(flag["position"]["price"] + stop))

			flag["records"]["log"].append("現在のポジションの取得単価は{}円です\n".format(flag["position"]["price"]))
			flag["records"]["log"].append("現在のポジションサイズは{}BTCです\n\n".format(flag["position"]["lot"]))
			
			flag["add-position"]["count"] += 1
			flag["add-position"]["last-entry-price"] = entry_price
	
	return flag

上記のコードの判定方法のロジックは以下です。

判定ロジック

1)前回のエントリー価格を last_entry_price に入れる
2)現在の終値を取得して current_price に入れる
3)両者の差が基準レンジ(unit_range)以上離れていれば、追加ポジションを取る
4)平均建値と合計サイズを計算して、flag["position"] を更新する
5)追加ポジションの執行価格を last_entry_priceに入れる

この1)~5)までをWhile文でループし、3)の条件を満たさなくなったら、break してWhile文を抜けます。以下で具体例を見てみましょう。

例)
・最初のエントリー価格 100万円
・基準レンジ 5000円
・前回(2回目)のエントリー価格 100万5000円
・今回の終値 101万2000円

⇒ 101万円で3回目のポジションを追加取得したと仮定する

例2)
・最初のエントリー価格 100万円
・基準レンジ 5000円
・前回(2回目)のエントリー価格 100万5000円
・今回の終値 102万5000円

⇒ 101万円で3回目、101万5000円で4回目のポジションを追加取得したと仮定する

should_add_position という変数を作って、3)の箇所の条件の判定をしています。また例2)のように、1期間足の間に2回以上の買い増しをしている可能性もあるので、全体のロジックをWhile文で囲っています。

※ 実践でBOTを運用する際には、While文で囲む必要はありません。

4-1)エントリー価格

ではもう少し詳しく分解してみてみましょう。
エントリー価格を計算して注文を出す箇所のコードを抜粋してみます。

# 追加注文を出す
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))
	
	# ここに売り注文のコードを入れる
	

前述のように、バックテストでは理想通りの価格で約定したと仮定しています。

・理想の約定価格 = 最初のエントリー価格 + (基準レンジ × n回目のエントリー)

そのため、バックテスト用の約定価格は、以下のような計算式になっています。ただし下の式では、一応、約定価格にスリッページの影響を考慮できるようにしてあります。

	entry_price = first_entry_price + (flag["add-position"]["count"] * unit_range) 
	#entry_price = round((1 + slippage) * entry_price)

本番でBOTを運用する際には、実際の約定価格(例えば、成行注文の執行価格)を entry_price に代入すればOKです。

4-2)ポジションの平均取得価格を更新

次に、flag[position]を更新する箇所のコードを見ておきましょう。

flag[position]は、今までの記事で見てきたように、現在のポジションの価格とサイズを記録するための変数です。こちらは、複数の建玉の管理を意識しなくていいように、追加ポジションを取得するたびに「合計サイズ」と「平均建値」を計算し、その数字だけを記録するようにします。

こうしておけば、複数回に分けてエントリーする場合でも、今まで作成した手仕舞いや損切りのコードに手を加える必要がなくなります。


# ポジション全体の情報を更新する
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

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

5)全体のメインループの箇所

最後に全体のメイン処理の部分を見ておきましょう。

いままでの全体ループでは、ポジションがある場合に「手仕舞いを判定する関数」「損切りを判定する関数」を順番に呼んでいました。この後ろに「追加ポジションを取得する関数」を付け足します。


while i < len(price):

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

これで今まで作成したpythonコードを「複数回に分けてのエントリー」に対応させることができました。

6)手仕舞いや損切りの箇所

最後に、ポジションを手仕舞ったり、損切りをするたびに、flag[add-position][count]をリセットするようにしておきます。これ以外に、手仕舞いの関数・損切りの関数を変更する必要はありません。

このflag[add-position][count]が唯一、今までのコードと今回新しく作成した関数を繋ぐ架け橋になる部分です。


# ポジションを手仕舞ったり損切した後

records( flag,data )
flag["position"]["exist"] = False
flag["position"]["count"] = 0
flag["add-position"]["count"] = 0 # 追記

以上でバックテスト用のコードは完成です!

実行結果

これを実行すると、以下のような感じでポジションの積み増しがシミュレーションできます。

▽ 出力ログファイル

実際のパフォーマンスは後ほど確認しましょう!

4.Bitflyer用のコード

ついでに、このコードをBitflyerFX(実践)で使えるようにする方法も見ておきましょう。今回、作成・修正した2つの関数は、成行注文を出す設計であれば、そのまま実践用のコードとして使えます。

置き換える必要があるのは、以下の2点だけです。

1)証拠金残高から購入可能サイズを計算する部分
2)約定価格を entry_price にセットする部分

1)証拠金残高の計算

証拠金残高の計算には、前回の記事と同じく「GET /v1/me/getcollateral/」というBitflyerの公式APIを使います。

Bitflyerの公式APIドキュメント

「getcollateral」というAPIは、collateral で現在の証拠金評価額、require_collateral で現在のポジション(建玉)を維持するための必要証拠金、を返します。

・collateral ... 現在の証拠金評価額
・require_collateral ... 建玉の必要証拠金

そのため、collateral から require_collateral を差し引くことで、注文に利用可能な証拠金を計算できます。


# 注文に利用可能な証拠金を取得する関数
def bitflyer_collateral():
	while True:
		try:
			collateral = bitflyer.private_get_getcollateral()
			
			# 追加注文で利用可能な証拠金を計算
			spendable_collateral = np.floor(collateral["collateral"] - collateral["require_collateral"])
			print("現在、新規注文に利用可能な証拠金の額は{}円です".format( int(spendable_collateral) ))
			return int( spendable_collateral )
			
		except ccxt.BaseError as e:
			print("BitflyerのAPIでの口座残高取得に失敗しました : ", e)
			print("20秒待機してやり直します")
			time.sleep(20)


# 注文ロットを計算する関数
def calculate_lot( last_data,data,flag ):

	# 口座残高を取得する
	balance = bitflyer_collateral()

	# 最初のエントリーの場合
	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["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"] ))
	
	# ストップ幅には、最初のエントリー時に計算したボラティリティを使う
	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

2)約定価格の取得

前述のように、バックテストでは理想の価格で約定したものと仮定してその価格を計算しましたが、実践用のコードでは、成行注文を出した上で、その執行価格をAPIで取得して、それを entry_price にセットする必要があります。

・成行注文の執行価格を取得する方法

5.検証結果

それでは、ドンチャン・チャネルブレイクアウトで複数回に分けてポジションを取得した場合のパフォーマンスを検証してみましょう。今回は以下のような前提条件を使います。

売買ロジック

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

資金管理

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

また追加ポジションの取得はすべて目標価格から0.1% 不利な方向に滑って約定したと仮定します。(slippage = 0.001)

1回で全ポジションを取得した場合

まずはブレイクアウトの時点で、許容額の限界まで1度にポジションを取得した場合のパフォーマンスを見てみます。


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  110回
勝率               :  39.1%
平均リターン       :  1.8%
平均保有期間       :  46.6足分
損切りの回数       :  52回

最大の勝ちトレード :  1277503円
最大の負けトレード :  -244647円
最大連敗回数       :  10回
最大ドローダウン   :  -1876398円 / -32.6%
利益合計           :  11429600円
損失合計           :  -6537527円
最終損益           :  4892073円

初期資金           :  300000円
最終資金           :  5192073円
運用成績           :  1731.0%

約9カ月で17倍(1731%)の運用成績ですから、かなり良い成績です。
しかし口座の4%のリスクを取るだけでも、ドローダウンは30%以上とかなり大きくなりました。許容リスクの割合を維持したまま、もう少しドローダウンを小さくできないでしょうか?

2回に分けてポジションを取得した場合

では次に、2回に分けてポジションを取得する場合を考えてみましょう。ブレイクアウトの時点では半分だけポジションを取り、その後、ブレイクアウトの方向に1レンジ進んだらもう半分のポジションを取ります。

-設定値
entry_times = 2
entry_range = 1


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

最大の勝ちトレード :  1336007円
最大の負けトレード :  -245197円
最大連敗回数       :  10回
最大ドローダウン   :  -1497132円 / -26.0%
利益合計           :  10776540円
損失合計           :  -5001125円
最終損益           :  5775415円

初期資金           :  300000円
最終資金           :  6075415円
運用成績           :  2025.0%

約9カ月の運用成績は20倍(2025%)と、先程よりパフォーマンスが向上しています。さらに最大ドローダウンも小さくなったため、資産グラフも先ほどより安定しています。

一方、平均リターンや勝率はさきほどより悪くなっているようです。

4回に分けてポジションを取得した場合

では4回に分けてポジションを取得する場合を見てみましょう。
ブレイクアウトの時点では許容リスクの1/4を上限にポジションを取り、その後、ブレイクアウトの方向に1/2レンジ進むたびに1/4ずつポジションを追加した場合です。

-設定値
entry_times = 4
entry_range = 0.5


-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  114回
勝率               :  30.7%
平均リターン       :  0.86%
平均保有期間       :  43.2足分
損切りの回数       :  64回

最大の勝ちトレード :  1435541円
最大の負けトレード :  -254950円
最大連敗回数       :  10回
最大ドローダウン   :  -1143648円 / -19.7%
利益合計           :  10508068円
損失合計           :  -4105824円
最終損益           :  6402244円

初期資金           :  300000円
最終資金           :  6702244円
運用成績           :  2234.0%


4回に分けてエントリーすると、勝率・平均リターンはともに最低となりましたが、一方で運用成績は2234%となり今までで一番いい結果となりました! また最大ドローダウンも-19.7%と今までで最小となっています。

考察

チャネルブレイクアウトのようなトレンドフォロー戦略の場合、複数回に分けてポジションを取ると、その分だけ平均建値の面では不利になります。勝率や平均リターンなどの指標が悪化しているのは、おそらくそのためです。

平均建値が不利になると、許容リスクとの関係から損切りにかかる可能性も高くなります。4回に分割してエントリーするパターンでは、全トレード114回のうち63回が損切りに掛かっています。

一方で、ブレイクアウトに失敗した場合の損失は(サイズを抑えているため)比較的小さく済みます。つまりリターンの良いときに大きいポジションを持ち、リターンの悪いときに小さいポジションを持っているため、全体としてみれば、パフォーマンスが向上し、かつドローダウンを小さく抑えることができています。

この傾向を他の条件でも確認するために、検証期間を変えた場合、口座の許容リスクを5%にした場合、異なる時間軸(2時間足)を使用した場合、などもテストしてみましょう。

その他の条件テスト(1)

・1時間足を使用(2018/1~2018/5)
・口座の許容リスク3%

分割回数 1回 2回 4回
勝率 35.5% 32.3% 30.2%
平均リターン 1.41% 0.92% 0.65%
最大DD -25.7% -19.9% -15.0%
利益合計 1115589円 1071247円 1141549円
損失合計 -655812円 -495360円 -430326円
最終資金 759777円 875887円 1011223円
運用成績 253% 292% 337%

その他の条件テスト(2)

・1時間足を使用(2017/8~2018/5)
・口座の許容リスク5%

分割回数 1回 2回 4回
勝率 39.1% 34.2% 30.7%
平均リターン 1.8% 1.21% 0.87%
最大DD -37.8% -31.4% -24.1%
利益合計 18070662円 17389476円 17210344円
損失合計 -11052685円 -8769789円 -7316265円
最終資金 7317977円 8919687円 10194079円
運用成績 2439% 2973% 3398%

その他の条件テスト(3)

・2時間足を使用(2017/3~2018/5)
・口座の許容リスク3%

分割回数 1回 2回 4回
勝率 34.0% 33.7% 31.7%
平均リターン 2.22% 1.71% 1.16%
最大DD -22.4% -18.4% -15.2%
利益合計 6671857円 6773418円 7252069円
損失合計 -3813601円 -2969508円 -2858596円
最終資金 3158256円 4103910円 4693473円
運用成績 1053% 1368% 1564%

このように、どの期間や時間軸、口座のリスク率を用いてテストしても、分割エントリーをした方が、(勝率や平均リターンは下がるものの)運用成績が向上するという傾向が見られました。

やはり「タートル流」の積み増しエントリーはBTCFXのチャネルブレイクアウトでも機能するようです。

6.今回の勉強で使用したコード




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


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

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

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

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

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

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



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

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

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


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



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

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



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


# 注文ロットを計算する関数
def calculate_lot( last_data,data,flag ):

	# 口座残高を取得する
	balance = flag["records"]["funds"]

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



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


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


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



# バックテストの集計用の関数
def backtest(flag):
	
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 連敗回数をカウントする
	consecutive_defeats = []
	defeats = 0
	for p in flag["records"]["profit"]:
		if p < 0:
			defeats += 1
		else:
			consecutive_defeats.append( defeats )
			defeats = 0
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 資産推移の列を追加する
	records["Funds"] = records.Gross + start_funds
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Funds.cummax().subtract(records.Funds)
	records["DrawdownRate"] = round(records.Drawdown / records.Funds.cummax() * 100,1)
	
	# 買いエントリーと売りエントリーだけをそれぞれ抽出する
	buy_records = records[records.Side.isin(["BUY"])]
	sell_records = records[records.Side.isin(["SELL"])]
	
	# 月別のデータを集計する
	records["月別集計"] = pd.to_datetime( records.Date.apply(lambda x: x.strftime('%Y/%m')))
	grouped = records.groupby("月別集計")
	
	month_records = pd.DataFrame({
		"Number"   :  grouped.Profit.count(),
		"Gross"    :  grouped.Profit.sum(),
		"Funds"    :  grouped.Funds.last(),
		"Rate"     :  round(grouped.Rate.mean(),2),
		"Drawdown" :  grouped.Drawdown.max(),
		"Periods"  :  grouped.Periods.mean()
		})
	
	print("バックテストの結果")
	print("-----------------------------------")
	print("買いエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(buy_records) ))
	print("勝率               :  {}%".format(round(len(buy_records[buy_records.Profit>0]) / len(buy_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(buy_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( buy_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(buy_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( buy_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("売りエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(sell_records) ))
	print("勝率               :  {}%".format(round(len(sell_records[sell_records.Profit>0]) / len(sell_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(sell_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( sell_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(sell_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( sell_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("総合の成績")
	print("-----------------------------------")
	print("全トレード数       :  {}回".format(len(records) ))
	print("勝率               :  {}%".format(round(len(records[records.Profit>0]) / len(records) * 100,1)))
	print("平均リターン       :  {}%".format(round(records.Rate.mean(),2)))
	print("平均保有期間       :  {}足分".format( round(records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( records.Stop.sum() ))
	print("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	print("最大連敗回数       :  {}回".format( max(consecutive_defeats) ))
	print("最大ドローダウン   :  {0}円 / {1}%".format(-1 * records.Drawdown.max(), -1 * records.DrawdownRate.loc[records.Drawdown.idxmax()]  ))
	print("利益合計           :  {}円".format( records[records.Profit>0].Profit.sum() ))
	print("損失合計           :  {}円".format( records[records.Profit<0].Profit.sum() ))
	print("最終損益           :  {}円".format( records.Profit.sum() ))
	print("")
	print("初期資金           :  {}円".format( start_funds ))
	print("最終資金           :  {}円".format( records.Funds.iloc[-1] )) 
	print("運用成績           :  {}%".format( round(records.Funds.iloc[-1] / start_funds * 100),2 )) 
	print("手数料合計         :  {}円".format( -1 * records.Slippage.sum() ))
	
	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) ))
	
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])

	# 損益曲線をプロット
	plt.plot( records.Date, records.Funds )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	plt.show()
	

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

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

flag = {
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"stop":0,
		"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):
#while i < 500:

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


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

backtest(flag)

証拠金から最適なポジションサイズを自動的に計算してBitflyerに注文を出そう

前回の記事の続きです。

適切な損切りラインを設定したら、そのストップ幅を基準にポジションサイズを計算して注文を出すコードを書いてみましょう。

1.ポジションサイズの計算式

この章の最初の記事で説明したように、ポジションサイズ(枚数)の計算式には、以下を使うことにします。

例えば、以下のような感じです。

1)理論上のポジションサイズの計算例

・証拠金残高 30万円
・1トレードで失ってもいい口座の割合 3%
・1時間足の平均ボラティリティ(ATR) 9000円
・損切ライン 2ATR

この場合、次のエントリー時の注文サイズは以下のようになります。

・理論上の注文サイズ = 30万円 × 0.03 ÷ ( 9000円 × 2 ) = 0.5枚

最大0.5枚までなら損切にかかっても口座資金の3%以上を失うことはない、ということですね。

2)実際に購入可能なポジションサイズの計算例

しかし一方で、「実際に購入可能な枚数」も計算しておく必要があります。実際に購入可能なサイズの計算式は以下です。

・証拠金残高 30万円
・レバレッジ設定 2倍
・エントリー時のBTC価格 104万円

・実際に購入可能なサイズ = 証拠金残高 × レバレッジ設定比率 ÷ BTC価格 = 0.58枚

このように、実際に購入可能な枚数というのはレバレッジ比率で決まります。そのため、BOTのプログラムでは、この両方のサイズを計算してどちらか「小さい方」で注文を出すという手順が必要になります。

2.注文サイズを計算するpythonコード

まずは注文サイズを計算するpythonコードを作ってみましょう。

なお、ここでは前回の記事で作成した「平均ボラティリティを計算する関数」calculate_volatility() を使うことを前提とします。


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

stop_range = 2     # 何レンジ幅にストップを入れるか
trade_risk = 0.03  # 1トレードあたり口座の何%まで損失を許容するか
levarage = 2       # レバレッジ倍率の設定

#--------------------------------

# 口座残高を取得する関数
def bitflyer_collateral():
	while True:
		try:
			collateral = bitflyer.private_get_getcollateral()
			print("現在のアカウント残高は{}円です".format( int(collateral["collateral"]) ))
			return int( collateral["collateral"] )
		except ccxt.BaseError as e:
			print("BitflyerのAPIでの口座残高取得に失敗しました : ", e)
			print("20秒待機してやり直します")
			time.sleep(20)

# 注文ロットを計算する関数
def calculate_lot( last_data,data,flag ):
	
	lot = 0	
	# 口座残高を取得する
	balance = bitflyer_collateral()
	
	# 1期間のボラティリティを基準にストップ位置を計算する
	volatility = calculate_volatility( last_data )
	stop = stop_range * volatility
	
	# 注文可能なロット数を計算する
	calc_lot = np.floor( balance * trade_risk / stop * 100 ) / 100
	able_lot = np.floor( balance * levarage / data["close_price"] * 100 ) / 100
	lot = min(able_lot,calc_lot)
	
	print("許容リスクから購入できる枚数は最大{}BTCまでです".format(calc_lot))
	print("証拠金から購入できる枚数は最大{}BTCまでです".format(able_lot))

	return lot,stop

コードの解説

まず最初に、bitflyer_collateral()という関数を作って、BitflyerのAPIで現在の証拠金残高を取得します。

現在の口座残高を調べるには「GET /v1/me/vetcollateral/」というAPIを使います。レスポンス結果のJSONデータから[“collateral”]を指定すると、証拠金を取得できます。

参考
CCXTライブラリでBitflyerに注文を出す方法をマスターする
Bitflyerの公式APIドキュメント

アカウント残高を取得したら、さきほどの計算式を使って「理論上の注文サイズ」(calc_lot)と「実際に購入可能なサイズ」(able_lot)をそれぞれ計算します。

Bitflyerの注文サイズは最小単位が0.01なので、100を掛けてから np.floor() を使って小数点以下を切り捨て、それをまた100で割っています。

calc_lot = np.floor( balance * trade_risk / stop * 100 ) / 100
able_lot = np.floor( balance * levarage / data["close_price"] * 100 ) / 100

※注) 最小単位と最低注文数量は違うので注意してください。BitflyerFXの最小単位は0.001ですが、最低注文単位は0.01です。例えば、0.011の注文は出せますが、0.001の注文は出せません。

計算が完了したら、min(A,B) でどちらか小さい方をロット数に設定して返します。

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

これに合わせてエントリー注文を出す関数の方も修正する必要があります。

具体的には、上記のcalculate_lot() から返ってきた注文数量が、最低注文単位に満たない場合には何もしない、という処理を加えます。前回までのドンチアン・チャネルブレイクアウトBOTのコードを以下のように修正しましょう。


# エントリー注文を出す関数
def entry_signal( data,last_data,flag ):

	signal = donchian( data,last_data )
	if signal["side"] == "BUY":
		print("過去{0}足の最高値{1}円を、直近の価格が{2}円でブレイクしました\n".format(buy_term,signal["price"],data[judge_price["BUY"]]))

		# ここから修正箇所
		lot,stop = calculate_lot( last_data,data,flag )
		if lot >= 0.01:
			print("{0}円あたりに{1}BTCで買いの注文を出します\n".format(data["close_price"],lot))
			
			# ここに買い注文のコードを入れる
			
			flag["order"]["lot"],flag["order"]["stop"] = lot,stop
			flag["order"]["exist"] = True
			flag["order"]["side"] = "BUY"
			flag["order"]["price"] = data["close_price"]
		else:
			print("注文可能枚数{}が、最低注文単位に満たなかったので注文を見送ります".format(lot))



上記を動かすにあたり、あらかじめflag変数にあたらしくロット数を管理する項目を作成しておく必要があります。

・flag[“order”][“lot”]
・flag[“position”][“lot”]

これは買いエントリーの箇所ですが、同じように売りエントリーの箇所やドテン注文の箇所にも修正を加えます。

バックテスト用の関数

バックテストの場合は、証拠金はBitflyerから取得するのではなく、自分で変数に口座残高を記録しておいて計算する必要があります。そのため、以下のようなコードになります。


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

stop_range = 2     # 何レンジ幅にストップを入れるか
trade_risk = 0.03  # 1トレードあたり口座の何%まで損失を許容するか
levarage = 2       # レバレッジ倍率の設定
start_funds = 300000 # シミュレーション時の初期資金

#--------------------------------

# 記録用の変数flagに新しくfundを追加する
flag = {
	(略)
	"records":{
		"funds" : start_funds, # 追加
	}
}


# 注文ロットを計算する関数
def calculate_lot( last_data,data,flag ):
	
	lot = 0	
	# 口座残高を取得する(バックテスト用)
	balance = flag["records"]["funds"]

	# printではなくログにする
	flag["records"]["log"].append("現在のアカウント残高は{}円です\n".format(balance))
	flag["records"]["log"].append("許容リスクから購入できる枚数は最大{}BTCまでです\n".format(calc_lot))
	flag["records"]["log"].append("証拠金から購入できる枚数は最大{}BTCまでです\n".format(able_lot))


今回は練習なのでこちらのコードを使います。

ついでに前回までのバックテスト検証用コードで初期資金の増減をシミュレーションできるようにコードを修正しておきましょう。変更点をまとめて列挙しておきます。

その他の変更点


# 各トレードのパフォーマンスを記録する関数
def records(flag,data):

	# 利益が出てるかの計算
	if flag["position"]["side"] == "BUY":
		flag["records"]["funds"] = flag["records"]["funds"] + buy_profit # 追加

	if flag["position"]["side"] == "SELL":
		flag["records"]["funds"] = flag["records"]["funds"] + sell_profit # 追加


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

	# 資産推移の列を追加
	records["Funds"] = records.Gross + start_funds # 追加

	# 最大ドローダウンの計算を資産ベースに変更
	records["Drawdown"] = records.Funds.cummax().subtract(records.Funds)
	records["DrawdownRate"] = round(records.Drawdown / records.Funds.cummax() * 100,1)

	print("バックテストの結果")
	print("-----------------------------------")
	print("総合の成績")
	print("-----------------------------------")

	# ドローダウンの%を資産ベースに変更
	print("最大ドローダウン   :  {0}円 / {1}%".format(-1 * records.Drawdown.max(),  -1 * records.DrawdownRate.loc[records.Drawdown.idxmax()]  ))

	# 初期資金が最終的に何倍になったかを追加
	print("初期資金           :  {}円".format( start_funds ))
	print("最終資金           :  {}円".format( records.Funds.iloc[-1] )) 
	print("運用成績           :  {}%".format( round(records.Funds.iloc[-1] / start_funds * 100),2 )) 

	print("-----------------------------------")
	print("月別の成績")
	
	for index , row in month_records.iterrows():
		print("-----------------------------------")
		print( "{0}年{1}月の成績".format( index.year, index.month ) )
		print("-----------------------------------")

		# 毎月の月末時点での資金額を追加
		print("月末資金           :  {}円".format( row.Funds.astype(int) ))


	# グラフのY軸を「総損益」から「資産推移」に変更
	plt.plot( records.Date, records.Funds )

では、これらのコードをすべて修正した上で、シミュレーション結果を検証してみましょう!

3.検証結果

まずは前回の記事と全く同じ前提条件を考えます。

基本条件

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

その上で、最初の資金を30万円で開始して1回あたりのトレードで取るリスク(= 口座残高の何%を賭けるか?)を変更していくと、結果がどう変わるかをシミュレーションしていきましょう。レバレッジは3倍までを上限とすると仮定します。

検証条件

・最初の軍資金 30万円
・レバレッジ3倍
・1回のトレードで取るリスクn%を変更

条件(1)口座の2%をリスクに晒す場合

一般によくトレードの入門書や教本には、1回のトレードで取るリスクは口座残高の1~2%がいいと記載されていますよね。有名な本「投資苑」の著者アレキサンダー・エルダー氏も、この2%ルールを推奨しています。

まずは毎回、口座残高の2%を賭けてトレードした場合の結果を検証してみましょう。

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

最大の勝ちトレード :  229799円
最大の負けトレード :  -34769円
最大ドローダウン   :  -287552円 / -17.9%

初期資金           :  300000円
最終資金           :  1548945円
運用成績           :  516.0%

もし30万円の資金でスタートしていたら、150万円にまで増えていたことがわかりました。8カ月で5倍になった計算ですからこれでも十分すぎる成績といえます。

ではもう少し1トレード当たりのリスクを取ってみるとどうなるでしょうか?

条件(2)口座の5%をリスクに晒す場合

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

最大の勝ちトレード :  2146354円
最大の負けトレード :  -442926円
最大ドローダウン   :  -3148210円 / -37.8%

初期資金           :  300000円
最終資金           :  7002507円
運用成績           :  2334.0%

今度は驚くべきパフォーマンスになりましたね!

運用成績は2334%(約23倍)で、当初の資金30万円は約700万円にまで増えました。 その代わりにドローダウンもかなり大きいですね。最大でピーク時の資金の38%を失っています。

詳しくはログファイルを見るとわかりますが、3月25日~4月2日の期間に11BTCのポジションを建てて1回のトレードで214万円の利益を上げています。さらにピーク時には最大832万円付近まで資金を増やしていますが、その後わずか2週間で10連敗し、300万円以上の資金を失っています。

▽ ピーク時のログ

▽ 連敗後のログ

当たり前ですが、トレード数や勝率・平均リターンなどは全く変わっていない点に注目してください。つまりこの成績の差は、純粋に「資金管理の方法」だけから生じているパフォーマンスの違いになります。

口座の何%のリスクを取るのが適切なのか?

このように1回のトレードで取るリスク(失ってもいい口座資金の割合)が大きいほど、資金が増えるのも爆発的に早くなりますが、1回の負けトレードで失うのも早くなります。

どのくらいの割合(%)が適切なのかは、資金量によって異なります。

資金が数十万円程度であれば、多少大きな損失を出しても、トレード以外の方法(= 労働・給料)で取り返すのにかかるコストや時間が相対的に小さいです。そのため、小資金の人ほどリスクを取りやすくなります。

例えば、1000万円を運用していて20%のドローダウンを受けるのと、30万円を運用していて20%のドローダウンを受けるのは全く意味が違います。後者は1~2カ月働けば取り返せるでしょうから、そこまで過度にリスク回避的になる必要はありません。

たまにBTCFXの世界では、「1万円でスタートして100倍にした!」といった実績を表明している方を見かけます。しかし「100万円を100倍にした!」と言ってる人は滅多に見かけません。これは少ない資金の人ほど1回のトレードで大きなリスクを取れることが理由です。

極端な話、1万円スタートであれば1回のトレードで口座の30%をリスクに晒しても別に構わないわけです。逆に数百万円以上の資金を運用するなら、1回のトレードの許容リスクは教科書通り1~3%に押さえるのが現実的です。

(例)
・初期資金 数十万円 n=5%~ で始める
・100万円を超えたら n=2~4%にする
・500万円を超えたら n=1~2%にする

※ nは1回のトレードで失ってもいい口座資金の割合

許容できる破産リスク

これは別の言い方をすると、運用資金の大きさによって許容できる破産リスクが違うということです。

30万円の資金で始める人なら、20%くらいの破産リスクを背負ってもいいからハイリターンを狙いたい!と考える人がいるかもしれません。一方、全財産の1000万円を運用している人からしたら、破産リスクは2%でも高すぎるかもしれません。

このように取れるリスクは、許容できる破産確率によって決まります。破産確率は、「システムの勝率」「平均利益」「平均損失」「1回のトレードで賭ける資金の割合」の4つの数字から、数学的に計算できます。

破産確率についてはまた別の記事で解説する予定です。

4.今回の勉強で使用したコード

少し関数が増えてきたので整理しておきました。





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


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

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

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

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

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:
		print("データが存在しません")
		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 ):
	
	lot = 0	
	balance = flag["records"]["funds"]
	
	volatility = calculate_volatility( last_data )
	stop = stop_range * volatility
	
	calc_lot = np.floor( balance * trade_risk / stop * 100 ) / 100
	able_lot = np.floor( balance * levarage / data["close_price"] * 100 ) / 100
	lot = min(able_lot,calc_lot)
	
	flag["records"]["log"].append("現在のアカウント残高は{}円です\n".format(balance))
	flag["records"]["log"].append("許容リスクから購入できる枚数は最大{}BTCまでです\n".format(calc_lot))
	flag["records"]["log"].append("証拠金から購入できる枚数は最大{}BTCまでです\n".format(able_lot))

	return lot,stop



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


# ドンチャンブレイクを判定する関数
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 = 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["order"]["lot"],flag["order"]["stop"] = lot,stop
			flag["order"]["exist"] = True
			flag["order"]["side"] = "BUY"
			flag["order"]["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 = 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["order"]["lot"],flag["order"]["stop"] = lot,stop
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"
			flag["order"]["price"] = data["close_price"]
		else:
			flag["records"]["log"].append("注文可能枚数{}が、最低注文単位に満たなかったので注文を見送ります\n".format(lot))

	return flag



# サーバーに出した注文が約定したか確認する関数
def check_order( flag ):
	
	# 注文状況を確認して通っていたら以下を実行
	# 一定時間で注文が通っていなければキャンセルする
	
	flag["order"]["exist"] = False
	flag["order"]["count"] = 0
	flag["position"]["exist"] = True
	flag["position"]["side"] = flag["order"]["side"]
	flag["position"]["stop"] = flag["order"]["stop"]
	flag["position"]["price"] = flag["order"]["price"]
	flag["position"]["lot"] = flag["order"]["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
			
			
			lot,stop = 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["order"]["lot"],flag["order"]["stop"] = lot,stop
				flag["order"]["exist"] = True
				flag["order"]["side"] = "SELL"
				flag["order"]["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
			
			
			lot,stop = 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["order"]["lot"],flag["order"]["stop"] = lot,stop
				flag["order"]["exist"] = True
				flag["order"]["side"] = "BUY"
				flag["order"]["price"] = data["close_price"]
			
	return flag



# 損切ラインにかかったら成行注文で決済する関数
def stop_position( 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
	
	
	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
			
	return flag



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


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

# バックテストの集計用の関数
def backtest(flag):
	
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 連敗回数をカウントする
	consecutive_defeats = []
	defeats = 0
	for p in flag["records"]["profit"]:
		if p < 0:
			defeats += 1
		else:
			consecutive_defeats.append( defeats )
			defeats = 0
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 資産推移の列を追加する
	records["Funds"] = records.Gross + start_funds
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Funds.cummax().subtract(records.Funds)
	records["DrawdownRate"] = round(records.Drawdown / records.Funds.cummax() * 100,1)
	
	# 買いエントリーと売りエントリーだけをそれぞれ抽出する
	buy_records = records[records.Side.isin(["BUY"])]
	sell_records = records[records.Side.isin(["SELL"])]
	
	# 月別のデータを集計する
	records["月別集計"] = pd.to_datetime( records.Date.apply(lambda x: x.strftime('%Y/%m')))
	grouped = records.groupby("月別集計")
	
	month_records = pd.DataFrame({
		"Number"   :  grouped.Profit.count(),
		"Gross"    :  grouped.Profit.sum(),
		"Funds"    :  grouped.Funds.last(),
		"Rate"     :  round(grouped.Rate.mean(),2),
		"Drawdown" :  grouped.Drawdown.max(),
		"Periods"  :  grouped.Periods.mean()
		})
	
	print("バックテストの結果")
	print("-----------------------------------")
	print("買いエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(buy_records) ))
	print("勝率               :  {}%".format(round(len(buy_records[buy_records.Profit>0]) / len(buy_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(buy_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( buy_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(buy_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( buy_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("売りエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(sell_records) ))
	print("勝率               :  {}%".format(round(len(sell_records[sell_records.Profit>0]) / len(sell_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(sell_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( sell_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(sell_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( sell_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("総合の成績")
	print("-----------------------------------")
	print("全トレード数       :  {}回".format(len(records) ))
	print("勝率               :  {}%".format(round(len(records[records.Profit>0]) / len(records) * 100,1)))
	print("平均リターン       :  {}%".format(round(records.Rate.mean(),2)))
	print("平均保有期間       :  {}足分".format( round(records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( records.Stop.sum() ))
	print("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	print("最大連敗回数       :  {}回".format( max(consecutive_defeats) ))
	print("最大ドローダウン   :  {0}円 / {1}%".format(-1 * records.Drawdown.max(), -1 * records.DrawdownRate.loc[records.Drawdown.idxmax()]  ))
	print("利益合計           :  {}円".format( records[records.Profit>0].Profit.sum() ))
	print("損失合計           :  {}円".format( records[records.Profit<0].Profit.sum() ))
	print("最終損益           :  {}円".format( records.Profit.sum() ))
	print("")
	print("初期資金           :  {}円".format( start_funds ))
	print("最終資金           :  {}円".format( records.Funds.iloc[-1] )) 
	print("運用成績           :  {}%".format( round(records.Funds.iloc[-1] / start_funds * 100),2 )) 
	print("手数料合計         :  {}円".format( -1 * records.Slippage.sum() ))
	
	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) ))
	
	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])

	# 損益曲線をプロット
	plt.plot( records.Date, records.Funds )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	plt.show()
	

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

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

flag = {
	"order":{
		"exist" : False,
		"side" : "",
		"price" : 0,
		"stop" : 0,
		"ATR"  : 0,
		"lot":0,
		"count" : 0
	},
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"stop":0,
		"ATR"  : 0,
		"lot":0,
		"count":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["order"]["exist"]:
		flag = check_order( flag )
	elif flag["position"]["exist"]:
		flag = stop_position( data,flag )
		flag = close_position( data,last_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)

BitflyerFXでBTCの平均ボラティリティ(ATR)を基準に自動で損切りを入れる

より実践的な自動売買BOTでは、エントリーや手仕舞いの条件とは別に、損切り(ストップ)を設定することが多いです。

1.損切りは必要なのか?

純粋に売買ロジックの期待値だけを考えれば、損切りを入れない方が期待リターンは高くなる場合も多いです。ラリー・コナーズ氏の著書「短期売買入門」では、「大半の売買ロジックでは損切りを用いると統計的なエッジ(優位性)が消える」と記載されています。

しかし資金管理の面では、損切りを設定しないと1回のトレードでの最大損失額を予測したりコントロールすることが難しくなります。

前回の記事で説明したように、多くの資金管理アルゴリズムでは、1回のトレードで取れるポジションの数量を「1回のトレードで許容できる最大損失額」から自動的に計算します。そのため、基本的には多少リターンを犠牲にしてでも損切りを設定することが推奨されます。

2.損切り(ストップ)の金額を設定する

「ストップの金額をいくらにすべきか?」にも色々な考え方があります。ここではATR(平均レンジ)を基準にそのときの市場の状況に応じた値幅でストップを入れる方法を解説します。

ATRとは

ATRとは、簡単にいうと一定期間における「1足の高値と安値のレンジ(値幅)」の平均値のことです。要するに、1期間で動く可能性のある値幅(ボラティリティ)の平均値のことです。

※ 正確には「高値と安値」「前の足の終値と安値」「前の足の終値と高値」の3つの値幅のうち最も大きいもののこととをATRといいます。ですが、この記事では単に高値と安値のレンジを使います。

ATRは(高値 – 安値)で計算できます。

早速、練習で1時間足のX期間における平均レンジ(ATR)を計算するpythonコードを書いてみましょう。前回までの章のコードとの繋がりを考えて、ここでは last_data という変数に過去のローソク足(OHLCV)が、配列で古い順に入っていると仮定します。

すると以下のようになります。

pythonコード


# 平均レンジを計算する期間
volatility_term = 5


# BitflyerFXのローソク足データを持つ変数(サンプル)
last_data = [
 {'close_price': 1080800,
  'close_time': 1524978000,
  'close_time_dt': '2018/04/29 14:00',
  'high_price': 1085000,
  'low_price': 1078550,
  'open_price': 1083601},
 {'close_price': 1081900,
  'close_time': 1524981600,
  'close_time_dt': '2018/04/29 15:00',
  'high_price': 1084750,
  'low_price': 1076900,
  'open_price': 1080366},
 {'close_price': 1083802,
  'close_time': 1524985200,
  'close_time_dt': '2018/04/29 16:00',
  'high_price': 1085000,
  'low_price': 1078888,
  'open_price': 1081922},
 {'close_price': 1082500,
  'close_time': 1524988800,
  'close_time_dt': '2018/04/29 17:00',
  'high_price': 1086750,
  'low_price': 1081300,
  'open_price': 1084250},
 {'close_price': 1071383,
  'close_time': 1524992400,
  'close_time_dt': '2018/04/29 18:00',
  'high_price': 1083639,
  'low_price': 1070530,
  'open_price': 1082500},
 {'close_price': 1070549,
  'close_time': 1524996000,
  'close_time_dt': '2018/04/29 19:00',
  'high_price': 1074696,
  'low_price': 1063100,
  'open_price': 1071266},
 {'close_price': 1062000,
  'close_time': 1524999600,
  'close_time_dt': '2018/04/29 20:00',
  'high_price': 1071700,
  'low_price': 1054140,
  'open_price': 1070549},
 {'close_price': 1065516,
  'close_time': 1525003200,
  'close_time_dt': '2018/04/29 21:00',
  'high_price': 1066200,
  'low_price': 1055999,
  'open_price': 1062000},
 {'close_price': 1068330,
  'close_time': 1525006800,
  'close_time_dt': '2018/04/29 22:00',
  'high_price': 1071250,
  'low_price': 1063000,
  'open_price': 1065800},
 {'close_price': 1070222,
  'close_time': 1525010400,
  'close_time_dt': '2018/04/29 23:00',
  'high_price': 1071500,
  'low_price': 1066301,
  'open_price': 1068330}]


# 期間の平均ボラティリティを計算する関数
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)
	return volatility


# メイン処理
print( calculate_volatility(last_data) )

これを計算して実行してみると、例えば、5期間の平均レンジ(ボラティリティ値)は10561円、10期間の平均レンジは9178円とわかります。大体、平均して1時間に1万円くらいの値動きがあった、ということですね。

BTCFXはボラティリティの変動が大きいため、固定値(5000円とか3%とか)で損切りを入れるより、そのときどきの市場ボラティリティの変動に合わせてストップを設定した方がリスクを柔軟にコントロールできます。

コードの解説

前述のように、ATR(ボラティリティ)とは高値と安値の差を1足ごとに計算し、それを合計したものを期間で割って平均値を求めたものです。ただし算数的には、先にすべての高値の合計とすべての安値の合計の差を計算してそれを期間で割っても同じです。

そのため、以下のようにシンプルに3行で書くことができます。

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)

1行目は、last_data という配列データの後ろからX個( volatility_term で設定した個数 )だけ取り出して、その中で高値([high_price])だけを合計しています。

この書き方には「スライス」と「リスト内包表記」という2つのテクニックを使っています。どちらも前の章で、「ドンチアンブレイクアウトの判定をする関数」を作ったときに解説しましたが、一応参考リンクを貼っておきます。

リスト内包表記について
スライスについての記事

3.自動的にストップを入れるコードを書く

Bitflyerでストップ注文を入れる方法としては、特殊注文のAPIを使う方法もあります。ですが、スキャルや高頻度売買BOTでない限り、普通に成行注文を出す設計にしておいた方が、実際の運用時にトラブルが減っていいと思います。

手順としては以下になります。

手順

1)エントリー時に上記の関数を使って、直近の1期間の平均ボラティリティを計算する
2)平均ボラティリティを基準にストップ金額を決める(例:2ATR)
3)エントリー注文の時に執行された価格を変数に記録しておく
4)全体ループのたびに新たに取得した足の安値(または高値)が損切にかかってないかチェックする
5)損切にかかっていたら決済の成行注文を出す

今後の記事では、エントリー後に損切りラインを有利な方向に動かしていく「トレイリングストップ」や、徐々にポジションを買い増ししていく「増し玉」なども解説していく予定です。

そこで毎回、ストップの逆指値注文を出したり、キャンセルしたりしていると、とてつもなくコードが複雑になります。そのため、このブログでは基本的に成行注文で損切りする前提で解説を進めます。

注意点1)実際の運用時

上記の方法の場合、逆指値のストップ注文を入れる方法に比べると、注文の執行が遅れる可能性があります。

実際の運用では、成行注文を出す場合でも、リアルタイムで形成中の足やより短い時間軸の足を使って損切りの判定をすることになります。つまり、エントリーや手仕舞いの判定には直近の確定足を使い、損切りの判定には形成中の足(または1分足)を使います。

注意点2)バックテスト時

一方、バックテストでは、短い時間軸の過去データが同じ期間分だけ揃わないため、上記の状況を再現することはできません。

そこでバックテストでは、「1時間足の終値が損切りラインに掛かっていたら、本来のストップ位置で約定したもの」と仮定します。ただし実際には、理想の価格よりも不利な位置で約定する可能性が高いため、平均ボラティリティを基準にして、1~2ティック不利な位置で約定したと仮定します。

pythonコードを書こう!

では実際のコードを見ていきましょう。

前回の章までで作成したコードに追記していくことを前提に解説していきます。まずはエントリー注文を出す関数に以下を追記します。

1)エントリー注文時


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

def entry_signal( data,last_data,flag ):
	signal = donchian( data,last_data )
	if signal["side"] == "BUY":

		# ここに買い注文のコードを入れる
		
		flag["order"]["ATR"] = calculate_volatility( last_data ) # 追記部分
		flag["order"]["price"] = data["close_price"]

	return flag

flag変数に新しく[“ATR”]という項目を作成し、ここにエントリー時の平均ボラティリティを記録します。

2)ポジションの取得時

指値注文が約定したら、以下のように[position][ATR]に、エントリー時のボラティリティの値を引き継ぎます。


# サーバーに出した注文が約定したか確認する関数

def check_order( flag,last_data ):

	flag["position"]["price"] = flag["order"]["price"]
	flag["position"]["ATR"] = flag["order"]["ATR"]
	return flag

なお、成行注文でエントリーする場合は上記は必要ありません。

前回はバックテストの章だったので説明しませんでしたが、実際の運用では、ドンチアン・ブレイクアウトのようなBOTは成行注文でエントリーする方が現実的です。

指値注文だと注文が刺さらなかった場合は諦めることになりますが、ブレイクアウトBOTでは「数回のトレード機会を見逃す」ことが、最終的に致命的なパフォーマンスの差に繋がる可能性があるからです。

成行注文でエントリーした場合に、執行価格を取得する方法は以下の記事を参考にしてください。

参考:Bitflyerの成行注文の執行価格をAPIで取得する

3)損切りを判定する関数

いったんポジションを保有したら、全体ループの中で以下のような「損切りの判定をする関数」で損切ラインを割っていないかを確認します。


# 損切ラインにかかったら成行注文で決済する関数

def stop_position( data,flag ):

	if flag["position"]["side"] == "BUY":
		stop_price = flag["position"]["price"] - flag["position"]["ATR"] * stop_range
		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
	
	
	if flag["position"]["side"] == "SELL":
		stop_price = flag["position"]["price"] + flag["position"]["ATR"] * stop_range
		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
			
	return flag

以下の式で損切りのラインを計算しています。

▽ 損切りラインの計算式


# 買いエントリーの場合
stop_price = flag["position"]["price"] - flag["position"]["ATR"] * stop_range

# 売りエントリーの場合
stop_price = flag["position"]["price"] + flag["position"]["ATR"] * stop_range

stop_range という変数は、何レンジ幅にストップを入れるかという設定項目です。例えば「2」であれば、エントリー方向から平均ボラティリティの2期間分、逆に動いたときに損切りします。これは最初に倍率を設定しておきます。

損切りラインを計算したら、直近の安値(売りなら直近の高値)が、損切りラインを割っていないかどうか判定します。損切りラインにかかっていた場合は、成行注文を出してポジションを決済します。

なお、バックテストの場合は、以下のように1分足で2ティック不利な位置で約定したと仮定します。

▽ バックテストでの約定価格


# 買いエントリーの場合
stop_price = round( stop_price - 2 * calculate_volatility(last_data) / ( chart_sec / 60) )

# 売りエントリーの場合
stop_price = round( stop_price + 2 * calculate_volatility(last_data) / ( chart_sec / 60) )

ここでは、平均ボラティリティの値幅を1分間に換算して、それを2倍しています。

つまり実際の運用時に1分足で2ティック分程度、注文が遅れたと仮定しています。ここは好みで調整してください。

なお、上記の「損切りを判定する関数」は、「手仕舞いの条件を判定する関数」より前に呼ばれる必要があります。そのため、「手仕舞いの条件を判定する関数」の方では、二重に決済されることがないように、以下のコードを入れておいてください。

# 手仕舞いとドテン注文 を出す関数
def close_position( data,last_data,flag ):

	# すでに損切りに掛かっていたら何もしない
	if flag["position"]["exist"] == False:
		return flag

4)全体のメイン処理

最後に全体のループ部分です。

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["order"]["exist"]:
		flag = check_order( flag )
	elif flag["position"]["exist"]:
		flag = stop_position( data,flag ) # ストップの関数を先に呼ぶ
		flag = close_position( data,last_data,flag )
	else:
		flag = entry_signal( data,last_data,flag )
	
	last_data.append( data )
	i += 1
	time.sleep(wait)

今回からは「平均ボラティリティを計算する期間」も考慮する必要があります。

そのため、最初に設定項目の中から(上値ブレイクアウト期間・下値ブレイクアウト期間・平均レンジの計算期間のうち)最も長いものを調べ、その期間分のローソク足データが溜まるまで次に進まないようにしています。

またポジションがある場合(flag[position][exist]==True)に、さきほど作成した損切を判定する関数(stop_position())を呼ぶようにしておきます。これで完成です!

実行結果

最後にこれらを追記して「1時間足の30期間ドンチアン・チャネルブレイクアウトで2ATRの損切りを使った場合」の結果を示しておきましょう。

今回は平均ボラティリティ(ATR)も30期間で計算しています。つまり直近の30期間の平均ボラティリティを使って、ブレイクアウトのポイントから2足分、逆に動いたら損切りする、というロジックを組み込んだ場合の成績です。

設定項目

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

結果

(base) C:\Pydoc>python test.py
--------------------------
テスト期間:
開始時点 : 2017/09/03 16:00
終了時点 : 2018/05/11 23:00
6000件のローソク足データで検証
--------------------------
バックテストの結果
-----------------------------------
買いエントリの成績
-----------------------------------
トレード回数       :  55回
勝率               :  43.6%
平均リターン       :  2.57%
総損益             :  1110155円
平均保有期間       :  49.9足分
損切りの回数       :  24回
-----------------------------------
売りエントリの成績
-----------------------------------
トレード回数       :  55回
勝率               :  32.7%
平均リターン       :  0.95%
総損益             :  837891円
平均保有期間       :  40.9足分
損切りの回数       :  28回
-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  110回
勝率               :  38.2%
平均リターン       :  1.76%
平均保有期間       :  45.4足分
損切りの回数       :  52回

最大の勝ちトレード :  545284円
最大の負けトレード :  -240272円
最大連敗回数       :  10回
最大ドローダウン   :  -308866円 / -29.0%
利益合計           :  4507994円
損失合計           :  -2559948円

最終損益           :  1948046円
手数料合計         :  -121351円
-----------------------------------
月別の成績
-----------------------------------
2017年9月の成績
-----------------------------------
トレード数         :  11回
月間損益           :  46625円
平均リターン       :  0.94%
月間ドローダウン   :  -31743円
-----------------------------------
2017年10月の成績
-----------------------------------
トレード数         :  14回
月間損益           :  99914円
平均リターン       :  1.62%
月間ドローダウン   :  -61226円
-----------------------------------
2017年11月の成績
-----------------------------------
トレード数         :  12回
月間損益           :  283306円
平均リターン       :  3.25%
月間ドローダウン   :  -100079円
-----------------------------------
2017年12月の成績
-----------------------------------
トレード数         :  9回
月間損益           :  330126円
平均リターン       :  3.34%
月間ドローダウン   :  -308866円
-----------------------------------
2018年1月の成績
-----------------------------------
トレード数         :  17回
月間損益           :  398919円
平均リターン       :  0.89%
月間ドローダウン   :  -206301円
-----------------------------------
2018年2月の成績
-----------------------------------
トレード数         :  12回
月間損益           :  511775円
平均リターン       :  4.22%
月間ドローダウン   :  -238405円
-----------------------------------
2018年3月の成績
-----------------------------------
トレード数         :  12回
月間損益           :  108046円
平均リターン       :  0.64%
月間ドローダウン   :  -216829円
-----------------------------------
2018年4月の成績
-----------------------------------
トレード数         :  17回
月間損益           :  147572円
平均リターン       :  0.93%
月間ドローダウン   :  -170708円
-----------------------------------
2018年5月の成績
-----------------------------------
トレード数         :  6回
月間損益           :  21763円
平均リターン       :  0.37%
月間ドローダウン   :  -119398円

注) ここでの月間ドローダウンは、前月から継続中の最大ドローダウンのことです。その月単独の損失ではありません。

ログファイルを見ると、どこで損切りに掛かっているのかもわかります。

比較検証してみよう!

では、以下の4つの条件で最終成績とドローダウンを比較してみましょう。

1)損切り(ストップ)を用いない場合
2)1レンジ幅に損切りを置いた場合
3)2レンジ幅に損切りを置いた場合
4)3レンジ幅に損切りを置いた場合

※ 損切りを用いない場合の検証は、損切りの関数を「#」でコメントアウトするだけで検証できます。

1)損切りを用いない場合

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  98回
勝率               :  46.9%
平均リターン       :  2.06%
平均保有期間       :  59.7足分
損切りの回数       :  0回

最大の負けトレード :  -296151円
最大連敗回数       :  5回
最大ドローダウン   :  -483332円 / -25.3%
最終損益           :  1929010円

2)損切りを1レンジ幅に置いた場合

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

最大の負けトレード :  -123445円
最大連敗回数       :  15回
最大ドローダウン   :  -740325円 / -75.9%
最終損益           :  1277551円

3)損切りを2レンジ幅に置いた場合

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

最大の負けトレード :  -240272円
最大連敗回数       :  10回
最大ドローダウン   :  -308866円 / -29.0%
最終損益           :  1948046円

4)損切りを3レンジ幅に置いた場合

-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  100回
勝率               :  45.0%
平均リターン       :  2.05%
平均保有期間       :  54.7足分
損切りの回数       :  24回

最大の負けトレード :  -357078円
最大連敗回数       :  9回
最大ドローダウン   :  -425672円 / -39.2%
最終損益           :  1927639円

検証結果

ストップ幅 損切なし 1ATR 2ATR 3ATR
トレード回数 98 154 110 100
勝率 46.9% 22.1% 38.2% 45.0%
平均リターン 2.06% 0.85% 1.76% 2.05%
損切りの回数 0 115 52 24
最大連敗回数 5 15 10 9
最大DD -25.3% -75.9% -29.0% -39.2%
最終損益 192万9010円 127万7551円 194万8046円 192万7639円

 

損切りの位置を近くに置けばおくほど、損切りにかかる回数は増え、その結果1トレード当たりの勝率や平均リターンが悪くなる、という明確な傾向が見てとれます。

期待値への影響

損切りのパラメーターは、恣意的に決められる自由度の高い数字なので、どうしてもカーブフィッティングに繋がりやすくなります。そのため、売買ロジックが持つ期待値の検証とは切り離して考えた方がいいと思います。

重要なのは、バックテスト編で検証したような、本来の売買ロジックが持つ期待値を「できるだけ邪魔しない」ことです。その意味で考えると1ATRのストップ幅は近すぎます。154回のトレードのうち115回も損切りにかかっていては、元々の売買ロジックの期待値を再現できません。

売買ロジックの持つ期待値を邪魔しない範囲で、2~3ATRのラインに損切りを置くのが適切だと思います。

資金管理への影響

売買ロジックの期待値を検証するときには、あまり損切りのパラメータを調整するべきではありません。ですが、売買ロジックを決定した後に、資金管理の方法を考える上では、損切りラインをどこに設定するかは重要な問題になります。

前回の記事の公式で説明したように、損切りラインを近くに設定するほどポジションサイズを大きく取ることができ、損切りラインを遠くに設定するほどポジションサイズは小さくなるからです。ポジションサイズの設計は、売買ロジックの期待値とは違う角度からパフォーマンスに大きな影響を与えます。

上記の例だと、2ATRの位置に損切りラインを置く場合と、3ATRの位置に損切りを置く場合とでは、ポジションを取れる量が1.5倍も違ってきます。2ATRでも3ATRでも、そこまで期待値が変わらないと判断できるのであれば、2ATRを用いて大きくポジションを取った方が、利益の絶対額は大きくなります。

次回の記事では、今回の記事で計算したストップから自動的に注文枚数を計算する部分のコードを実装していきます。

今回の練習問題

今回のコードでは、ついでに新しく以下の2つを実装しました。

1)損切りに掛かった回数を集計する
2)最大連敗回数を集計する

いずれも前回の章(バックテスト編)で勉強した内容で簡単にかけるコードなので、興味がある方は練習問題として自身でコードを書いてみてください。連敗回数の取得にはfor文を使いましたが、本当はもっとスマートな書き方もあるかもしれません。

▽ 今回の記事で使ったコード



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


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

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

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

volatility_term = 30       # 平均ボラティリティの計算に使う期間
stop_range = 2             # 何レンジ幅に損切(ストップ)を置くか

wait = 0                   #  ループの待機時間
lot = 1                    #  BTCの注文枚数
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:
		print("データが存在しません")
		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)
	return volatility


# ドンチャンブレイクを判定する関数
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["records"]["log"].append(str(data["close_price"]) + "円で買いの指値注文を出します\n")

		# ここに買い注文のコードを入れる
		
		flag["order"]["ATR"] = calculate_volatility( last_data )
		flag["order"]["price"] = data["close_price"]
		flag["order"]["exist"] = True
		flag["order"]["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")

		# ここに売り注文のコードを入れる
		
		flag["order"]["ATR"] = calculate_volatility( last_data )
		flag["order"]["price"] = data["close_price"]
		flag["order"]["exist"] = True
		flag["order"]["side"] = "SELL"

	return flag



# サーバーに出した注文が約定したか確認する関数
def check_order( flag ):
	
	# 注文状況を確認して通っていたら以下を実行
	# 一定時間で注文が通っていなければキャンセルする
	
	flag["order"]["exist"] = False
	flag["order"]["count"] = 0
	flag["position"]["exist"] = True
	flag["position"]["side"] = flag["order"]["side"]
	flag["position"]["ATR"] = flag["order"]["ATR"]
	flag["position"]["price"] = flag["order"]["price"]
	
	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["records"]["log"].append("さらに" + str(data["close_price"]) + "円で売りの指値注文を入れてドテンします\n")
			
			# ここに売り注文のコードを入れる
			
			flag["order"]["ATR"] = calculate_volatility( last_data )
			flag["order"]["price"] = data["close_price"]
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"
			

	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["records"]["log"].append("さらに" + str(data["close_price"]) + "円で買いの指値注文を入れてドテンします\n")
			
			# ここに買い注文のコードを入れる
			
			flag["order"]["ATR"] = calculate_volatility( last_data )
			flag["order"]["price"] = data["close_price"]
			flag["order"]["exist"] = True
			flag["order"]["side"] = "BUY"
			
	return flag


# 損切ラインにかかったら成行注文で決済する関数
def stop_position( data,flag ):
	
	if flag["position"]["side"] == "BUY":
		stop_price = flag["position"]["price"] - flag["position"]["ATR"] * stop_range
		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
	
	
	if flag["position"]["side"] == "SELL":
		stop_price = flag["position"]["price"] + flag["position"]["ATR"] * stop_range
		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
			
	return flag


# 各トレードのパフォーマンスを記録する関数
def records(flag, data, close_price, close_type=None):
	
	# 取引手数料等の計算
	entry_price = int(round(flag["position"]["price"] * lot))
	exit_price = int(round( close_price * 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 ))
		if buy_profit  > 0:
			log = str(buy_profit) + "円の利益です\n"
			flag["records"]["log"].append(log)
		else:
			log = str(buy_profit) + "円の損失です\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 ))
		if sell_profit > 0:
			log = str(sell_profit) + "円の利益です\n"
			flag["records"]["log"].append(log)
		else:
			log = str(sell_profit) + "円の損失です\n"
			flag["records"]["log"].append(log)
	
	return flag

# バックテストの集計用の関数
def backtest(flag):
	
	# 成績を記録したpandas DataFrameを作成
	records = pd.DataFrame({
		"Date"     :  pd.to_datetime(flag["records"]["date"]),
		"Profit"   :  flag["records"]["profit"],
		"Side"     :  flag["records"]["side"],
		"Rate"     :  flag["records"]["return"],
		"Stop"     :  flag["records"]["stop-count"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 連敗回数をカウントする
	consecutive_defeats = []
	defeats = 0
	for p in flag["records"]["profit"]:
		if p < 0:
			defeats += 1
		else:
			consecutive_defeats.append( defeats )
			defeats = 0
	
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Gross.cummax().subtract(records.Gross)
	records["DrawdownRate"] = round(records.Drawdown / records.Gross.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(),
		"Rate"     :  round(grouped.Rate.mean(),2),
		"Drawdown" :  grouped.Drawdown.max(),
		"Periods"  :  grouped.Periods.mean()
		})
	
	print("バックテストの結果")
	print("-----------------------------------")
	print("買いエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(buy_records) ))
	print("勝率               :  {}%".format(round(len(buy_records[buy_records.Profit>0]) / len(buy_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(buy_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( buy_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(buy_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( buy_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("売りエントリの成績")
	print("-----------------------------------")
	print("トレード回数       :  {}回".format( len(sell_records) ))
	print("勝率               :  {}%".format(round(len(sell_records[sell_records.Profit>0]) / len(sell_records) * 100,1)))
	print("平均リターン       :  {}%".format(round(sell_records.Rate.mean(),2)))
	print("総損益             :  {}円".format( sell_records.Profit.sum() ))
	print("平均保有期間       :  {}足分".format( round(sell_records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( sell_records.Stop.sum() ))
	
	print("-----------------------------------")
	print("総合の成績")
	print("-----------------------------------")
	print("全トレード数       :  {}回".format(len(records) ))
	print("勝率               :  {}%".format(round(len(records[records.Profit>0]) / len(records) * 100,1)))
	print("平均リターン       :  {}%".format(round(records.Rate.mean(),2)))
	print("平均保有期間       :  {}足分".format( round(records.Periods.mean(),1) ))
	print("損切りの回数       :  {}回".format( records.Stop.sum() ))
	print("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	print("最大連敗回数       :  {}回".format( max(consecutive_defeats) ))
	print("最大ドローダウン   :  {0}円 / {1}%".format(-1 * records.Drawdown.max(), -1 * records.DrawdownRate.loc[records.Drawdown.idxmax()]  ))
	print("利益合計           :  {}円".format( records[records.Profit>0].Profit.sum() ))
	print("損失合計           :  {}円".format( records[records.Profit<0].Profit.sum() ))
	print("")
	print("最終損益           :  {}円".format( records.Profit.sum() ))
	print("手数料合計         :  {}円".format( -1 * records.Slippage.sum() ))
	
	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) ))

	
	# ログファイルの出力
	file =  open("./{0}-log.txt".format(datetime.now().strftime("%Y-%m-%d-%H-%M")),'wt',encoding='utf-8')
	file.writelines(flag["records"]["log"])

	# 損益曲線をプロット
	plt.plot( records.Date, records.Gross )
	plt.xlabel("Date")
	plt.ylabel("Balance")
	plt.xticks(rotation=50) # X軸の目盛りを50度回転
	
	plt.show()
	


# ここからメイン処理

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

flag = {
	"order":{
		"exist" : False,
		"side" : "",
		"price" : 0,
		"ATR"  : 0,
		"count" : 0
	},
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"ATR" :0,
		"count":0
	},
	"records":{
		"date":[],
		"profit":[],
		"return":[],
		"side":[],
		"stop-count":[],
		"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["order"]["exist"]:
		flag = check_order( flag )
	elif flag["position"]["exist"]:
		flag = stop_position( data,flag )
		flag = close_position( data,last_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で自動売買BOTに適切な注文数量(サイズ)を計算させよう!

さて前回までの記事では、BitflyerのBTCFXで自動売買BOTの勝率、リターンやリスク(ドローダウン)を検討する方法を解説しました。

これで過去においてプラスの期待値を持つ売買ロジックを見つけることができましたね。プラスの期待値を持つ売買ロジックを見つけたら、次に考える必要があるのは資金管理です。

1.資金管理とは?

資金管理(マネーマネジメント)とは、簡単にいうと「1回のトレードで何枚のポジションを持つのが数学的に正解なのか?」を追求する分野です。

今までの章では、売買システムの期待値を正しく評価するために、敢えて「ずっと1BTCだけ売買する」というpythonコードを書いていました。バックテストで売買ロジックを検証する段階では、これが正しい検討方法だと思います。

しかし実践でリターンを最大化しようと思ったら、当然、勝って資金が増えてきたら1トレードの枚数は増やしたほうが合理的です。同様に負けて資金が減ってきたら1トレードの枚数を減らすのが正しいリスク管理です。

このような計算は、pythonを使えば効率的に自動化できます。

何BTCでトレードするのが適切なのか?

資金管理でやるべきことは、シンプルにまとめると以下の2点です。

1)口座資金が増えたら1トレードの枚数を増やす
2)ドローダウンが大きくなったら1トレードの枚数を減らす

1のペースを速くすると、複利効果でどんどん利益が膨らみます。しかし大きな負けトレードで資金を1度で失う可能性も高くなります。逆に1のペースを慎重にすると利益の伸びは減るものの、運悪く連敗が続いても利益を大きく失う可能性は低くなります。

上記を考慮してポジションサイズを自動的に計算するアルゴリズムを考えます。

2.資金管理アルゴリズム

資金管理のアルゴリズムには、様々な手法があります。

興味がある方は、ラリーウィリアムズの「短期売買法」の13章や、ペンフォールドの「システムトレード 基本と原則」の8章、ジョンヒルの「究極のトレーディングガイド」の11章などに詳しい説明があります。

この章では、もっとも基本的な以下のような方法を使うことにします。

Fund … 口座資金
n% … 1回のトレードで失ってもいい口座資金の割合
stop … 1BTC当たりの損切(ストップ)の設定額

購入枚数 = (Fund × n%) ÷ stop

例えば、以下のような条件でトレードをしていると仮定します。

・口座残高 100万円
・1BTCの価格 75万円
・1回のトレードあたりの許容できる損失 3%
・損切りライン / エントリー価格の2%下(-15000円)
・レバレッジ3倍

この場合、次のエントリーシグナルが出たときの注文枚数は、(100万円 × 3%) / 15000円 = 2BTCまで、と計算できます。最大2BTCまでなら1回のトレードで口座の3%以上の資金を失うことはありません。

このような数式をBOTに組み込んで、注文をするたびに自動的にサイズ(注文数量)をBOTに計算させるのがこの章の目的です。

3.レバレッジが必要な理由

レバレッジというのは、必ずしも「リスクとリターンを同じ比率で増幅するもの」ではありません。上記の式から資金管理を考えれば、レバレッジが有効な場面が存在することがわかります。

例えば、もう1度さっきの例を考えてみましょう。

・口座資金 100万円
・1トレードあたりの許容できる損失 3%
・1BTCの価格 75万円
・1BTCの損切り幅 1万5000円

資金管理上は1トレードあたり3万円(口座の3%)までリスクを取ることが許されています。また売買ロジックの性質上、ストップはエントリー価格の1万5000円下に置くのが最適だということもバックテストの検証の結果、わかっているとします。

このとき、理論上は最大2枚(BTC)までポジションを取るのが最も合理的です。しかし1BTCは75万円なので、レバレッジを掛けなければ、1.33BTCまでしかポジションを取ることができません。つまり1トレードのリスク量が口座資金の2%以下に制限されてしまうことになります。

資金管理の目的は「リスクを許容範囲におさえながらリターンを最大化すること」です。本来、取っていいはずのリスクを取れないことは機会損失になり、パフォーマンスの低下に繋がります。

4.この章で勉強すること

この章では、前回までの章で勉強したドンチャン・チャネルブレイクアウトBOTを使って、以下のようなpythonコードを追加していきます。

(1)口座資金に応じて注文サイズを自動的に変更する
(2)BOTで自動的に損切りを入れられるようにする
(3)市場の値幅(ボラティリティ)に応じたストップを計算する
(4)トレイリングストップを入れられるようにする
(5)1トレードの資金を数回に分けてエントリーする
(6)破産確率から適切な1トレードあたりのリスクを計算する
(7)損切り・資金管理の設定による総利益の違いをシミュレーションする