🔥

Binance永久先物の世界で0.5 * ATRと戯れる

2022/10/09に公開

きっかけ

Binance永久先物の世界でドテンくんと戯れるを書いて、順張りロジックはホールド時間がながければ長いほどリターンが大きくなる傾向があるということがわかってきました。

では逆張りロジックはどうだろう? と言うことが気になってきます。

逆張りロジックで有名なものといえばrichman先生の有名なML Botチュートリアルで使われている0.5 * ATRを閾値にしたものだろう、ということでこちらも試してみることにしました。

検証データについて

  • 期間は2019/1 から2022/10/5までとしました。Binance永久先物は比較的新しい市場なので、大半の銘柄が2020年以降の上場になっています
  • 銘柄は時価総額上位から10銘柄を選択しました
  • 対象とする時間足は、richmanbtc先生のチュートリアルに合わせて15分足としました

シミュレーションについて

  • 毎時間、直前の時間足のクローズ価格+0.5ATRで売指値、直前の時間足のクローズ価格-0.5ATRで買指値を出します
  • ノーポジションの状態でどちらかの指値がヒットしたら全力でエントリーします
  • ポジションを持っている状態で、イグジットできる指値がヒットしたら、イグジットしてノーポジションにします
  • 以降、シミュレーションが終わるまで上記を繰り返します
  • 手数料は片道0.02%としました

コード

またもやかなり適当に書いたシミュレーションです。

# df_priceには例によって全銘柄の15分足が入れてあります

@nb.jit
def calc_position_return(buy_limit_hit, sell_limit_hit, buy_limit, sell_limit, close, force_exit_steps):
  position = np.empty(buy_limit_hit.shape, dtype=float)
  position[:] = 0.0
  
  ret = np.empty(buy_limit_hit.shape, dtype=float)
  ret[:] = 0.0

  _pos = 0.0
  _entry_price = np.nan
  _entry_step = np.nan

  for i in range(position.size):
    _prev_pos = _pos

    if _prev_pos > 0:
      if sell_limit_hit[i] == True:
        # 利確 (手数料は後で計算する)
        _pos = 0.0
        _ret = np.log(sell_limit[i]) - np.log(_entry_price)
      elif force_exit_steps > 0 and i - _entry_step >= force_exit_steps:
        _pos = 0.0
        _ret = np.log(close[i]) - np.log(_entry_price)
      else:
        # ポジションをそのまま維持
        _pos = _prev_pos
        _ret = 0.0
    elif _prev_pos < 0:
      if buy_limit_hit[i] == True:
        # 利確 (手数料は後で計算する)
        _pos = 0.0
        _ret = -(np.log(buy_limit[i]) - np.log(_entry_price))
      elif force_exit_steps > 0 and i - _entry_step >= force_exit_steps:
        _pos = 0.0
        _ret = -(np.log(close[i]) - np.log(_entry_price))
      else:
        # ポジションをそのまま維持
        _pos = _prev_pos
        _ret = 0.0
    else:
      if buy_limit_hit[i] == True:
        # ロングでエントリー
        _pos = 1.0
        _ret = 0.0
        _entry_price = buy_limit[i]
        _entry_step = i
      elif sell_limit_hit[i] == True:
        # ショートでエントリー
        _pos = -1.0
        _ret = 0.0
        _entry_price = sell_limit[i]
        _entry_step = i
      else:
        # ポジションをそのまま維持
        _pos = _prev_pos
        _ret = 0.0
    
    position[i] = _pos
    ret[i] = _ret
    
  return position, ret

def simulate_atrkun(window_size = 14, atr_factor = 0.5, fee = 0.02, force_exit_steps = 0):
  #_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['high'] = df_price.loc[df_price['symbol'] == _symbol, 'high'].astype(float)
    _df_analysis['low'] = df_price.loc[df_price['symbol'] == _symbol, 'low'].astype(float)

    # 1本後の時間足で有効な買指値、売指値を計算してデータフレームに追加
    _limit_price_diff = talib.ATR(_df_analysis['high'], _df_analysis['low'], _df_analysis['close'], timeperiod = window_size) * atr_factor
    _df_analysis['buy_limit'] = (_df_analysis['close'] - _limit_price_diff).shift(1)
    _df_analysis['sell_limit'] = (_df_analysis['close'] + _limit_price_diff).shift(1)

    _df_analysis['buy_limit_hit'] = _df_analysis['buy_limit'] > _df_analysis['low']
    _df_analysis['sell_limit_hit'] = _df_analysis['sell_limit'] < _df_analysis['high']

    _df_analysis['position'], _df_analysis['trade_logreturn'] = calc_position_return(_df_analysis['buy_limit_hit'].values, _df_analysis['sell_limit_hit'].values, _df_analysis['buy_limit'].values,  _df_analysis['sell_limit'].values, _df_analysis['close'].values, force_exit_steps)
    _df_analysis['position_diff'] = _df_analysis['position'].diff()
    _df_analysis.dropna(inplace = True)
    
    # トレードを行うごとに手数料は1回分カウントする
    _df_analysis['fee'] = 0
    _df_analysis.loc[(_df_analysis['position_diff'] != 0.0) & (np.isnan(_df_analysis['position_diff']) == False), 'fee'] = fee * 0.01

    # トレードごとのリターンから手数料を引いて利益を計算
    _df_analysis['profit'] = _df_analysis['trade_logreturn'] - _df_analysis['fee']
    _df_analysis['profit_cumsum'] = _df_analysis['profit'].cumsum()

    # トレードを実行した行のみを抜き出して各種処理を行う
    _df_analysis_tradeonly = _df_analysis[_df_analysis['position_diff'] != 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'])

    _profit_trade = _df_analysis_tradeonly['profit_trade'].fillna(0).astype(float)
  
    # 累積利益の描画
    _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} {atr_factor:.1f}ATRくん 累積リターン')

    # ホールド時間とリターンのヒストグラムの描画
    _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} {atr_factor:.1f}ATRくん ホールド時間 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} {atr_factor:.1f}ATRくん 累積リターン (ホールド時間別)')
    _ax.legend()
    
  print(_df_statistic.sort_values('final_profit', ascending = False).to_markdown())
  _fig.show()

simulate_atrkun(window_size = 14, atr_factor = 0.5, fee = 0.02, force_exit_steps = 0)

結果

例によってATRくんの挙動を直感的にイメージするためにまずグラフを書いてみました。
綺麗な右肩下がりのグラフになってしまいました。

出力画像

累積リターン

綺麗な右肩下がりのグラフで、利益が全く出ていないことが明らかです。手数料負けでしょう。前回のドテンくんはテイカー手数料0.04%を往復で支払っても利益が出る銘柄がありましたが、それとは全く性質が違います。

トレードごとのホールド時間に対するリターンの散布図

トレードごとのホールド時間に対するリターンの散布図を見ると、ホールド時間はおおむね8時間 (24本のタイムバー) が上限であることがわかります。ドテンくんが170時間 (170本のタイムバー) もホールドするときがあったこととと比較すると、ホールド時間はかなり短い戦略だということが分かります。

全体的に、ホールド時間が短いトレードでは利益が出る傾向があるようです。0.5ATRくんは逆張りBotなので、価格変化の反転を見越して逆張りでエントリーした後、速やかに反転が起こりイグジットできると利益がでやすいが、その目論見が外れると損しやすいということなのでしょう。

トレードごとのホールド時間に対する累積利益

トレードごとのホールド時間を横軸にした累積利益と累積損失もプロットしてみました。

例えばBTCUSDTでは、ほとんどの取引 (75%) は30分以内にクローズされています。おおむねホールド時間1時間30分くらいまでは利益が損失を上回っているようです。

利益やトレード間隔など

さて、グラフからホールド時間が長い場合は損失が多そうだ…ということがわかったので、表で確認しておきます。

銘柄 最終利益 ホールド時間の75パーセンタイル値 [時間] ホールド時間 < 75%の利益の合計 ホールド時間 < 75%の勝率 ホールド時間 >= 75%の利益の合計 ホールド時間 >= 75%の勝率
1000SHIBUSDT -0.481656 0.5 20.4082 0.288242 -20.8896 0.336221
DOGEUSDT -3.88157 0.5 28.9701 0.290377 -32.8515 0.327341
DOTUSDT -5.13742 0.5 30.068 0.294648 -35.2052 0.309165
XRPUSDT -6.51713 0.5 27.9534 0.290475 -34.4703 0.328874
ADAUSDT -7.57053 0.5 30.3722 0.285735 -37.9425 0.314419
ETHUSDT -8.93668 0.5 25.8585 0.291727 -34.795 0.319152
BTCUSDT -9.26133 0.5 16.6465 0.286158 -25.9076 0.326399
BNBUSDT -10.3302 0.5 23.7876 0.28657 -34.1176 0.304191
SOLUSDT -12.085 0.5 30.5889 0.286263 -42.6738 0.312453
MATICUSDT -12.161 0.5 32.0115 0.282721 -44.1723 0.306029

ドテンくんとは様相がかなり違って、そもそもホールド時間が長くても短くても勝率が3割程度です。そしてホールド時間が短いトレードで莫大な利益を出し、ホールド時間が長いトレードでさらに莫大な損失を出しています。

ドテンくんと同じように、0.5ATRくんにも得意な領域と不得意な領域があるということが実感として理解できました。この不得意な部分をMLフィルタで補助してあげよう…というのがrichmanbtc先生のチュートリアルのキモなのかな、と理解しています。

おまけ1

ATRくんはBinanceのメイカー手数料0.02%の世界では即死しそうですが、では手数料が0%だったらどうなるかを計算してみました。

結果、少ないながらも利益が出る銘柄が出てきました。

BTCUSDTなどの有名銘柄を見ると、2020年~2021年は勝てていましたが、2022年に入ってからヨコヨコあるいは勝てない…というようなTwitter上で漏れ聞こえる声と似た結果が出ています。

出力画像

利益やトレード間隔など

銘柄 最終利益 ホールド時間の75パーセンタイル値 [時間] ホールド時間 < 75%の利益の合計 ホールド時間 < 75%の勝率 ホールド時間 >= 75%の利益の合計 ホールド時間 >= 75%の勝率
1000SHIBUSDT 4.51574 0.5 23.189 0.291478 -18.6732 0.344703
DOGEUSDT 4.09463 0.5 33.4861 0.294717 -29.3915 0.336763
XRPUSDT 3.28047 0.5 33.5048 0.294258 -30.2243 0.340603
DOTUSDT 2.69318 0.5 34.5966 0.298755 -31.9034 0.317827
ADAUSDT 2.24767 0.5 36.0206 0.2897 -33.7729 0.323868
BTCUSDT 1.27887 0.5 22.3795 0.292786 -21.1006 0.342376
ETHUSDT 1.15492 0.5 31.4793 0.296744 -30.3244 0.331365
BNBUSDT -0.725393 0.5 29.2974 0.291652 -30.0228 0.316059
SOLUSDT -4.63482 0.5 34.8589 0.289635 -39.4938 0.318931
MATICUSDT -4.99382 0.5 36.1631 0.285996 -41.1569 0.31359

おまけ2

ATRくんは損切りを実装するとパフォーマンスが落ちるという話が以前ありました。本当かな? ということで、イグジットできない状態が続いた場合、特定時間後のバーのクローズ価格で強制イグジットとして、どうなるかを見てみました。

詳細は割愛しますが、確かに強制イグジットさせるとパフォーマンスが落ちやすいことが分かりました。早期に損切りした方が利益が伸びるパラメータの組み合わせも存在することがわかりましたが、こちらはカーブフィッティングになっているのだと思います。

おわりに

ということで、ATRくんを題材に執行戦略の検討の練習をしてみました。逆張り・指値を使う執行戦略がどんな挙動を示すかのイメージができてきた気がします。

  • 逆張り・指値botは価格が反転しそうな価格に逆張りの指値を出す。この指値の幅をどう考えるかが核心部分。
  • 目論見通り反転した場合は短い時間で利益が出ることが多そう
  • 目論見通り反転しない場合は損失を出すことが多そう

ATRくんの挙動を見て思ったのは、逆張りbotの利益は短いホールド期間のトレードから生まれることが多そうなので、逆張りbotを作るのなら、エントリーしたら固定時間でイグジットするシンプルな執行戦略が教師あり機械学習との組み合わせには向いているんじゃないか、ということです。

ATRくんに長くホールドせざるを得ないポジションを取らせないよう努力するのも良いかもしれません。エントリーしてからイグジットまでの時間が短いか長いかを分類問題として学習するイメージです。

まああまり深く考えすぎず、自分で無理なく作れる難易度で思いついたものをたくさん作っては動かしてみるのがいいのかもしれないですね。

Discussion