Pandasの練習問題ーBTCFXのドンチャンブレイクアウトBOTの月別成績を集計する

前回の記事「Pandasを使ってBTCFXの自動売買BOTの月別の成績を集計しよう!」の練習問題の回答コードです。

1時間足での30期間ドンチャン・ブレイクアウトBOTの月別の成績を集計してみましょう! なお、今回のコードではPandasの集計方法を使って以下のような指標も加えておきました。

・全トレードの回数と勝率
・全トレードの平均リターン
・最大の勝ちトレードでの利益
・最大の負けトレードでの損失
・最終的な利益合計
・最終的な損失合計

これらも自動売買BOTの成績を評価する際に参考にしてみてください。


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

#-----設定項目

chart_sec = 3600    # 1時間足を使用
term = 30           # 過去n期間の設定
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"]) + "\n"
	flag["records"]["log"].append(log)
	return flag


# ドンチャンブレイクを判定する関数
def donchian( data,last_data ):
	
	highest = max(i["high_price"] for i in last_data)
	if data["high_price"] > highest:
		return {"side":"BUY","price":highest}
	
	lowest = min(i["low_price"] for i in last_data)
	if data["low_price"] < 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(term,signal["price"],data["high_price"]))
		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(term,signal["price"],data["low_price"]))
		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(term,signal["price"],data["low_price"]))
			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(term,signal["price"],data["high_price"]))
			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=1483228800)

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):

	# ドンチャンの判定に使う過去30期間分の安値・高値データを準備する
	if len(last_data) < 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 )
	
	
	# 過去データを30個に保つために先頭を削除
	del last_data[0]
	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時間足でシンプルな30期間のドンチャンブレイクBOTを、2017年8月~2018年4月にかけて運用した場合の成績です。

損益グラフ

ドンチャン・ブレイクアウトBOTの成績


(base) C:\Pydoc>python test.py
--------------------------
テスト期間:
開始時点 : 2017/08/13 08:00
終了時点 : 2018/04/20 15:00
6000件のローソク足データで検証
--------------------------
バックテストの結果
-----------------------------------
買いエントリの成績
-----------------------------------
トレード回数       :  49回
勝率               :  51.0%
平均リターン       :  3.04%
総損益             :  1090811円
平均保有期間       :  62.2足分
-----------------------------------
売りエントリの成績
-----------------------------------
トレード回数       :  49回
勝率               :  42.9%
平均リターン       :  0.85%
総損益             :  670134円
平均保有期間       :  56.7足分
-----------------------------------
総合の成績
-----------------------------------
全トレード数       :  98回
勝率               :  46.9%
平均リターン       :  1.94%
平均保有期間       :  59.5足分

最大の勝ちトレード :  545284円
最大の負けトレード :  -296151円
最大ドローダウン   :  -483332円 / -24.4%
利益合計           :  4591094円
損失合計           :  -2830149円

最終損益           :  1760945円
手数料合計         :  -104961円
-----------------------------------
月別の成績
-----------------------------------
2017年8月の成績
-----------------------------------
トレード数         :  6回
月間損益           :  1552円
平均リターン       :  0.08%
月間ドローダウン   :  -20319円
-----------------------------------
2017年9月の成績
-----------------------------------
トレード数         :  10回
月間損益           :  139626円
平均リターン       :  3.02%
月間ドローダウン   :  -20785円
-----------------------------------
2017年10月の成績
-----------------------------------
トレード数         :  12回
月間損益           :  119928円
平均リターン       :  2.12%
月間ドローダウン   :  -50845円
-----------------------------------
2017年11月の成績
-----------------------------------
トレード数         :  11回
月間損益           :  405276円
平均リターン       :  4.89%
月間ドローダウン   :  -138695円
-----------------------------------
2017年12月の成績
-----------------------------------
トレード数         :  10回
月間損益           :  175018円
平均リターン       :  1.68%
月間ドローダウン   :  -364745円
-----------------------------------
2018年1月の成績
-----------------------------------
トレード数         :  14回
月間損益           :  425925円
平均リターン       :  1.08%
月間ドローダウン   :  -247323円
-----------------------------------
2018年2月の成績
-----------------------------------
トレード数         :  13回
月間損益           :  284263円
平均リターン       :  2.58%
月間ドローダウン   :  -483332円
-----------------------------------
2018年3月の成績
-----------------------------------
トレード数         :  11回
月間損益           :  155253円
平均リターン       :  1.07%
月間ドローダウン   :  -425155円
-----------------------------------
2018年4月の成績
-----------------------------------
トレード数         :  11回
月間損益           :  54104円
平均リターン       :  0.3%
月間ドローダウン   :  -216376円

月別の成績で見ても、すべての月でプラスの成績が出ています。
これはなかなか悪くない結果ですね。

こちらの月別の成績は、あくまで「ポジションを手仕舞った日時」を基準に区切っている点に注意してください。例えば、2月の成績はプラスになっていますが、これは1月から持ち越したポジションで大きな利益が出ているからです。もし2月からBOTの稼働を開始していたら、2月の損益はマイナスになります。

コードの解説

基本的には、前回の記事「Pandasを使って自動売買BOTの成績を月別に集計しよう!」で、例として解説したコードをそのまま使っています。特に難しいところは無かったのではないでしょうか。

以下のところだけ、前回の記事には登場していなかった書き方なので、追加で解説しておきます。

最大ドローダウン率

最大ドローダウン率とは、ある行の最大ドローダウンの金額を、その行までの最大資産額で割った数字です。特定の行までの最大値は、cummax()で取得できます。そのため、以下のような式になります。

records["DrawdownRate"] = round(records.Drawdown / records.Gross.cummax() * 100,1)

ただし実際に最大ドローダウン率を表示するときには注意が必要です。単にドローダウン率の中から最大値を選ぶだけだと、最大ドローダウンの金額と時期が一致するとは限らないからです。

例えば、「最大ドローダウン率でみると序盤の2月の30%が最大だけど、金額ベースでみると4月の300万円が最大ドローダウンだ」ということもあり得ます。

このとき私たちが知りたいのは、最大ドローダウン率ではなく、「最大ドローダウン金額が最大だったときのドローダウン率」であるはずです。そのため、以下のように記述します。


# 最大ドローダウンの行番号を取得
records.Drawdown.idxmax()

# 最大ドローダウンと同じ行のドローダウン率を取得
records.DrawdownRate.loc[records.Drawdown.idxmax()]

これで最大ドローダウンと同じ時期のドローダウン率を取得することができました。

pandasの表データのfor文処理

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) ))

pandasの表データを1行ずつループ処理したい場合、以下のように書くことができます。

for index,row in 表データ変数.iterrows():
		# 1行ずつ処理したい内容

index (インデックス)というのは、表データから特定の「行」を検索するときの「索引」のことです。

行には通常、1行ごとに先頭から 0,1,2,3,4,5.....などの行番号が割り振られていますが、それだと検索するときに不便です。縦の列にカラム名(ここでは Gross/Rate/Drawdownなど)があるのと同じように、行にも名前を付けることができます。それが index です。

今回のコードでは、pandas の groupby()で「月別」にデータをグループ化しているため、index には「2017/08」「2017/09」「2017/10」などのDate型のデータが指定されています。そのため、for文の中で index.year / index.month を指定することで、これらの数字を print できます。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です