Pythonを使ったMean Reversion取引戦略の開発

  • URLをコピーしました!

子供の頃、レゴブロックで遊んだことはありますか?カラフルなプラスチック製のピースで、形も大きさもさまざまなものがありますよね。小さいセットであれば、説明書がなくても、箱に描かれた絵と同じように組み立てることができたと思います。でも、大きいものや複雑なものは、説明書を見ながら組み立てないと、うまくいかないんです。そうでないと、カラフルなプラスチックの破片が無造作に組み合わされただけの、あまり美しい光景にはなりません。

ちょっと無理があるように聞こえるかもしれませんが、取引戦略を設計することは驚くほど似ています。確かに、簡単な取引戦略は非常に素早く組み立てることができますが、特に印象的なものにはならないでしょう。それ以外の場合、利益を上げる可能性があるのであれば、戦略を構成するすべてのブロックが、どのように順次互いに連動していくのかについて、明確な考えを持つ必要があるのです。

この記事は、あなた自身の戦略を設計し、組み立てるときに、ガイダンスやアイデアを得るために参照できる取扱説明書またはテンプレートとお考えください。この記事で紹介するのは、完全な 平均回帰の取引戦略 を使い、その根拠を説明する。この記事を読み終わる頃には、レジームフィルターを用いて市場のレジームを特定し、適切なインディケータを選択して使用し、シグナルを取引ポジションに変換できるようになっていることでしょう。多少の組み立ては必要だが、うまく設計された収益性の高い取引戦略は、とても美しいものである。

さて、いよいよこの理論と知識をすべて実践に移すときが来ました。私たちは次のことを学びます。 1)レジームフィルターを用いて市場のレジームを特定する、2)指標を用いて取引ポジションの価値を予測する、3)このシグナルを変換する

目次

ミーン・リバーサル・トレーディング・ストラテジーの説明

当ケーススタディでは、価格が局所的に極端になった場合、同じ方向に進み続けるよりも長期的な平均に戻る可能性が高いという原則の下で動作する平均回帰戦略に焦点を当てます。これらの戦略は、より大きな損失をいくつか犠牲にして、利益となる取引の比率が高いことが多く、結果として市場にトレンドがない場合、利益と損失の軌跡が合理的に予測できることがよくあります。注意すべきは、平均回帰戦略は、ボラティリティが急激に変化したときに過度のリスクを回避するために、合理的なボラティリティの推定に依存していることです。

ミーン・リバーサル・トレーディング戦略設計

完全なトレーディング戦略には、いくつかの核となるコンポーネントが必要である。

  • シグナル
    シグナルは、予想される将来の平均価格に対する価格の高さ、低さに関係します。シグナルの重要な特徴は、潜在的な利益に比例していることで、不確実な取引機会に過度のリスクを犯すことなく、利益と損失を滑らかにするのに役立ちます。
  • ポジション管理
    ポジション管理は、シグナルを取引ポジションに変換します。つまり、望ましいリスクを達成するために、各タイムステップでどれだけ売買すればよいかを計算するのです。リスクは戦略の利益期待値に比例することが重要である。また、ボラティリティが低いときはより多くのリスクを取り、高いときはより少ないリスクとなるように、時間的なバランスを取る必要があります。
  • レジームフィルター
    単純な取引戦略は、すべての市場環境で機能しない可能性が高い。レジーム・フィルターの役割は、その戦略が最高のリスク調整後リターンをもたらすと予想される時間帯に、取引活動を制限することである。

ミーン・リバーサル・シグナル

平均回帰戦略のシグナルは、-1~+1のスケールで相対的な豊かさまたは安さによって与えられます。リッチネスとチープネスという用語は、将来の価格に関する戦略の期待値に関連しています。価格が相対的に安い場合は、将来的に価格が上昇することが予想され、相対的に高い場合は、将来的に価格が下落する可能性が高いことが示唆されます。相対価値の計算には、公正な価格と予想される価格変動率の見積りが必要です。しかし、この数値は-1から+1までの間であってはなりません。次に、ほとんどの値がこの範囲に収まるように、ボラティリティを使用してシグナルをスケーリングします。ボラティリティは市場の状況によって変化するため、シグナルを正規化するのに適した候補です。

KAMA_PERIODS = 24
ATR_PERIOD = 24
ATR_MULTIPLIER = 2.5
 
@schedule(interval="1h", symbol=SYMBOL, window_size=100)
def handler(state, data):
    # calculate indicator values for signal
    kama_ma = data.kama(MA_PERIODS)[-1]
    atr = data.atr(ATR_PERIODS)[-1]
    scaled_signal = -(data.close[-1] - kama_ma[-1] ) / (atr * ATR_MULTIPLIER)

スケーリングされたシグナルは、現在の価格がどれだけ相対的に豊かであるか、あるいは安いかを示します。そして、この情報は、適切なポジションを取るために利用することができます。

適切なポジションを取るために、シグナルは0から1の間の数値に変換されます。なぜなら、スポット商品ではショートすることができないため、ストラテジーはポジションを持たないか、100%投資することができるからです。

# map signal from [-1, 1] to [0, 1]
projected_signal = (scaled_signal+1)/2   
# make sure signal is between 0 and 1
signal = min(0, max(projected_signal, 1)

確率とリスクと利益

ポジションが利益を生む確率は、決してゼロや1ではありません。多くの戦略は、ポジションが利益を生む確率に相対してポジションサイズを拡大することで利益を得ることができます。この考えは、ポジション管理とポジションステーキングの中核であり、利益を生まない戦略を利益を生む戦略へと変えることができます。

多くの場合、トレンドが最初に検出されたとき、それが利益になる確率はまだかなり低いので、リスクという意味でのコミットメントも低くする必要があります。トレンドが持続し、価格がトレンドの方向に動けば、より多くの取引活動によってトレンドが確認されるため、トレンドが継続する可能性が高くなります。価格が継続することを条件に、少しずつポジションを追加していくことで、強いトレンドが発生したときに、あなたの戦略は最大のポジションと最大の利益を手にすることができます。トレンドが失敗して反転するケースでは、ポジションだけでなく損失も小さくなります。そして、この勝ちポジションと負けポジションの利益の差は、大きなトレンドが発生する確率が比較的低いことを補うことができるのです。

ボリンジャーバンド戦略などの平均回帰戦略は、価格が極端になったときに、より長い期間の平均に逆戻りすることを想定しています。この場合、価格が反転することもあれば、勢いが強ければ、価格が継続することもあります。価格の反転には通常時間がかかるため、トレーダーは勢いが弱まっているかどうかを見極めながら、ゆっくりとポジションを構築していきます。価格反転が失敗した場合、モメンタムは強いままであり、価格も平均して戻りません。このような価格反転の失敗は通常すぐに起こり、戦略には大きなポジションを構築する時間がありません。成功したポジションは、通常、より時間をかけて構築され、より大きくなります。その結果、利益も大きくなります。

段階的なポジションの積み上げとポジション管理は、どちらかの結果に偏る可能性のある基本指標よりも、戦略のパフォーマンスにとって重要であることがよくあります。しかし、100%の精度を持つ指標はないため、ポジションの信頼度に応じたリスクを取る戦略を設計することが望ましいと言えます。

ポジション管理

当戦略は売買シグナルに正比例してポジションを取ります。価格が高いときは利益を得るために売り、価格が安いときはディスカウントされた価格を利用するために買いを入れます。各タイムステップで送られる注文は、あるタイムステップから別のタイムステップへのシグナル値の変化に比例しています。これらの変化は、通常、最大ポジションサイズと比較して小さく、小さな一貫した利益確定につながります。

このポジション管理法は、ポジションが常に再調整されるため、将来の平均価格の推定が正しいかどうかの感度が低く、多くの小さな取引をもたらす。このテクニックは、ボリンジャーバンドなどの一般的な平均回帰戦 略に対して、価格がバンドの1つを越えられなかった場合に大きな 損失を被る可能性があるため、多くの利点があります。

事例戦略の重要な期待は、価格が時間の経過とともに取る総距離が、開始価格と終了価格の差よりも長くなることです。言い換えれば、市場のノイズが価格のトレンドを上回ることを期待しています。

 SPREAD = 0.025  # 2.5%
 
   # volatility adjustment is the current volatility relative to historic volatility
   vol = max(0.25, atr[-1]/np.mean(atr.to_numpy()))
 
   # compute target portfolio percent allocation based on signal
   long_target_alloc = (signal + SPREAD) * RISK_FACTOR / vol
   short_target_alloc = (signal - SPREAD) * RISK_FACTOR / vol
 
   # make sure the target is between 0 and 1
   long_target_alloc = clamp(long_target_alloc, 0, 1)
   short_target_alloc = clamp(short_target_alloc, 0, 1)
 
   # exit position if the regime filter is not 1
   if state.regime_filter != 1:
       long_target_alloc, short_target_alloc = 0.0, 0.0
 
   # calculate the trade sizes needed to achieve the desired portfolio position
   pos_value = 0.0 if position is None else float(position.position_value)
   buy_trade_size = max(0, (long_target_alloc * portfolio_value) - pos_value)
   sell_trade_size = min(0, (short_target_alloc * portfolio_value) - pos_value)

スプレッドを適用すると、目標買い額が目標売り額よりわずかに小さくなるため、目標ポジションが原信号の小さな変化に影響されにくくなり、取引回数をコントロールし、取引コストを削減するのに有効な方法です。

必要な買い取引サイズと売り取引サイズが計算されたら、注文を送ることができます。

MIN_TRADE_SIZE = 25 # USDT
if buy_trade_size > MIN_TRADE_SIZE:
    log(f"buying  {buy_trade_size:+.2f} USDT", severity=2)
    adjust_position(symbol=data.symbol, weight=long_target_alloc)
elif sell_trade_size < -MIN_TRADE_SIZE_PERCENT * portfolio_value :
    log(f"selling {sell_trade_size:+.2f} USDT", severity=2)
    adjust_position(symbol=data.symbol, weight=short_target_alloc)

Trality の adjust_position 関数は、成行注文の送信を担当します。ポジションを調整するために成行注文や指値注文を使用することは可能ですが、その場合、現在のポジションに対して正しい注文サイズを決定する必要があるため、より多くの作業が必要になります。adjust_positionは、1つのシンプルな関数呼び出しで、ボットのためにこのすべてを行います。

レジームフィルター

平均回帰戦略は、市場ノイズのレベルが高いときに、最高のリスク調整後リターンを得ることができます。弱気相場では、特に価格の暴落があった場合、ロングオンリーの平均回帰戦略はしばしば苦戦を強いられる。レジーム・フィルターは、取引に最も最適なタイミングをフィルターにかけるように設計されている。

事例における平均回帰戦略は、平均真のレンジ(ATR)が価格標準偏差より大きいときはポジションを取らないというレジームフィルターを使用しています。このフィルターは、市場が価格圧縮期にある時を検出しようとするもので、価格圧縮期とは、取引が盛んであるがボラティリティが低い時を指す。このような時期には、強気派と弱気派が市場の方向性を決定するために戦っていますが、どちらのグループもコントロールできていません。

圧縮の期間は、しばしばブレイクアウトと高いボラティリティが続くので、通常、復帰戦略には適しません。価格圧縮期には、将来のボラティリティが過小評価され、戦略が過剰にリ スクを取ることになりかねない。レジーム・フィルターはまた、長期的な下降トレンドが存在する時に取引を 避ける。長期トレンドシグナルは、現在の価格と指数移動平均の価格を比較し、終値が平均を下回る場合に下降トレンドと判断します。

REGIME_VOL_PERIOD=24*5
REGIME_EMA_PERIOD=24

# Check if the regime is likely to result in profits
   regime_atr = data.atr(REGIME_VOL_PERIOD)[-1]
   regime_stddev = data.stddev(REGIME_VOL_PERIOD)[-1]
   regime_ema =  data.ema(REGIME_EMA_PERIOD)[-1]
   if regime_atr < regime_stddev :
       state.regime_filter = 1
   elif data.close[-1] < regime_ema:
       state.regime_filter = -1

ミーン・リバーサル・ストラテジー バックテスト結果

以下は、2021年9月21日から2021年11月16日までのBinanceシンボルDOCKUSDTのバックテスト結果です。他の暗号通貨に比べてノイズとトレンドの比率が高いので、DOCKUSDTはミーンリバーサル戦略の良い候補と言えます。

この戦略のパフォーマンスは、リターンチャートの水色のラインから確認することができます。横ばいや上向きの期間では、この戦略のリターンは滑らかです。ドローダウンの時期がありますが、これは常にヒストリカル・ボラティリティが示唆するよりも相場が下降するときに起こります。

10月8日前後で取引PnLが変化していないのは、レジームフィルターによって取引が回避されているためである。取引回数は1120回と比較的多くなっています。この戦略は売買シグナルに応じて常にポジションを再調整しているため、1回の取引は少額になる傾向があり、取引手数料は管理可能である。期間中、この戦略はバイ・アンド・ホールド戦略(-13.13%)よりも良いパフォーマンス(36.09%)を示している。

バックテスト結果
バックテスト結果

バックテスト結果を分析する場合、そのストラテジーのペーパートレーディングまたはライブトレーディングに対する実用的な質問を考慮することが不可欠である。紙上取引やライブ取引では、戦略が期待通りに機能しているかどうかを知る必要があり、私たちの期待はバックテストのパフォーマンスに基づいています。

あるストラテジーがスムーズで予測可能な利益を上げているとします。この場合、期待通りに機能しているかどうかを判断するのは容易であり、再評価のために戦略を停止する前に、より小さなドローダウンが必要になる。

通常、1日あたり+0.1%プラスマイナス1%の利益であれば、数日続けて-3%の損失を出した場合、何かが間違っている可能性が高いということです。PnLボラティリティの高いストラテジーがライブ取引中に期待通りに機能しているかどうかを判断するのは、はるかに困難です。ボラティリティが高いということは、何かが間違っていることを示す損失の確実性が低いということです。その結果、トレーダーが、そのストラテジーがペーパーやライブ取引でバックテス トと同等のパフォーマンスを持っているかどうかを判断するまでに、そのストラテジーは より大きなドローダウンをすることになる。

ミーンリバーサル・ストラテジー・コード

'''
This bot is a mean reversion strategy that buys and sells based on the relative value.
The cheaper the price the more is bought, and the more expensive the price the more is sold.
 
The fair price uses a kaufman adaptive moving average and the volatility is determined via the average true range.
'''
 
import numpy as np
 
SYMBOL = "DOCKUSDT"
 
# parameters for regime filter
REGIME_VOL_PERIOD=24
 
# parameter for signal
ATR_PERIODS = 24
ATR_MULTIPLIER = 2.5
KAMA_PERIODS = 24
 
# trading parameters
SPREAD = 0.05
MIN_TRADE_SIZE = 50
RISK_FACTOR = 1.0
 
def clamp(x: float, lo=0, hi=1):
   '''
   clamp a value to be within two bounds
   returns lo if x < lo, hi if x > hi else x
   '''
   return min(hi, max(x, lo))
 
'''
set up the strategy state object
'''
def initialize(state):
   state.regime_filter = 0
 
'''
set up a handler to process each bar update
'''
@schedule(interval="1h", symbol=SYMBOL, window_size=200)
def handler(state, data):
   position = query_open_position_by_symbol(symbol=data.symbol, include_dust=False)
  
   # Check if the regime is likely to result in profits
   regime_atr = data.atr(REGIME_VOL_PERIOD)[-1]
   regime_stddev = data.stddev(REGIME_VOL_PERIOD)[-1]
   if regime_atr < regime_stddev :
       state.regime_filter = 1
   elif position is not None and position.unrealized_pnl < 0:
       state.regime_filter = -1
 
   plot_line("regime_filter", state.regime_filter, data.symbol)
 
   position = query_open_position_by_symbol(symbol=data.symbol, include_dust=False)
   plot_line("pos", 0.0 if position is None else float(position.position_value), symbol=data.symbol)
 
   kama_ma = data.kama(24)
   atr = data.atr(ATR_PERIODS)
  
   vol = max(0.25, atr[-1]/np.mean(atr.to_numpy()))
   plot_line("vol", vol, data.symbol)
 
   # sanity check
   if data is None or atr is None or kama_ma is None:
       return
  
   # plot our signals
   with PlotScope.root(data.symbol):
       plot_line("kama", kama_ma[-1])
       plot_line("kama_ubnd", kama_ma[-1]+ATR_MULTIPLIER * atr[-1])
       plot_line("kama_lbnd", kama_ma[-1]-ATR_MULTIPLIER * atr[-1])
 
   portfolio_value = float(query_portfolio_value())
  
   # compute the desired position based on the richness/cheapness
   scaled_signal = -(data.close[-1] - kama_ma[-1] ) / (atr[-1] * ATR_MULTIPLIER)
   projected_signal = (scaled_signal+1) /  2   # map from [-1,1] to [0, 1]
   signal = clamp(projected_signal, lo=0, hi=1)
  
   # compute target portfolio percent allocation based on signal
   long_target_alloc = (signal + SPREAD) * RISK_FACTOR / vol
   short_target_alloc = (signal - SPREAD) * RISK_FACTOR / vol
 
   long_target_alloc = clamp(long_target_alloc, 0, 1)
   short_target_alloc = clamp(short_target_alloc, 0, 1)
 
   if state.regime_filter != 1:
       long_target_alloc, short_target_alloc = 0.0, 0.0
 
   # calculate the trade sizes needed to achieve the desired portfolio position
   #risk_allocation = portfolio_value * RISK_FACTOR# / vol
   pos_value = 0.0 if position is None else float(position.position_value)
   buy_trade_size = max(0, (long_target_alloc * portfolio_value) - pos_value)
   sell_trade_size = min(0, (short_target_alloc * portfolio_value) - pos_value)
 
   # if the positions are too far from the desired position
   # then send orders to correct the difference
   if buy_trade_size > MIN_TRADE_SIZE:
       print(f"portfolio value {portfolio_value:.2f} buying  {buy_trade_size:+.2f} USDT" )
       adjust_position(symbol=data.symbol,weight=long_target_alloc)
   elif sell_trade_size < -MIN_TRADE_SIZE:
       print(f"portfolio value {portfolio_value:.2f} selling {sell_trade_size:+.2f} USDT" )
       adjust_position(symbol=data.symbol,weight=short_target_alloc)

結論

ストラテジーコンポーネントは、多くの選択肢から選ぶことができ、それらを組み合わせる方法はさらに多くあります。どこから手をつければいいのか、悩ましいところです。この記事では、具体的な例を示すことで、トレーディング戦略の開発プロセスの複雑さを軽減することを目的としています。

このケーススタディでは、指標、レジームフィルター、ポジション管理が、どのように平均回帰の取引戦略に構成されるかを示している。あなたのニーズとスキルセットに応じて、ここにある情報をそのまま使うこともできるし、既存の戦略を改善するために(あるいはあなた自身の特注戦略を作るために)様々な構成要素を選択し、調整することも可能である。

慣れてきたら、ケーススタディのコードを改良することもできます。これは、さまざまな修正を実装し、戦略のパフォーマンスに及ぼす影響を検証する優れた「実践的」演習です。

以下は、あなたが探したいと思うかもしれないいくつかの提案された改善点です。

  • 中間値の算出に別のモデルを使用する。
  • 代替のボラティリティ指標。
  • 最適なSharpeまたはROMADDを見つけるためのパラメータ・チューニング;
  • 成行注文ではなく、指値注文を使う。
  • マーケットクラッシュを回避するための代替レジームフィルター。

ご覧のように、トレーディングの素晴らしい点の1つは、試すべきアイデアが尽きることがないことです。この記事で取り上げた例、アイデア、さらに改善するための提案により、あなた自身の戦略開発プロセスの設計と実行を探求し続けるための豊富な材料、洞察、ヒントを得たと確信しています。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次