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)損切り・資金管理の設定による総利益の違いをシミュレーションする

BitflyerFXでCCXTを使って成行注文を出しその執行価格を取得する方法

Bitflyerの自動売買BOTで成行注文を出す場合、その執行価格を把握しておきたい場面があると思います。この記事では成行注文の執行価格を表示するpythonコードを紹介しておきます。

前提

CCXTライブラリを使う

CCXT経由でBitflyerに成行注文を出す方法は、以前にこちらの記事で解説しているので参考にしてください。

pythonコード


# 成行注文の執行状況を確認する関数
def check_market_order( id,lot ):
	while True:
		try:
			size = []
			price = []
			
			executions = bitflyer.private_get_getexecutions( params = { "product_code" : "FX_BTC_JPY" })
			for exec in executions:
				if exec["child_order_acceptance_id"] == id:
					size.append( exec["size"] )
					price.append( exec["price"] )
			
			# 全部約定するまで待つ
			if round(sum(size),2) != lot:
				time.sleep(20)
				print("注文がすべて約定するのを待っています")
			else:
				# 平均価格を計算する
				average_price = round(sum( price[i] * size[i] for i in range(len(price)) ) / sum(size))
				print("すべての成行注文が執行されました")
				print("執行価格は平均 {}円です".format(average_price))
				return average_price
				
		except ccxt.BaseError as e:
			print("BitflyerのAPIで問題発生 : ",e)
			print("20秒待機してやり直します")
			time.sleep(20)

コードの解説

まずは以下のコードでBitflyerから約定履歴の一覧を取得しています。

executions = bitflyer.private_get_getexecutions( params = { "product_code" : "FX_BTC_JPY" })

BitflyerのAPIでは「 GET /v1/me/getexecutions 」で自分の約定履歴の一覧を取得することができます。取得した約定履歴の一覧からは、[size]で数量、[price]で執行価格を取得することができます。

(参考)
BitflyerのAPI仕様書の説明ページ
CCXT経由で個別のAPIを使用する方法

基本的な流れ

成行注文の場合、注文がいくつかに分割されて異なる価格で執行されることも多いです。そのため、上記の関数では以下の2つの情報を必要としています。

(1)成行注文を送ったときに返ってくる注文ID
(2)成行注文を出したときの注文数量

成行注文は、分割されて約定しても全て同一の注文ID(order_acceptance_id)で記録されます。例えば、以下のような感じです。

# 例)0.09BTC の売りの成行注文が、0.08BTC と 0.01BTCに割れて執行された場合

[{'child_order_acceptance_id': 'JRF20180424-060559-396699',
  'child_order_id': 'JFX20180424-021850-855859F',
  'commission': 0.0,
  'exec_date': '2018-04-24T02:18:50.45',
  'id': 215522439,
  'price': 958028.0,
  'side': 'SELL',
  'size': 0.08},
 {'child_order_acceptance_id': 'JRF20180424-060559-396699',
  'child_order_id': 'JFX20180424-021850-855859F',
  'commission': 0.0,
  'exec_date': '2018-04-24T02:18:50.45',
  'id': 215522439,
  'price': 958290.0,
  'side': 'SELL',
  'size': 0.01}
]

そのため、成行注文を出したときに注文ID(order_acceptance_id)を覚えておいて、その注文IDで約定履歴を検索すれば、分割されて執行されたすべての注文を捕捉することができます。

1.注文がすべて執行されたことを確認する

成行注文を送った後に、20秒ごとに約定履歴をAPIで取得して、注文IDが一致している注文の「価格」と「数量」を取得します。それが以下の部分です。

for exec in executions:
	if exec["child_order_acceptance_id"] == id:
		size.append( exec["size"] )
		price.append( exec["price"] )

そして「数量」の合計が、最初に送った注文数量と一致すれば、すべて約定したと判断します。一致しなければ、一致するまで20秒ごとに確認を繰り返します。(秒数は適当に変更してください)

# 全部約定するまで待つ
if round(sum(size),2) != lot:
	time.sleep(20)
	print("注文がすべて約定するのを待っています")
	# ここでwhileループの先頭に戻る

一致したらすべての注文が執行されたものとして次に進みます。

2.執行価格の平均を計算する

すべての注文数量が約定したことを確認できたら、執行価格を計算します。これは以下の式で計算できます。

A)注文1の価格 × 注文1の数量 + 注文2の価格 × 注文2の数量 + 注文3の価格 × 注文3の数量 +….
B)注文数量の合計

執行価格の平均 A ÷ B

これをpythonで1行で書くと以下のようになります。

# 平均価格を計算する
average_price = round(sum( price[i] * size[i] for i in range(len(price)) ) / sum(size))

3.注文サイズの計算方法の注意点

もう1つだけ注意点があります。それが小数点の計算です。pythonでは2進法で小数を計算するため、成行注文が細かく割れてしまった場合には、注文サイズが送ったものと一致しなくなることがあります。

具体例

例えば、0.21BTCを成行注文で出した場合を仮定してください。そして注文が以下のように割れて約定したとします。

▽ 0.21BTCの注文

注文1) 0.11634476 BTC
注文2) 0.08175024 BTC
注文3) 0.011905 BTC

合計 0.21BTC

ご自身で確認していただくとわかりますが、これは電卓で計算すると丁度 0.21BTCになります。それではpythonで同じ計算をしてみましょう。


size1 = 0.11634476
size2 = 0.08175024
size3 = 0.011905

print(size1 + size2 + size3)

#----- 実行結果 ------
0.21000000000000002

この計算結果は、なんとpythonでは 0.21000000000000002 になってしまいます。そこでround()を使って、注文したときと同じ桁数に小数点を丸める必要があります。それが以下の箇所です。


# 全部約定するまで待つ
if round(sum(size),2) != lot:

これを最初私はよくわかっていなかったのですが、note読者の方に症状を教えていただき発見できました。いつも計算が一致しないわけではなく、細かい単位で注文が割れた場合の一部のケースでこの症状がおこるようです。

参考:「pythonの浮動小数点演算の問題

成行注文のコードと組み合わせる

最後に成行注文を出すコードと組み合わせたパターンも書いておきます。

以下のコードで成行注文を出せば、約定後にその執行価格を返すところまでセットで実行できます。損益やスリッページの計算、建値の計算、損切価格ラインの決定などに使ってください。

# 成行注文を出す関数
def market_order(side,lot):
	while True:
		try:
			order = bitflyer.create_order(
				symbol = 'BTC/JPY',
				type='market',
				side= side,
				amount= lot,
				params = { "product_code" : "FX_BTC_JPY" })

			# 注文時のidを記録しておく
			order_id = order["id"]
			time.sleep(30)
			
			# 執行状況を確認する関数を呼ぶ
			average_price = check_market_order( order_id, lot )
			return average_price
			
		except ccxt.BaseError as e:
			print("Bitflyerの注文APIでエラー発生",e)
			print("注文が失敗しました")
			print("30秒待機してやり直します")
			time.sleep(30)


# 成行注文の執行状況を確認する関数
def check_market_order( id,lot ):
	while True:
		try:
			size = []
			price = []
			
			executions = bitflyer.private_get_getexecutions( params = { "product_code" : "FX_BTC_JPY" })
			for exec in executions:
				if exec["child_order_acceptance_id"] == id:
					size.append( exec["size"] )
					price.append( exec["price"] )
			
			# 全部約定するまで待つ
			if round(sum(size),2) != lot:
				time.sleep(20)
				print("注文がすべて約定するのを待っています")
			else:
				# 平均価格を計算する
				average_price = round(sum( price[i] * size[i] for i in range(len(price)) ) / sum(size))
				print("すべての成行注文が執行されました")
				print("執行価格は平均 {}円です".format(average_price))
				return average_price
				
		except ccxt.BaseError as e:
			print("BitflyerのAPIで問題発生 : ",e)
			print("20秒待機してやり直します")
			time.sleep(20)

BitflyerFXで注文が消えたり、約定拒否や二重注文になる場合の対策コード

PythonでBOTを作ってBitflyerFXで運用していると、以下のようなトラブルにときどき遭遇します。いずれも「対策をしないと気付かないうちに意図しない建玉が残る」という種類のトラブルです。

(1)注文反映の遅延
(2)キャンセルとスレ違いでの約定
(3)注文が消える・約定拒否
(4)二重注文(ダブル約定)

定義

これらの用語はよく聞くものの、それぞれの具体的な定義がなかなか調べてもわからなかったので、以下、私が理解している範囲のそれぞれの定義を記載しておきます。

問題 意味
注文遅延 API経由で指値注文を出し、それが正常にサーバー側に受け取られたが、その情報が「未約定の注文一覧」「建玉の一覧」のどちらにも反映されず、数十秒~数分間、宙に浮いている状態
スレ違い API経由で指値注文のキャンセルを出し、それが正常にサーバー側に受け取られたが、実際はキャンセルに成功しておらず、スレ違いで注文が約定して建玉に残っている状態
注文が消える API経由で注文を出し、それが正常にサーバー側に受け取られたにも関わらず、出したはずの注文がどこかに消えてしまう状態
二重注文 API経由で注文を出したものの、それがタイムアウト等の通信エラーになり、例外処理でリトライをしたところ、実はさっきの注文が正常に受理されていたため、二重に注文が通ってしまう状態

「約定拒否」というのが何かよくわからず、個人的には「注文が消える」と類似の問題と理解しているのですが、もし違ったら教えてください。

問題点

これらの共通の問題は、通信エラーが判定条件にならないことです。

通信エラーが出てくれれば、単に例外処理をすればいいだけですが、通信エラーが出ていないのに注文が反映されない場合(または通信エラーが出たのに注文が執行された場合)、BOTの挙動がおかしくなる原因になります。

遅延の対策

注文の遅延とは、正常に指値注文のリクエストが受理されたのに、「注文一覧」か「建玉一覧」に注文が反映されない状態です。

これはもし「信じて待っていればいつか必ず反映される」という前提であれば、ただひたすら待てばいいことになります。大抵の場合は実際それで問題ありません。基本的な遅延の対策コードは以下に紹介しています。

参考:Bitflyerで自動売買の試作BOTを作ろう!

消えた場合の対策

しかしごく稀に「消えてしまう」という厄介なケースがあります。この場合、信じて待っていると永久に「反映待ち」の状態が続きます。そのため、少し気持ち悪いですが、一定の間隔でカウンターを回してどこかで諦めて「消えた」と判定しなければなりません。

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


# サーバーの注文を確認する関数
def bitflyer_check_order( flag ):
	try:
		position = bitflyer.private_get_getpositions( params = { "product_code" : "FX_BTC_JPY" })
		orders = bitflyer.fetch_open_orders(
			symbol = "BTC/JPY",
			params = { "product_code" : "FX_BTC_JPY" })
	except ccxt.BaseError as e:
		print("BitflyerのAPIで問題発生 : ",e)
	else:
		# 注文が約定していた場合
		if position:
			print("注文が約定しました!")
			flag["order"]["exist"] = False
			flag["order"]["count"] = 0
			flag["order"]["latency"] = 0
			
			# 平均建値やサイズを取得
			time.sleep(5)
			price,size,side = check_bf_positions()
			
			flag["position"]["exist"] = True
			flag["position"]["side"] = side
			flag["position"]["price"] = price
			flag["position"]["lot"] = size
			return flag
		else:
			# 注文一覧に残っている場合
			if orders:
				print("まだ未約定の注文があります")
				for o in orders:
					print( o["id"] )
				flag["order"]["count"] += 1
				
				# 約定しない注文をキャンセルする
				if flag["order"]["count"] > 12:
					flag = bitflyer_cancel_order( orders,flag )
			else:
				# 遅延対策
				print("注文が遅延しているようです")
				flag["order"]["latency"] += 1
				
				# ずっと遅延が続いて消えた疑いがある場合
				if flag["order"]["latency"] > 12:
					print("注文が消えてしまったようです")
					flag["order"]["exist"] = False
					flag["order"]["count"] = 0
					flag["order"]["latency"] = 0
	return flag

通常は多くても5~10行、遅延が続いたあとに注文一覧に反映されます。しかし4月後半に注文が消えるという問題が何度か出たことがあったと思います。

記事にしようと思いつつ時間が経ってしまい、少し前のテストなのでうろ覚えですが、たしかこのテストのときは注文が消えたと思います。「注文が消える」ケースはそう頻繁に再現できないので、なかなか対策の方法が難しいです。

▽ 注文が消えた場合

二重注文の対策

二重注文は、タイムアウトやサーバーエラーなどの通信エラーが出たにも関わらず、実際には注文が正常に受理されていたパターンです。それに気づかずに、例外処理をして注文を再送すると、ダブルで約定してしまいます。

これは注文を連打せずに、ある程度、しっかり間隔をあけて間にポジションチェックを入れれば回避できる気もします。しかし前述のように、遅延が酷いときは1~2分待ったくらいでは確信を持って判断できないときもあります。サーバーが混雑していないときまで毎回1~2分待つのも非効率です。

タイムアウトエラーの対策

1つの対策としては、接続のタイムアウトの判定時間を長めに設定しておくという方法があります。

CCXTライブラリ経由でBitflyerに注文を出す場合、初期設定でのタイムアウトの判定は10秒に設定されています。つまり10秒待って応答がなければエラーとして扱われるわけですが、以下のように記述することで、その待機時間を伸ばすことができます。


bitflyer = ccxt.bitflyer()
bitflyer.apiKey = ''
bitflyer.secret = ''
bitflyer.timeout = 30000     # 通信のタイムアウト時間の設定

上記の例では、タイムアウトエラーの判定時間を30秒にしています。抜本的な解決方法ではありませんが、個人的には、上記の設定でかなりエラー頻度が減ったように思います。

定期的に建玉をチェックする

結局のところ、あまりスマートではないですが、定期的に「意図しない建玉」が残っていないかをチェックするしかないかもしれません。現在の建玉を取得する関数の作り方は以下を参考にしてください。

参考:BitflyerFXの平均建値とサイズを取得する

例えば、以下のような関数を全体ループの中に入れることを考えます。

def find_unexpected_pos( flag ):
	
	if flag["position"]["exist"] == True:
		return flag
	count = 0
	while True:
		price,size,side = bitflyer_check_positions()
		if size == 0:
			return flag
		
		print("把握していないポジションが見つかりました")
		print("反映の遅延でないことを確認するため様子を見ています")
		count += 1
		
		if count > 5:
			# ポジションの復活
			print("把握していないポジションが見つかったためポジションを復活させます")
			
			flag["position"]["exist"] = True
			flag["position"]["side"] = side
			flag["position"]["lot"] = size
			flag["position"]["price"] = price
			return flag
		time.sleep(30)

※ 例外処理は check_bf_position() の中に入っています。

このような処理をする場合は、今度は逆に「成行注文で決済したはずなのに遅延で建玉が残っている場合」に騙されないように注意しなければなりません。そのため、成行注文を出したときに、確実にすべて執行されたところまで見届けることが重要です。

またすべての成行注文が執行されたとしても、決済後も「建玉一覧」にポジションが解消されたことへの反映が遅延する可能性もあるため、上記のコードでは5回ほどカウンターを回して確認しています。

もっと良い方法も模索中です。

BOTの一番成績のいい最適なパラメーターをpythonで総当たりで探索しよう!

さて、前回までの記事でバックテストをして自動売買BOTのパフォーマンスを評価する方法は、大体マスターできたと思います!

今回は、バックテスト編の総仕上げとして最適なパラメーターを探索する方法を紹介しておきましょう。このパラメーター探索ができることこそ、ある意味、自分でpythonのプログラミングができることの優位性といっても過言ではないかもしれません。

手動だと何百通り、何千通りも試す必要のある膨大な組み合わせの中から、もっとも勝率や期待リターンが高く、最もリスクやドローダウンの小さいパラメーターは何なのか? それを調べる方法を解説します。

パラメーター最適化とは

パラメーターとは、ある自動売買BOTのロジックの中で自由に動かして変更できる値のことをいいます。例えば、前回までの記事で学習した一番シンプルなn期間ドンチャン・ブレイクアウトBOTには、以下の2つのパラメーターがありました。

1)X分足の時間軸を使う
2)n期間の最高値(最安値)ブレイクアウトで買う

同じドンチャンブレイクアウトでも、15分足で10期間のドンチャン・ブレイクアウト戦略を使うのと、1時間足で30期間のドンチャン・ブレイクアウト戦略を使うのとでは、全く成績が異なります。

上記の2つの組み合わせだけなら、前回までの記事の方法で1つ1つを手作業で検証することも可能です。しかし組み合わせが何百通り、何千通りと増えてくると、すべてのパターンを手作業で検証して比較するのは難しくなります。

そこでpythonの出番です。pythonのようなプログラムなら、自動的に何百通りものパラメーターの組み合わせでバックテストを行い、最も利益を最大化できるようなパラメーターを探索することができます。今回はその方法を解説していきます!

今回の練習で試すパラメーター

まずは準備として前回までのドンチャン・ブレイクアウトのコードを改良し、以下の4つのパラメーターを設定できるようにしてみましょう。

変更可能なパラメーター

1)X分足の時間軸を使う
2)n期間の最高値のブレイクアウトで買う
3)m期間の最安値のブレイクアウトで売る
4)ブレイクアウトの判定基準に、高値/安値 or 終値 を使う

より実践的なドンチャン・ブレイクアウト戦略では、上値のブレイクアウトと下値のブレイクアウトで異なるパラメーターを使用することがよくあります。そのため、今までのコードを改良し、買いエントリと売りエントリで、それぞれ違う期間のパラメーターを設定できるようにしておきます。

また今までのコードでは、ブレイクアウトの判定基準を「過去n期間の最高値(最安値)を直近の高値が上回ったとき」としていましたが、高値ではなく終値のパターンも検証しておきましょう。

上記の修正済みのコードは、以下の別記事にまとめておきます。
時間のある方は自分でも挑戦してみてください!

パラメーターを設定できる改良版コード

パラメーターの組み合わせを総当たりでテストする方法

パラメーター探索と聞くと、最近流行りの機械学習やニューラルネットワークなど、難しい数学やプログラミングスキルが必要だと誤解している方もいるかもしれません。

しかし基本的には、数千通りくらいのパターンであれば、ただの「総当たり」で全パターンを計算すれば十分です。全パターンを試しても数十秒~1分もあれば終わりますので、複雑な機械学習や最適化アルゴリズムは必要ありません。

例えば、以下のようなパターンの組み合わせをすべて試したいとしましょう。


# 4種類のパラメーター
paramA = [ 10,20,30,40,50 ]
paramB = [ 10,20,30,40,50 ]
paramC = [ "A" , "B" ]
paramD = [ 1,2,3,4,5,6,7,8,9,10 ]

この場合、以下のようにfor文を書くことで全パターンの組み合わせを試すことができます。


# for文で総当たりのパターンを記述
for a in paramA:
	for b in paramB:
		for c in paramC:
			for d in paramD:
				
				# 各組合せで実行したい処理


パラメーターAのfor文処理をするなかで、1つの要素についてさらにパラメーターBのfor文処理をし、パラメーターBのfor文処理のなかの1つの要素に対してさらにパラメーターCのfor文処理をし….という入れ子構造にしていくわけですね。

これを実行することで、5 × 5 × 2 × 10 = 500通りの組み合わせについて、全てのパターンでバックテストを実行することができます。

シンプルな記述方法

なお、pythonでは上記のコードをもっとシンプルにして以下のように書くことができます。


combinations = [(a, b, c, d)
	for a in paramA
	for b in paramB
	for c in paramC
	for d in paramD]

for a,b,c,d in combinations:
	# 各組合せで実行したい処理

試すパラメーターの組み合わせ

なお、今回のドンチャンブレイクアウトのパラメーター最適化では、以下の組み合わせを総当たりで全て試してみることにします。

1)使用する時間軸
⇒ 30分足/1時間足/2時間足/6時間足/12時間足/1日足

2)上値ブレイクアウトの判定期間
⇒ 10/15/20/25/30/35/40/45

3)下値ブレイクアウトの判定期間
⇒  10/15/20/25/30/35/40/45

4)判定に使用する価格
⇒  高値・安値/終値・終値

時間足が6通り、判定期間が上値ブレイクアウト・下値ブレイクアウトでそれぞれ8通りずつ、判定に使用する価格が2通りで、合計 768通りのパターンをテストしてみましょう。

これをfor文の総当たりでテストするためには、以下のように書けばOKです。


# バックテストのパラメーター設定

chart_sec_list  = [ 1800, 3600, 7200, 21600, 43200, 86400 ] # 時間足
buy_term_list   = [ 10,15,20,25,30,35,40,45 ] # 上値ブレイクの判断期間
sell_term_list  = [ 10,15,20,25,30,35,40,45 ] # 下値ブレイクの判断期間
judge_price_list = [
	{"BUY":"close_price","SELL":"close_price"}, # 終値/終値 と 高値/安値
	{"BUY":"high_price","SELL":"low_price"}
]

# for文の記述方法

combinations = [(chart_sec, buy_term, sell_term, judge_price)
	for chart_sec in chart_sec_list
	for buy_term  in buy_term_list
	for sell_term in sell_term_list
	for judge_price in judge_price_list]

for chart_sec, buy_term, sell_term,judge_price in combinations:
	# 各回のバックテスト処理を記述
	# (今までの記事で作成したもの)

これで総当たりのfor文の準備は完了です!

最大化したい指標を決める

何のためにパラメーター最適化を実施するのかといえば、リターンを極限まで高くしたいからですよね。なので「総利益が最大になるようなパラメーターを探す」というのも1つの方法です。

しかし自動売買BOTの評価を最終損益だけで判断するのは不十分です。より厳密にいえば、私たちが探している理想の売買ロジックは、「できるだけリスクを抑えながら、できるだけ大きなリターンを得ること」にあるはずです。そのため、リスクとリターンの比率を1つの数字で表せるような指標を「最大化したい数字」に設定すべきです。

このような指標には、シャープレシオ、MARレシオなど、いくつかの指標がありますが、ここでは一番オーソドックスな「プロフィットファクター」を使うことにします。

プロフィットファクター(PF)とは

同じ最終利益100万円でも、「利益110万円 / 損失10万円」の売買システムと、「利益200万円 / 損失100万円」の売買システムとでは、かなり意味が違ってきます。

前者は、損失が利益のおよそ1/10で済んでいるのに対し、後者は、なんと利益の半分もの損失を出してしまっています。当然、前者の方が安定した売買システムで、後者のほうがより不安定な(リスクの高い)売買システムということになります。

このような、「 総利益 / 総損失 」で表される指標のことをプロフィットファクター(PF)といいます。PFが大きければ大きいほど、そのシステムは安定していてリスクが小さいと評価できます。

プロフィットファクターの計算コード

今まで作成してきたドンチャン・ブレイクアウトのバックテスト検証コードに、プロフィットファクターを計算するコードを追加しておきましょう。

前回のpandasの記事を読んで練習した方なら書けると思います!

▽ プロフィットファクターの計算式 (総利益 ÷ 総損失)

PF = round( -1 * (records[records.Profit>0].Profit.sum() / records[records.Profit<0].Profit.sum()) ,2)

さて、それでは実際のコードを作っていきましょう!

2.pythonコード

いつものように、まず最初に全コードの内容と実行結果を示します。
その後コードの書き方を解説していきます。


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

#-----設定項目

wait = 0            # ループの待機時間
lot = 1             # BTCの注文枚数
slippage = 0.001    # 手数料・スリッページ


# バックテストのパラメーター設定
#---------------------------------------------------------------------------------------------
chart_sec_list  = [ 1800, 3600, 7200, 21600, 43200, 86400 ] # テストに使う時間軸
buy_term_list   = [ 10,15,20,25,30,35,40,45 ] # テストに使う上値ブレイクアウトの期間
sell_term_list  = [ 10,15,20,25,30,35,40,45 ] # テストに使う下値ブレイクアウトの期間
judge_price_list = [
	{"BUY":"close_price","SELL":"close_price"}, # ブレイクアウト判定に終値を使用
	{"BUY":"high_price","SELL":"low_price"}     # ブレイクアウト判定に高値・安値を使用
]
#---------------------------------------------------------------------------------------------



# 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 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["order"]["exist"] = True
		flag["order"]["side"] = "BUY"
		flag["order"]["price"] = round(data["close_price"] * lot)

	if signal["side"] == "SELL":

		# ここに売り注文のコードを入れる
		
		flag["order"]["exist"] = True
		flag["order"]["side"] = "SELL"
		flag["order"]["price"] = round(data["close_price"] * 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"]["price"] = flag["order"]["price"]
	
	return flag


# 手仕舞いのシグナルが出たら決済の成行注文 + ドテン注文 を出す関数
def close_position( data,last_data,flag ):
	
	flag["position"]["count"] += 1
	signal = donchian( data,last_data )
	
	if flag["position"]["side"] == "BUY":
		if signal["side"] == "SELL":
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			# ここに売り注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"
			flag["order"]["price"] = round(data["close_price"] * lot)
			

	if flag["position"]["side"] == "SELL":
		if signal["side"] == "BUY":
			
			# 決済の成行注文コードを入れる
			
			records( flag,data )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			# ここに買い注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "BUY"
			flag["order"]["price"] = round(data["close_price"] * lot)
			
	return flag


# 各トレードのパフォーマンスを記録する関数
def records(flag,data):
	
	# 取引手数料等の計算
	entry_price = flag["position"]["price"]
	exit_price = round(data["close_price"] * lot)
	trade_cost = round( exit_price * slippage )
	flag["records"]["slippage"].append(trade_cost)
	
	# 手仕舞った日時と保有期間を記録
	flag["records"]["date"].append(data["close_time_dt"])
	flag["records"]["holding-periods"].append( flag["position"]["count"] )
	
	# 値幅の計算
	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 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 ))
	
	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"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 総損益の列を追加する
	records["Gross"] = records.Profit.cumsum()
	
	# 最大ドローダウンの列を追加する
	records["Drawdown"] = records.Gross.cummax().subtract(records.Gross)
	records["DrawdownRate"] = round(records.Drawdown / records.Gross.cummax() * 100,1)

	print("バックテストの結果")
	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("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	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() ))
	
	# バックテストの計算結果を返す
	result = {
		"トレード回数"     : len(records),
		"勝率"             : round(len(records[records.Profit>0]) / len(records) * 100,1),
		"平均リターン"     : round(records.Rate.mean(),2),
		"最大ドローダウン" : -1 * records.Drawdown.max(),
		"最終損益"         : records.Profit.sum(),
		"プロフィットファクタ―" : round( -1 * (records[records.Profit>0].Profit.sum() / records[records.Profit<0].Profit.sum()) ,2)
	}
	
	return result
	


# ここからメイン処理

# バックテストに必要な時間軸のチャートをすべて取得
price_list = {}
for chart_sec in chart_sec_list:
	price_list[ chart_sec ] = get_price(chart_sec,after=1451606400)
	print("-----{}分軸の価格データをCryptowatchから取得中-----".format( int(chart_sec/60) ))
	time.sleep(10)

# テストごとの各パラメーターの組み合わせと結果を記録する配列を準備
param_buy_term  = []
param_sell_term = []
param_chart_sec = []
param_judge_price = []

result_count = []
result_winRate = []
result_returnRate = []
result_drawdown = []
result_profitFactor = []
result_gross = []

# 総当たりのためのfor文の準備
combinations = [(chart_sec, buy_term, sell_term, judge_price)
	for chart_sec in chart_sec_list
	for buy_term  in buy_term_list
	for sell_term in sell_term_list
	for judge_price in judge_price_list]

for chart_sec, buy_term, sell_term,judge_price in combinations:
	
	price = price_list[ chart_sec ]
	last_data = []
	i = 0
	
	# フラッグ変数の初期化
	flag = {
		"order":{
			"exist" : False,
			"side" : "",
			"price" : 0,
			"count" : 0
		},
		"position":{
			"exist" : False,
			"side" : "",
			"price": 0,
			"count":0
		},
		"records":{
			"date":[],
			"profit":[],
			"return":[],
			"side":[],
			"holding-periods":[],
			"slippage":[]
		}
	}
	
	while i < len(price):
		
		# ドンチャンの判定に使う期間分の安値・高値データを準備する
		if len(last_data) < buy_term or len(last_data) < sell_term:
			last_data.append(price[i])
			time.sleep(wait)
			i += 1
			continue
		
		data = price[i]
		
		if flag["order"]["exist"]:
			flag = check_order( flag )
		elif flag["position"]["exist"]:
			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(int(chart_sec/60)) + "分足で検証")
	print("パラメータ1 : " + str(buy_term)  + "期間 / 買い" )
	print("パラメータ2 : " + str(sell_term) + "期間 / 売り" )
	print(str(len(price)) + "件のローソク足データで検証")
	print("--------------------------")

	
	result = backtest( flag )
	
	
	# 今回のループで使ったパラメータの組み合わせを配列に記録する
	param_buy_term.append( buy_term )
	param_sell_term.append( sell_term )
	param_chart_sec.append( chart_sec )
	if judge_price["BUY"] == "high_price":
		param_judge_price.append( "高値/安値" )
	else:
		param_judge_price.append( "終値/終値" )
	
	
	# 今回のループのバックテスト結果を配列に記録する
	result_count.append( result["トレード回数"] )
	result_winRate.append( result["勝率"] )
	result_returnRate.append( result["平均リターン"] )
	result_drawdown.append( result["最大ドローダウン"] )
	result_profitFactor.append( result["プロフィットファクタ―"] )
	result_gross.append( result["最終損益"] )
	
	

# 全てのパラメータによるバックテスト結果をPandasで1つの表にする
df = pd.DataFrame({
	"時間軸"        :  param_chart_sec,
	"買い期間"      :  param_buy_term,
	"売り期間"      :  param_sell_term,
	"判定基準"      :  param_judge_price,
	"トレード回数"  :  result_count,
	"勝率"          :  result_winRate,
	"平均リターン"  :  result_returnRate,
	"ドローダウン"  :  result_drawdown,
	"PF"            :  result_profitFactor,
	"最終損益"      :  result_gross
})

# 列の順番を固定する
df = df[[ "時間軸","買い期間","売り期間","判定基準","トレード回数","勝率","平均リターン","ドローダウン","PF","最終損益"  ]]

# トレード回数が100に満たない記録は消す
df.drop( df[ df["トレード回数"] < 100].index, inplace=True )

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

実行結果

これを実行すると以下のようなCSVファイルが出力されます。

プロフィットファクター(PF)のJ列を「降順」で並び変えてみると、ドンチャン・ブレイクアウトの有望なパラメーターの組み合わせがわかります。

▽ PFで並び替え後の成績ベスト30

上記のコードではトレード回数が100回に満たなかった結果データを全て捨てているので、どのパラメーターの組み合わせも最低限のサンプル数を確保できています。上位30の成績のパラメーターをみると、ほとんどが2時間足以上の時間軸なのがわかります。

▽ PFで並び替え後の成績ワースト30

逆に最もプロフィットファクターの低かった(悪かった成績)30コをみると、短い時間軸(30分足)に集中しているのがわかります。ドンチアンブレイクアウトは、あまり短い時間軸には適さない戦略の可能性がありそうです。

(ただし時間軸によってテストに使用しているローソク足の期間が違うので、厳密には比較できない点に注意してください。)

実行結果2

次はさらに範囲を絞ってテストをしてみましょう!

今度は時間軸を1時間足・2時間足だけに絞り、上値ブレイクアウトの期間・下値ブレイクアウトの期間を10~55期間の範囲にして1期間刻みでテストしています。以下のようにパラメーターを設定すればOKですね。

# バックテストのパラメーター設定

chart_sec_list  = [ 3600,7200 ]
buy_term_list   = np.arange(10,56) # 10~55までの連番の配列を作る
sell_term_list  = np.arange(10,56)

この条件でテストしてみると以下のようなCSVファイルが出力されます。

・CSV結果出力ファイル1
・CSV結果出力ファイル2

ファイル1は先ほどと同じくトレード回数が100回に満たなかったテスト結果を削除したもの、ファイル2は削除せずに全ての結果を残したものです。今回はファイル2を使ってみましょう。

▽ PFで並び替え後の成績ベスト30(1時間足)

1時間足の上位の成績を見ると、明らかに上値ブレイクアウト(買い)の期間は40前後、下値ブレイクアウト(売り)の期間は20前後が良さそう、という傾向が見えます。また判定基準は「終値」が上位を独占していますね。

▽ PFで並び替え後の成績ベスト30(2時間足)

2時間足の上位の成績を見ると、上値ブレイクアウト(買い)の期間は50以上、下値ブレイクアウト(売り)の期間は20~30期間がいいようですね。またやはり「終値」でブレイクアウトの判定をした方が、トレード回数は減るものの成績が良くなる傾向にありそうです。

より詳しく分析したい方は、上位のパラメーターを前回までの記事で学習した「月別集計」「資産曲線」などを使って調べてみるといいでしょう。

Pythonコードの解説

さて、それでは新しく変更した部分のpythonコードを解説しておきましょう!

基本的なコードは前回まで勉強した内容とほとんど変わっていないことに気付いたと思います。要するに、今まで作成してきた「While文でローソク足を回してバックテストし、最終成績をbacktest() で集計するコード」を、まるまる上記で説明した「総当たりのfor文」の中に入れるだけですね。

図にすると以下のような感じです。

⇒ 各パラメーターの組み合わせでループする ⇒ ローソク足のデータを1本ずつループする ⇒ 全てのトレード成績を配列に記録する ⇒ トレード結果の配列を集計してpandasで表にする ⇒ 各バックテストの結果を計算して配列に記録する⇒ バックテスト結果の配列を集計してpandasで表にする ⇒ 最終結果の表をCSVに出力する

こののような入れ子構造になっている点だけ混乱しなければ、上記のコードで難しい箇所はなかったと思います。

今までの記事では、バックテスト集計用の関数 backtest() は、単に集計・計算した結果を表示(print)するだけでしたが、今回のパラメーター最適化のコードでは、各バックテストの結果を返すように変更する必要があります。

そのためバックテスト集計用の関数に以下の部分を足しています。

# バックテストの計算結果を返す
result = {
	"トレード回数"     : len(records),
	"勝率"             : round(len(records[records.Profit>0]) / len(records) * 100,1),
	"平均リターン"     : round(records.Rate.mean(),2),
	"最大ドローダウン" : -1 * records.Drawdown.max(),
	"最終損益"         : records.Profit.sum(),
	"プロフィットファクタ―" : round( -1 * (records[records.Profit>0].Profit.sum() / records[records.Profit<0].Profit.sum()) ,2)
}

return result

各バックテストの結果を配列に記録して、それを最後にpandasで1つの表にする方法は、前回までの記事の「各トレード結果を配列に記録して最後に集計する方法」と全く同じなので、説明は省きます。

drop() で不要な行を削除する

唯一新しく登場したのは、最後のdrop()の箇所でしょう。

今回のコードでは、トレード回数の少ないパラメーターの組み合わせを除外できるようにしています。それが以下の部分のコードです。

# トレード回数が100に満たない記録は消す
df.drop( df[ df["トレード回数"] < 100].index, inplace=True )

表名.drop()は、行のindexを指定することで、その行を表から削除することのできる関数です。これを使って以下のような仕組みで、トレード回数の少なすぎるバックテスト結果を削除しています。


# トレード回数が100未満のデータを抽出
df[df["トレード回数"] < 100]

# トレード回数が100未満のデータの行のindexを取得
df[df["トレード回数"] < 100].index

# トレード回数が100未満のデータを行ごと削除
df.drop( df[df["トレード回数"] < 100].index )

なお、inplce=Trueは、現在の表データに結果をそのまま上書きする、という意味です。以上でコードの解説はおしまいです!

まとめ

この章の前の方の記事でも解説したように、このようなパラメーター調整には常にカーブフィッティングの問題がつきまといます。以下のような点に注意しておきましょう。

(1)パラメーターの数を増やし過ぎないようにする
(2)おおまかな傾向を捉えることを目的にする
(3)トレード数(サンプル)が少なくならないようにする

一般論としていえば、今回のドンチャン・チャネル・ブレイクアウトのように広範なパラメーターのどの値を使ってもプラスの期待値が出る、という場合で、おおまかな傾向を捉える目的(例えば、20期間あたりが一番良さそう)でパラメーターを最適化するのは問題ありません。

一番問題があるのは、例えば、「6」という数字を使えば利益が出る、「8」という数字を使うとマイナスに転落する、「13」という数字を使えばプラスになる、といった全く連続性も傾向もないパラメーターを恣意的に調整することです。これをすると、過去データでしか利益の出せないパラメーター調整になります。

次回

今回の記事で、いったんバックテスト編はおしまいです。

ドンチャン・チャネルブレイクアウトで買い/売りの期間を別々に設定できるようにする

次回の記事「pythonで最適なパラメーターを自動的に探索する」での練習にあたって、まず先に以下のようなパラメーターを自由に設定できるようにドンチャン・ブレイクアウトのコードを改良しておきましょう。

設定可能なパラメーター

1)X分足の時間軸を使う
2)n期間の最高値のブレイクアウトで買う
3)m期間の最安値のブレイクアウトで売る
4)ブレイクアウトの判定に、高値/安値 or 終値/終値 を使う

今回の記事では、これらのパラメーターを設定できるようにコードを修正する方法を解説し、最後に1時間足で買い(30期間)、売り(20期間)、判定基準(終値)でのバックテスト結果を見てみましょう。

Pythonコード

先に改良版のコード全体を記します。


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)を使用
}
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 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"]["exist"] = True
		flag["order"]["side"] = "BUY"
		flag["order"]["price"] = round(data["close_price"] * lot)

	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"]["exist"] = True
		flag["order"]["side"] = "SELL"
		flag["order"]["price"] = round(data["close_price"] * 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"]["price"] = flag["order"]["price"]
	
	return flag


# 手仕舞いのシグナルが出たら決済の成行注文 + ドテン注文 を出す関数
def close_position( data,last_data,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 )
			flag["position"]["exist"] = False
			flag["position"]["count"] = 0
			
			flag["records"]["log"].append("さらに" + str(data["close_price"]) + "円で売りの指値注文を入れてドテンします\n")
			
			# ここに売り注文のコードを入れる
			
			flag["order"]["exist"] = True
			flag["order"]["side"] = "SELL"
			flag["order"]["price"] = round(data["close_price"] * lot)
			

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


# 各トレードのパフォーマンスを記録する関数
def records(flag,data):
	
	# 取引手数料等の計算
	entry_price = flag["position"]["price"]
	exit_price = round(data["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"] )
	
	# 値幅の計算
	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"],
		"Periods"  :  flag["records"]["holding-periods"],
		"Slippage" :  flag["records"]["slippage"]
	})
	
	# 総損益の列を追加する
	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("-----------------------------------")
	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("-----------------------------------")
	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("")
	print("最大の勝ちトレード :  {}円".format(records.Profit.max()))
	print("最大の負けトレード :  {}円".format(records.Profit.min()))
	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,
		"count" : 0
	},
	"position":{
		"exist" : False,
		"side" : "",
		"price": 0,
		"count":0
	},
	"records":{
		"date":[],
		"profit":[],
		"return":[],
		"side":[],
		"holding-periods":[],
		"slippage":[],
		"log":[]
	}
}


last_data = []
i = 0
while i < len(price):

	# ドンチャンの判定に使う期間分の安値・高値データを準備する
	if len(last_data) < buy_term or len(last_data) < sell_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 = 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)

これでドンチャン・ブレイクアウトの自動売買ロジックに、上値のブレイクアウトと下値のブレイクアウトとで異なる期間を設定できるようになりました。

またブレイクアウトの判定基準について、高値・安値を使うことも、終値を使うこともできるようになりました。

コードの解説

今までの「最もシンプルなドンチャン・ブレイクアウト」では、買い・売りともに同じ期間(例えば30期間)を使っていました。そのため、ブレイクアウトを判定する際に、比較に使うための過去のローソク足データは、配列に30個ぴったり用意しておけばOKでした。

しかし買い・売りで別々の期間を設定する場合は、そうはいきません。そこで、ドンチャンブレイクアウトを判定するための関数を以下のように改良します。

# ドンチャンブレイクを判定する関数
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}

ここでは、過去のローソク足データ(last_data)を30個丁度にキープするのではなく、どんどん溜めていって、後ろから欲しい数だけを取り出すようにしています。

例えば、上値ブレイクアウトの基準が30期間、下値ブレイクアウトの基準が20期間であれば、買いエントリーの判定では last_data の後ろから30コを取り出し、売りエントリーの判定では last_data の後ろから20コを取り出すようにしています。

ここで新しく使っているのが、スライス(:)という記述方法です。

スライスとは

スライスは、配列データの範囲を指定したり、pandasの表データで行や列の範囲を指定するときに、非常に便利な記述方法です。以下のように書くことで、配列や表(行・列)の範囲を指定することができます。

[2:5] 3番目~5番目まで
[:5]   先頭~5番目まで
[3:]   4番目~最後まで
[-3:] 後ろから3番目~最後まで

プログラミングの世界では、先頭は0番目から数えるので、指定した数字から1つズレる点に注意してください。最後はズレません。

number = [0,1,2,3,4,5,6,7]

print( number[2:5] )
[2, 3, 4]

print( number[:5] )
[0, 1, 2, 3, 4]

print( number[3:] )
[3, 4, 5, 6, 7]

print( number[-3:] )
[5, 6, 7]

そのため、上値ブレイクアウトの判定に必要な期間分のローソク足データは、以下のように指定することができます。

last_data[ (-1* buy_term): ]

この知識を使うと、ブレイクアウトの判定に必要な期間のデータを取り出しその中から最大値を抽出する、というコードを以下の1行で書くことができます。

highest = max(i["high_price"] for i in last_data[ (-1* buy_term): ])

2.ブレイクアウトの判定基準に「終値」を使えるようにする

これは簡単ですね。
変更前のコードで、data["high_price"] と指定していた部分を変数にすればいいだけです。

# 変更前
if data["high_price"] > highest:
	return {"side":"BUY","price":highest}

# 変更後
#--------設定項目--------
judge_price={
  "BUY" : "high_price", # high_price か close_price を指定
  "SELL": "low_price"   # low_price  か close_price を指定
}
#-----------------------

if data[ judge_price["BUY"] ] > highest:
	return {"side":"BUY","price":highest}

3.メイン処理のループ文の変更

最後にメイン処理のWhile文ループの中身を修正します。

# 変更前
last_data = []
i = 0
while i < len(price):

	# ドンチャンの判定に使う期間分の安値・高値データを準備する
	if len(last_data) < term:
		(略)
		continue

# 変更後
	# ドンチャンの判定に使う期間分の安値・高値データを準備する
	if len(last_data) < buy_term or len(last_data) < sell_term:
		(略)
		continue

売り・買いエントリーで別々の期間を使用することになるので、最初に buy_term と sell_term の両方の期間分を超えるまでデータを蓄積します。

# 変更前
	# 過去データを30個に保つために先頭を削除
	del last_data[0]

# 変更後
	(削除)

またスライスを使うことで、過去のローソク足データをぴったりn個にキープしておく必要がなくなったので、上記の行は削除しておきます。これで完成です!

あとはログファイルの出力が「直近の安値が~」「直近の高値が~」になっているので、そこは各自で修正しておいてください。(上記のコードでは修正済)

実行結果

試しに1時間足で、買いエントリーは30期間の最高値ブレイクアウト、売りエントリーは20期間の最安値ブレイクアウト、各ブレイクアウトの判定には終値を使う、という場合の成績を見てみましょう。

# パラメーター設定
chart_sec = 3600
buy_term =  30
sell_term = 20
judge_price={
  "BUY" : "close_price",
  "SELL": "close_price"
}

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

損益曲線

成績

--------------------------
テスト期間:
開始時点 : 2017/08/15 01:00
終了時点 : 2018/04/22 08:00
6000件のローソク足データで検証
--------------------------
バックテストの結果
-----------------------------------
買いエントリの成績
-----------------------------------
トレード回数       :  40回
勝率               :  52.5%
平均リターン       :  4.39%
総損益             :  1628677円
平均保有期間       :  70.1足分
-----------------------------------
売りエントリの成績
-----------------------------------
トレード回数       :  40回
勝率               :  52.5%
平均リターン       :  1.77%
総損益             :  1223424円
平均保有期間       :  74.7足分
-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  80回
勝率               :  52.5%
平均リターン       :  3.08%
平均保有期間       :  72.4足分

最大の勝ちトレード :  545284円
最大の負けトレード :  -220610円
最大ドローダウン   :  -381395円 / -13.2%
利益合計           :  4905987円
損失合計           :  -2053886円

最終損益           :  2852101円
手数料合計         :  -84811円
-----------------------------------
月別の成績
-----------------------------------
2017年8月の成績
-----------------------------------
トレード数         :  4回
月間損益           :  -27411円
平均リターン       :  -1.34%
月間ドローダウン   :  -13500円
-----------------------------------
2017年9月の成績
-----------------------------------
トレード数         :  10回
月間損益           :  88980円
平均リターン       :  1.96%
月間ドローダウン   :  -20785円
-----------------------------------
2017年10月の成績
-----------------------------------
トレード数         :  10回
月間損益           :  86498円
平均リターン       :  2.21%
月間ドローダウン   :  -92319円
-----------------------------------
2017年11月の成績
-----------------------------------
トレード数         :  9回
月間損益           :  375451円
平均リターン       :  5.46%
月間ドローダウン   :  -114141円
-----------------------------------
2017年12月の成績
-----------------------------------
トレード数         :  6回
月間損益           :  988615円
平均リターン       :  8.9%
月間ドローダウン   :  -220610円
-----------------------------------
2018年1月の成績
-----------------------------------
トレード数         :  14回
月間損益           :  425674円
平均リターン       :  1.03%
月間ドローダウン   :  -381395円
-----------------------------------
2018年2月の成績
-----------------------------------
トレード数         :  11回
月間損益           :  374125円
平均リターン       :  3.46%
月間ドローダウン   :  -316547円
-----------------------------------
2018年3月の成績
-----------------------------------
トレード数         :  9回
月間損益           :  305010円
平均リターン       :  3.1%
月間ドローダウン   :  -327540円
-----------------------------------
2018年4月の成績
-----------------------------------
トレード数         :  7回
月間損益           :  235159円
平均リターン       :  3.93%
月間ドローダウン   :  -38818円

※ ここでの月間ドローダウンは、その月に経験している継続中の最大ドローダウンのことです。その月だけのドローダウンを計算しているわけではありません。

このように、同じドンチャン・ブレイクアウトのロジックでも、買い判定の期間、売り判定の期間、ブレイクアウトの判定を高値(安値)でするか、終値でするか、などの設定によってかなり異なる結果になります。

しかしどのようなパラメーター設定が最適なのかを手作業で探すのは困難です。以下のようなパラメーター設定だけでも、その組み合わせの数は膨大だからです。

最適なパラメーターの組み合わせ

1)使用する時間軸
⇒ 15分足/30分足/1時間足/2時間足/6時間足

2)上値ブレイクアウトの判定期間
⇒ 10/15/20/25/30/35/40/45

3)下値ブレイクアウトの判定期間
⇒  10/15/20/25/30/35/40/45

4)判定に使用する価格
⇒  高値・安値/終値・終値

上記の組み合わせを試すだけでも、そのパターンは640通りにもなります。ちょっと手作業で1つ1つ結果をテストする気にはなりませんよね(笑)

こういうときこそpythonの出番です! 次回の記事では、pythonで自動的に最適なパラメーターを探索する方法を解説します!

次回記事:一番成績のいい最適なパラメーターを自動で探索する

Pandasを使ってBTCFXの自動売買BOTの月別の成績を集計しよう!

前回の記事では、ドンチャンブレイクアウトの自動売買BOTの成績を、matplotlibを使って視覚的なグラフにする方法や、最大ドローダウンを計算する方法を説明しました。

しかし自動売買BOTの安定性を評価するためには、最終的な損益だけでなく途中の経過、つまり月別の成績を把握しておくことも必須です。

今回の記事では、これらの成績(平均リターン・ドローダウン・勝率など)を月別に集計する方法を解説します!

Pandasを使ってデータを集計しよう!

前回までのやり方の問題点

前回までの記事では、なるべくpythonのプログラミング初心者でもわかりやすいように、すべてのトレード成績を配列に記録していました。

flag変数に以下のような配列を用意して、ポジションを手仕舞うたびに各トレードの成績や指標を計算し、各配列に append していましたよね。

flag = {
	"records":{
		"buy-count": 0,
		"buy-winning" : 0,
		"buy-return":[],
		"buy-profit": [],
		"buy-holding-periods":[],
		
		"sell-count": 0,
		"sell-winning" : 0,
		"sell-return":[],
		"sell-profit":[],
		"sell-holding-periods":[],
		
		"drawdown": 0,
		"date":[],
		"gross-profit":[0],
		"slippage":[],
		"log":[]
	}
}

しかしこのやり方だと、買いエントリーと売りエントリーに分けて成績を記録しているため、違う切り口でデータを集計したいときに凄く不便です。

例えば、「売り・買いで分けずに、月別の平均リターンや勝率を検証したい」と思った場合、売り・買いそれぞれのデータを(順番がおかしくならないように)くっつけて、それを日付で分割する、というややこしい処理をしなければなりません。

新しく月別の勝率を記録する変数を用意する方法もありますが、そうするとflag変数がどんどん増えていってしまい、pythonコードがどんどん長くややこしくなります。

もっとシンプルにデータを記録しておいて、あとから欲しい数字だけを上手に集計する方法はないでしょうか?

表のような形式で成績を記録する

もし以下のような表形式で各トレードの成績を記録しておけば、どうでしょうか? Excelのシートのようなイメージですね。

日付 損益 方向 保有期間
2018-02-04 20143円 買いエントリ 6足分
2018-02-04 22121円 売りエントリ 14足分
2018-02-05 33412円 売りエントリ 13足分
2018-03-06 -2312円 買いエントリ 15足分
2018-03-08 -16125円 売りエントリ 9足分

もし、このような表データを1つの変数として保持することができ、後から以下のような計算ができれば便利です。

————————————————————–
例)
・B列の「損益」のうちC列が「買い」のものだけを合計する
・A列の日付が2018-02のものだけで、B列の「損益」の平均値を求める
・後からE列を追加してそこに左列の損益の合計値(累積和)を追加する
・B列の「損益」のうち値がプラスのもの(=勝ち数)だけをカウントする
————————————————————–

このようなことは、Excelシートだと簡単にできますよね。同じことがpythonでも出来ないでしょうか?

実は、全く同じようなことがpythonでできるライブラリがあります。それがPandasデータフレームです。

Pandasは表計算ソフトのようなもの

Pythonを勉強しはじめた初心者の方でも、「Pandas」という言葉を聞いたことがあるかもしれません。「なんだか上級者向きの難しい奴でしょ?」というイメージをお持ちの方も多いかもしれません。

しかしPandasは全く難しくありません。要するに「Excelのような表計算ソフトと同じようなものだ」と思えば、文系の方でも少し親近感が湧きますよね。

この記事では、バックテストのデータ集計に必要なpandasの使い方を、1回でほぼ全て習得することを目指します! ぜひ今回の記事でpandasの使い方をマスターしてしまいましょう!

Pandasの使い方をマスターする

1)まずは表型のデータを作ってみよう!

pandasでは、複数行・複数列にわたる表形式のデータのことを「DataFrame」といいます。まずは練習としてDataFrame型の変数を1つ作ってみましょう!

今回は、以下のような5回分のトレード成績を記録した配列を用意しました。これは先ほど表の例と全く同じものです。今回はこちらを例に説明していきます。


# 今回の練習で使う5回分のトレード成績のサンプル

Date = ["2018-02-04","2018-02-04","2018-02-05","2018-03-06","2018-03-08"]
Profit = [20143,22121,33412,-2312,-16125]
Side = ["BUY","SELL","SELL","BUY","SELL"]
Rate = [0.024,0.012,0.014,-0.022,-0.019]
Periods = [6,14,13,15,9]

まずは上記のように、「日付」「各トレードの損益」「エントリーの方向」「保有期間」など、表を作るために必要な最低限の情報を記録した配列を用意します。

「トレード回数」「ドローダウン」「総損益」「買いエントリーの勝率」など、後から表をもとに計算できる数字は、この時点で準備する必要はありません。つまりトレードするたびに記録する数字は、上記の5種類だけでOKということです。

すべてのトレードが終わったら、上記の配列をくっつけて1つのDataFrame型の表データに変換します。それが以下のコードです。

import pandas as pd

# 上の配列データをくっつけて1つの表データにする

records = pd.DataFrame({
	"Date":pd.to_datetime(Date), # 文字を日付型に変換
	"Profit":Profit,
	"Side":Side,
	"Rate":Rate,
	"Periods":Periods
})

各配列を縦向きの「列」として結合し、1つのDataFrame型の表にして records という名前の変数に入れています。

これを公式にすると、以下のような感じですね。

表型の変数名 = pd.DataFrame({
	"列名" : データ配列 ,
	"列名" : データ配列 ,
	"列名" : データ配列 ,
	"列名" : データ配列
})

日付データだけは、pd.to_datetime()を使って、テキスト型から日付型に変換しておきます。その理由は、前回の記事で解説しています。

さて、これで先ほどイメージした表データをpythonで作ることができました! 念のためにprintしてみましょう!

print( records )

以下のようになります。

▽ 上記のコードで作った配列データ

▽ 今回の練習で使う表

欲しい数字だけを集計して取り出そう!

では、次にこの表データ(records)から「欲しい数字」を取り出していきましょう。 以下、よく使うパターンをまとめて列挙していきます。

1.損益データだけを取り出す

print( records.Profit )

表名.列名で取り出す

列名が英文字の場合は、records.profit のように「表名.列名」指定できます。 列名が日本語の場合は、records[“損益”] のように指定します。

2.平均リターンを計算する

print( records.Profit.mean() )

表名.列名.mean()で列の平均値を計算する

3.損益の合計を計算する

print( records.Profit.sum() )

表名.列名.sum()で列の合計値を計算する

3.特定の行のデータを取り出す

print( records.iloc[2] )

表名.iloc[2]で2行目だけを取り出す

locは、loc[ 行名, 列名 ] のかたちで表データの範囲を指定する記述方法です。一方、ilocは、iloc[ 行番号, 列番号 ]のかたちで表データ範囲を指定する記述方法です。

これらは以下のサイトに詳しい説明があります。

参考:at/iat/loc/ilocの使い方

4.買いエントリーのデータを取り出す

print( records[records.Side.isin(["BUY"])] )

表名[ 表名.列名.isin([値]) ]でSide列が”BUY”の行だけ取り出す

5.買いエントリーの損益だけを取り出す

print( records[records.Side.isin(["BUY"])].Profit )

表名[ 表名.列名.isin([値]) ].列名でSide列が”BUY”の行のProfit列を取り出す

6.買いエントリーの平均リターンを計算する

print( records[records.Side.isin(["BUY"])].Profit.mean() )

表名[ 表名.列名.isin([値]) ].mean() でSide列が BUYの行のProfit列の平均値を計算する

7.トレード回数をカウントする

# 全トレード数をカウントする
print( len(records) )

# 買いエントリーのトレード数をカウントする
print( len( records[records.Side.isin(["BUY"])] ))

len(表名) で行数を数える / len(表名[ 表名.列名.isin([値]) ])でSide列が BUYの行の行数を数える

len( 表名 )のかわりに、表名.列名.count() と書いても構いません。

8.勝率を計算する

# 全体の勝ちトレードの数をカウントする
print( len(records[records.Profit > 0]) )

# 全体の勝率を計算する
print( len(records[records.Profit > 0]) / len(records) * 100 )

Profit列がプラスの行だけ選択する / len(表名)で勝利トレードの行数を数えて全体の行数で割る

勝率は、勝ちトレード数 / 全体のトレード数 で計算できます。そのため、Profit列が0以上の行の数を数えて、それを全体の行数で割れば、全体の勝率が計算できます。

# 買いエントリーの勝ちトレードだけを取り出す
print( records[records.Side.isin(["BUY"]) & records.Profit>0] )

# 買いエントリーの勝ちトレード数をカウントする
print( len(records[records.Side.isin(["BUY"]) & records.Profit>0]) )

# 買いエントリーの勝率を計算する
print( len(records[records.Side.isin(["BUY"]) & records.Profit>0]) / len(records.Side.isin(["BUY"])) * 100 )

同じことを、Side列が BUY の行だけに限定して行えば、買いトレードの勝率を計算できます。

9.各日付時点での総損益を新しい列に追加する

records["Gross"] = records.Profit.cumsum()

表名.列名.cumsum() でProfit列の累積和を計算する

後から新しい列を追加したいときは、records[“新しい列名”] = 式 と書くことで新しい列を作成できます。また総損益とは、要するに利益の累積和のことなので、Profit列のcumsum()を計算します。

10.各日付時点のドローダウンを新しい列に追加する

records["Drawdown"] = records.Gross.cummax().subtract( records.Gross )

cummax()で累積最大値を調べることができます。累積最大値とはその行までの最大値のことです。例えば、Gross列の2行目までの累積最大値は42264、3行目までの累積最大値は75676になります。

最大ドローダウンとは、要するに「 n行目までの総利益の累積最大値から n行目の総利益を差し引いたもの」なので、上記の式で各行の最大ドローダウンを計算できます。引き算には、subtract()を使います。

終了

はい!
ここまでで、今までの記事で勉強してきたバックテストの成績の指標は、すべてpandasに置き換えることができました! 何となくpandasの便利さを実感できたのではないでしょうか?

月別にバックテスト結果を集計しよう!

pandasではさらにグルーピングという便利な機能があります。上記で作成した表データを、月ごとにグルーピングしてみましょう!

以下のように書くだけです。


records["月別集計"] = records.Date.apply(lambda x: x.strftime('%Y/%m'))
grouped = records.groupby("月別集計")

month_records = pd.DataFrame({
	"Gross" : grouped.Profit.sum(),
	"Rate" : round(grouped.Rate.mean()*100,1),
	"Drawdown" : grouped.Drawdown.max(),
	"Periods" : grouped.Periods.mean()
	})
print("---------------------------------------------------")
print( month_records )

groupby()でグループ分けできる!

groupby( 列名 )で特定の列を指定すると、その列の値が同じもの同士をグループ化することができます。例えば、groupby(“Side”)でグループ分けすれば、買いエントリーと売りエントリーとでデータをグループ分けできます。

groupby(列名)で列の値が同じもの同士をグループ化する

ここでは「月別」にデータをグループ分けします。ただし2017年3月と2018年3月が、同じ「3月」でグルーピングされると困りますよね。「〇年〇月」までをセットでグルーピングしなければなりません。

そのため、最初に以下の1行を書いています。

records["月別集計"] = records.Date.apply(lambda x: x.strftime('%Y/%m'))

ここでは、グループ分けをするために専用の新しい列を作っています。

もともと存在したDate列のデータを「年/月」のかたちに変形し、それを新しく作った「月別集計」という列に入れる、という処理をしています。

補足)lambda と apply() で全行のデータを変形する

ここには、apply()とlambdaという新しい記述方法が登場しています。

lambda は「 lambda 変数 : 式 」と書いて、変数を式のかたちに変形する記述方法です。表データに対して、apply( lambda 引数 : 式 ) と記述することで、表データのすべての行に対して、まとめて同じ式の処理を実行することができます。

以下の例をみるとわかりやすいでしょう。

(例)

number = pd.DataFrame({"data":[10,20,30,40,50]})
number = number.apply(lambda x : x+5)

# これを実行するとデータは{ "data":[15,25,35,45,55] } になります。

for文のような処理を、表(DataFrame型)に適用するための記述方法ですね。もっと興味がある方は、apply()やlambdaを調べて勉強してみてください。

さて、これで「年/月」という新しい列ができたので、この列を基準にグループ分けをします。それが以下の行です。

# グループ化する処理
grouped = records.groupby("月別集計")

この1行だけで、「月別集計」の列が同じもの同士をグループ化することができます。

グループ化したデータの使い方

グループ化したデータの結果は、以下のようなかたちで取り出すことができます。

# グループ化したデータから値を取り出す

grouped.Profit.sum()  # グループごとのProfit列の合計値の配列を返す
grouped.Profit.mean() # グループごとのProfit列の平均値の配列を返す
grouped.Profit.max()  # グループごとのProfit列の最大値の配列を返す

上記の結果を実行すれば、例えば、「2018/2」「2018/3」「2018/4」などのグループの、それぞれの利益の合計値、平均値、最大値を取り出すことができます。

月別のデータ集計といっても、欲しい数字は項目によって異なります。例えば、総損益なら合計値(sum)が必要ですし、平均リターンなら平均値(mean)が必要です。最大ドローダウンであれば最大値(max)が必要でしょう。

さて、それぞれ必要なデータを取り出したら、それを「列」として結合して新しい表(DataFrame)を作ります。配列データをくっつけて1つの表データにする方法は、1番最初に勉強しました。覚えていますよね?


month_records = pd.DataFrame({
	"Gross" : grouped.Profit.sum(),
	"Rate" : round(grouped.Rate.mean()*100,1),
	"Drawdown" : grouped.Drawdown.max(),
	"Periods" : grouped.Periods.mean()
	})
print("---------------------------------------------------")
print( month_records )

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

実行結果

これで最初の5回分のトレード成績を、以下のように月別にグルーピングできました。

月別集計 最大ドローダウン 平均リターン 月間損益 平均保有期間
2018/2 0円 1.7% 75676円 11期間
2018/3 -18437円 -2.0% -18437円 12期間

練習問題

ドンチャン・ブレイクアウトBOTの月別リターンを集計しよう!

では、最後にここまで勉強したpandasの知識を使って、前回までの記事で作成した「ドンチャンブレイクアウトBOT」のバックテストのコードを改良しましょう!

以下のように月別の成績を集計して表示できるようにします! 具体的なコードは以下の記事に記載していますが、まずはご自身で挑戦してみてください。


・解答のサンプル記事はこちら