Binance永久先物の世界でドテンくんと戯れる #1
(このメモはド素人の個人的な記録です。理論的な間違いや計算間違いが含まれている可能性が高いことにご注意ください)
きっかけ
このツイートにあったドテンくんというものが何か良く分からなかったので調べてみると、昔一世を風靡したスターBotだったらしいです。ただ、2018年の後半にはいわゆるボラのない冬の時代がやってきて、表舞台から去っていったようです。
つくり方を見てみると、この間richmanbtc先生が紹介していたRolling Rank特徴量を使えば簡単に実験できそうだったので、トレード手法探索の練習としてチャレンジしてみることにしました。
検証データについて
- 期間は2019/1 から2022/10/5までとしました。Binance永久先物は比較的新しい市場なので、大半の銘柄が2020年以降の上場になっています
- 銘柄は時価総額上位から10銘柄を選択しました
- 検証対称は1時間足としました
シミュレーションについて
- 1時間足のHigh / Lowそれぞれについてウィンドウ幅18のRolling Rankを計算します
- High側のRankが1のときに買いポジションを取ります
- Low側のRankが18のときに売りポジションを取ります
- 手数料とポジションを考慮して対数リターンの累積和を計算します
以下、適当に書いたシミュレーションのpythonコードです。Google Colabで動かしました。
# あらかじめロードしてあるdf_priceは、行が時間、列はsymbolとclose価格になっています
# 2019年から現在までの151銘柄分の1時間足のクローズ価格をまとめてひとつのデータフレームに入れておきます
# Google ColabではPandasのバージョンが古くrankがウィンドウ関数として使えないので
# applyを使ってrankを計算するための関数
# https://qiita.com/bauer/items/28a207d8a1b1eb741864
@nb.jit
def calc_last_rank(x):
return np.argsort(np.argsort(x))[-1]
def simulate_dotenkun(window_size = 18):
#_list_symbols = list(df_price['symbol'].unique())
#_list_symbols.sort()
#_list_symbols = ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'XRPUSDT', 'ADAUSDT', 'SOLUSDT', 'DOGEUSDT', 'DOTUSDT', '1000SHIBUSDT', 'MATICUSDT', 'TRXUSDT', 'AVAXUSDT', 'ETCUSDT', 'ATOMUSDT', 'UNIUSDT', 'LTCUSDT', 'NEARUSDT', 'LINKUSDT', 'XLMUSDT', 'XMRUSDT', 'BCHUSDT', 'ALGOUSDT']
_list_symbols = ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'XRPUSDT', 'ADAUSDT', 'SOLUSDT', 'DOGEUSDT', 'DOTUSDT', '1000SHIBUSDT', 'MATICUSDT']
_rows = len(_list_symbols)
_fig, _axs = plt.subplots(nrows = _rows, ncols = 3, figsize=(3 * 8, 5 * _rows))
_df_statistic = pd.DataFrame()
for _idx, _symbol in enumerate(tqdm(_list_symbols)):
_df_analysis = pd.DataFrame()
_df_analysis['close'] = df_price.loc[df_price['symbol'] == _symbol, 'close'].astype(float)
_df_analysis['logreturn'] = np.log(_df_analysis['close']).diff()
_df_analysis['high'] = df_price.loc[df_price['symbol'] == _symbol, 'high'].astype(float)
_df_analysis['low'] = df_price.loc[df_price['symbol'] == _symbol, 'low'].astype(float)
_df_analysis['high_rank'] = _df_analysis.loc[:, 'high'].rolling(window_size).apply(calc_last_rank, raw = True, engine = 'numba')
_df_analysis['low_rank'] = _df_analysis.loc[:, 'low'].rolling(window_size).apply(calc_last_rank, raw = True, engine = 'numba')
_df_analysis['position'] = np.nan
_df_analysis.loc[_df_analysis['high_rank'] == 1, 'position'] = 1.0
_df_analysis.loc[_df_analysis['low_rank'] == window_size, 'position'] = -1.0
# ブレイクした次の足でポジションをドテンするためにshiftする
_df_analysis['position'] = _df_analysis['position'].shift(1)
# ffillしてポジションを次のポジション変換まで維持し、初期のポジションを0クリアしておく
_df_analysis['position'] = _df_analysis['position'].ffill().fillna(0.0)
# ドテンなのでトレードを行うごとに手数料は2回分カウントする
_df_analysis['fee'] = 0
_df_analysis.loc[_df_analysis['high_rank'] == 1, 'fee'] = 0.08 * 0.01
_df_analysis.loc[_df_analysis['low_rank'] == window_size, 'fee'] = 0.08 * 0.01
_df_analysis['profit'] = _df_analysis['logreturn'] * _df_analysis['position'] - _df_analysis['fee']
_df_analysis['profit_cumsum'] = _df_analysis['profit'].cumsum()
_df_analysis_tradeonly = _df_analysis[_df_analysis['fee'] != 0].copy()
_df_analysis_tradeonly['time_trade'] = _df_analysis_tradeonly.index
_df_analysis_tradeonly['interval_trade'] = (-_df_analysis_tradeonly['time_trade'].diff(-1) / np.timedelta64(1, "h")).fillna(0).astype(float)
_df_analysis_tradeonly['profit_trade'] = -_df_analysis_tradeonly['profit_cumsum'].diff(-1)
_df_analysis_tradeonly.sort_values(['interval_trade', 'time_trade'], inplace = True)
_df_analysis_tradeonly['gain_trade'] = np.where(_df_analysis_tradeonly['profit_trade'] >= 0, _df_analysis_tradeonly['profit_trade'], 0)
_df_analysis_tradeonly['loss_trade'] = np.where(_df_analysis_tradeonly['profit_trade'] < 0, _df_analysis_tradeonly['profit_trade'], 0)
_df_analysis_tradeonly.set_index('interval_trade', drop = False, inplace = True)
_df_statistic.loc[_symbol, 'final_profit'] = _df_analysis['profit_cumsum'].iloc[-1]
_interval_trade_75pct = _df_analysis_tradeonly['interval_trade'].quantile(0.75)
_df_statistic.loc[_symbol, 'interval_75pct'] = _interval_trade_75pct
_df_statistic.loc[_symbol, 'interval_lt75pct_return'] = _df_analysis_tradeonly.loc[_df_analysis_tradeonly['interval_trade'] < _interval_trade_75pct, 'profit_trade'].sum()
_df_statistic.loc[_symbol, 'interval_lt75pct_winrate'] = len(_df_analysis_tradeonly.loc[(_df_analysis_tradeonly['interval_trade'] < _interval_trade_75pct) & (_df_analysis_tradeonly['profit_trade'] > 0), 'profit_trade']) / len(_df_analysis_tradeonly.loc[_df_analysis_tradeonly['interval_trade'] < _df_analysis_tradeonly['interval_trade'].quantile(0.75), 'profit_trade'])
_df_statistic.loc[_symbol, 'interval_gt75pct_return'] = _df_analysis_tradeonly.loc[_df_analysis_tradeonly['interval_trade'] >= _interval_trade_75pct, 'profit_trade'].sum()
_df_statistic.loc[_symbol, 'interval_gt75pct_winrate'] = len(_df_analysis_tradeonly.loc[(_df_analysis_tradeonly['interval_trade'] >= _interval_trade_75pct) & (_df_analysis_tradeonly['profit_trade'] > 0), 'profit_trade']) / len(_df_analysis_tradeonly.loc[_df_analysis_tradeonly['interval_trade'] >= _df_analysis_tradeonly['interval_trade'].quantile(0.75), 'profit_trade'])
# 累積利益の描画
_ax = _axs[_idx, 0]
_ax.plot(_df_analysis.loc[:, 'profit_cumsum'], lw = 0.5)
_ax.axhline(color = 'red', ls = 'dashed')
_tm = pd.date_range('2019/1/1 00:00' ,'2022/10/5 00:00')
_ax.set_xlim(_tm[0], _tm[-1])
_ax.set_title(f'{_symbol} ドテンくん累積リターン')
# ホールド時間とリターンのヒストグラムの描画
_ax = _axs[_idx, 1]
_interval_trade = _df_analysis_tradeonly['interval_trade']
_profit_trade = _df_analysis_tradeonly['profit_trade'].fillna(0).astype(float)
_ax.scatter(_interval_trade, _profit_trade, marker = '.')
_ax.axvline(_interval_trade.quantile(0.25), color = 'red', ls = 'dashed')
_ax.axvline(_interval_trade.quantile(0.5), color = 'red', ls = 'dashed')
_ax.axvline(_interval_trade.quantile(0.75), color = 'red', ls = 'dashed')
_ax.axhline(_profit_trade.quantile(0.25), color = 'red', ls = 'dashed')
_ax.axhline(_profit_trade.quantile(0.5), color = 'red', ls = 'dashed')
_ax.axhline(_profit_trade.quantile(0.75), color = 'red', ls = 'dashed')
_ax.set_title(f'{_symbol} ドテンくん ホールド時間 vs リターン散布図')
# トレードごとのリターンのヒストグラム (ホールド時間75パーセンタイル以下) の描画
_ax = _axs[_idx, 2]
_ax.plot(_df_analysis_tradeonly['gain_trade'].cumsum(), label = 'gain')
_ax.plot(-1.0 * _df_analysis_tradeonly['loss_trade'].cumsum(), label = 'loss')
_ax.axvline(_df_analysis_tradeonly['interval_trade'].quantile(0.25), color = 'red', ls = 'dashed')
_ax.axvline(_df_analysis_tradeonly['interval_trade'].quantile(0.5), color = 'red', ls = 'dashed')
_ax.axvline(_df_analysis_tradeonly['interval_trade'].quantile(0.75), color = 'red', ls = 'dashed')
_ax.set_title(f'{_symbol} ドテンくん 累積リターン (ホールド時間別)')
_ax.legend()
print(_df_statistic.sort_values('final_profit', ascending = False).to_markdown())
_fig.show()
simulate_dotenkun(18)
結果
ドテンくんの挙動を直感的にイメージするためにまずグラフを書いてみました。
累積リターン
累積リターンのグラフを見るとわかる通り、利益は出ている銘柄と出ていない銘柄があります。有名どころのBTCUSDTとETHUSDTは損失が出ているので、商材屋から5万円でドテンくんロジックを買っちゃった人の中には残念なことになっている人がいそうですね。
トレードごとのホールド時間に対するリターンの散布図
トレードごとのホールド時間に対するリターンの散布図を見ると、とにかくホールド時間もリターンもテールが広いことがわかります。
リターンで見るとBNB, XRP, MATIC, XLMでは一撃80%越えの利益、DOGEに至っては一撃150%のビッグトレードがあります。一方で損失は利益ほど大きくなく、非対称な分布になっています。
ホールド時間で見ると、メジャーどころのBTCやETHでも170時間以上ホールドし続けるトレードがあり、しかもそれらがかなり大きな利益を出しています。
全体的に、ホールド時間が長いとトレードの利益が大きくなる傾向があり、ホールド時間が短いトレードは損失トレードが多いです。ドテンくんは順張りBotなので、順張りでエントリーした後ホールド時間が長ければ長いほどそのまま利益が伸びていくと言うことなのだと思います。
トレードごとのホールド時間に対する累積リターン
この傾向をより分かりやすく理解するために、トレードごとのホールド時間を横軸にした累積リターンをプロットしてみました。
例えばBTCUSDTではホールド時間下位75パーセントの取引は損失を累積しているだけです。最大で-1000%ほどまで到達しています。一方ホールド時間上位25パーセントのトレードが一気に利益を稼ぎ、最終的には約-80%の損益で終わっています。ホールド時間上位25パーセントの取引は、2019年から2022年までの3年間で原資を9倍にできていた…ということですね。やるじゃんドテンくん。
利益やトレード間隔など
さて、グラフからホールド時間が短い場合は損失が多そうだ…ということがわかったので、念のため表で確認しておきます。
銘柄 | 最終利益 | ホールド時間の75パーセンタイル値 [時間] | ホールド時間 < 75%の利益の合計 | ホールド時間 < 75%の勝率 | ホールド時間 >= 75%の利益の合計 | ホールド時間 >= 75%の勝率 |
---|---|---|---|---|---|---|
MATICUSDT | 2.9151 | 18 | -13.2521 | 0.225084 | 16.0845 | 0.68652 |
DOGEUSDT | 1.7794 | 19 | -12.2303 | 0.204365 | 13.9172 | 0.692737 |
SOLUSDT | 1.33705 | 18.25 | -14.4052 | 0.20913 | 15.7001 | 0.713376 |
BNBUSDT | 1.24696 | 22 | -12.8514 | 0.247993 | 14.0518 | 0.760526 |
ETHUSDT | 0.832331 | 19 | -13.4084 | 0.229249 | 14.1975 | 0.753425 |
ADAUSDT | 0.731794 | 21 | -14.885 | 0.231596 | 15.5904 | 0.747549 |
XRPUSDT | -0.411895 | 23 | -13.0178 | 0.228497 | 12.5208 | 0.735897 |
DOTUSDT | -0.542804 | 19 | -12.6068 | 0.23741 | 12.0249 | 0.703927 |
BTCUSDT | -0.778304 | 21 | -10.3856 | 0.202813 | 9.55039 | 0.696581 |
1000SHIBUSDT | -1.64928 | 20 | -9.61409 | 0.223256 | 7.91373 | 0.6621 |
- 利益の大半は18~20時間以上ホールドできたポジションから生み出される。長時間ホールドできた場合の勝率は7割近い。
- 短期間しかホールドできなかった場合は勝率2割程度でほぼ負ける
短期間しかホールドできなかった場合の小さな損の積み重ねを、長期間ホールドした場合の大きな利益で取り返すという構図がはっきりとわかります。
Binance永久先物の時間足一本分のリターンと戯れる #1では、リターンの絶対値の平均を元に利益を出すために必要な勝率を求めましたが、この方法はドテンくんのような偏りが強い執行戦略には通用しないでしょう。
おわりに
ということで、ドテンくんを題材にRolling Rank特徴量の練習をしたついでに、ドテンくんのことをより深く調べてみました。
ドテンくんロジックでは、ドテンするチャンスがない、一方向の強いトレンドがある状態でホールド時間が長いトレードが発生し、大きな利益をもたらすことがわかりました。
ということで、ドテンくんロジックを使うのであれば、マーケットのトレンドが一方向に偏っている時に使うのがよさそうです。そのようなマーケットの状況は長く続くことは珍しいので、必然的にバブル期やバブル崩壊時期の短期決戦で使うことになるのでしょう。
ドテンくんにMLを組み合わせて、おおむね24時間以内にドテンすることになる取引を排除することができれば、とても儲かる超ドテンくんが爆誕するかもしれません。どんな特徴量が効くか考えるのは面白そうですね。ドテンくんフォーエバー!
ただ、MLにとっては全体的にホールド時間が短い戦略の方が予測が簡単ですし、試行回数も稼げるのでよいのではないか…と言う気も色々調べていてしました。ドテンくんのような順張り戦略ではなく、richmanbtc先生チュートリアルのような逆張り戦略の方がML向きなんじゃないかと特に根拠なく思って今回の実験は終了です。
Discussion