
オープニングレンジブレイクアウト日中取引戦略の解読
はじめに
オープニングレンジブレイクアウト(ORB)戦略は、市場が開いた直後に形成される初期の取引レンジが、買い手と売り手が価値に合意する重要な価格レベルを反映しているという考えに基づいて構築されています。特定のレンジを上抜けまたは下抜けするブレイクアウトを特定することで、市場の方向性が明確になるにつれて発生することが多いモメンタムを利用し、トレーダーは利益を狙うことができます。
本記事では、Concretum Groupの論文から応用された3つのORB戦略を紹介します。まず、研究の背景として、主要な概念および使用された方法論について解説します。その後、それぞれの戦略について、仕組みの解説、シグナルルールの一覧、統計的なパフォーマンス分析をおこないます。最後に、ポートフォリオの観点から戦略を検証し、分散投資というテーマにも焦点を当てます。
本記事ではプログラミングの詳細な解説はおこなわず、主に研究課程に重点を置きます。具体的には、戦略の再現、分析、検証などの手順です。これは、取引の優位性を模索する読者や、これらの戦略の研究・再現過程に関心のある方に適しています。なお、エキスパートアドバイザー(EA)用のすべてのMQL5コードは公開しますので、読者ご自身でこのフレームワークを拡張していただくことも可能です。
研究の背景
このセクションでは、本記事で戦略を分析する際に使用する研究手法と、後のセクションでも登場する主要な概念について説明します。
Concretum Groupは、数少ない日中取引戦略を開発している学術研究チームのひとつです。今回参考にする彼らの研究では、市場のオープンからクローズまで(米東部時間9:30~16:00)の間に取引をおこなう戦略に焦点を当てています。私たちが使用しているブローカーのサーバー時間はUTC+2/3であるため、これはサーバー時間では18:30〜24:00に相当します。テストをおこなう際は、ご自身のブローカーのタイムゾーンに合わせて調整してください。
元の研究では、QQQというETF(上場投資信託)を対象に取引をおこなっています。QQQは、ナスダック100指数を追跡するETFです。ナスダック100は、ナスダック市場に上場している大手テクノロジー企業100社のパフォーマンスを表す指数です。ただし、ナスダック100自体は直接取引できず、その派生商品を通じて取引します。QQQは、これらの企業にひとつの銘柄を通じて投資する手段を提供しており、個人投資家にも人気です。本記事では、テスト対象としてQQQではなくUSTEC(ナスダック100指数に連動するCFD)を使用します。CFD(差金決済取引)を使うことで、原資産を保有せずに価格変動に対して投機的な取引が可能となり、多くの場合レバレッジを利用して損益を拡大することができます。
本記事で導入する重要な2つの指標がアルファとベータです。アルファは、ある投資が市場のベンチマーク(例:株価指数)と比較してどれだけ超過リターンを上げたかを示します。これは投資が期待以上の成果を出しているかを測るものであり、戦略の優位性を表します。ベータは、市場全体の動きに対してその投資がどれだけ感応的(連動的)かを示す指標です。ベータが1.0であれば市場と同じ動きをし、1を上回ると市場以上に価格が変動しやすく、1を下回ると市場よりも穏やかに動くことを意味します。これらの指標を理解することで、その戦略が市場のトレンド(方向性)に依存しているのか、あるいは独自の優位性に基づいて成果を上げているのかを見極めることができます。特に、株価指数や仮想通貨などトレンドが出やすい資産においては、戦略のバイアスや依存度を判断するうえで非常に重要です。
アルファとベータは次のように計算されます。
Riは投資のリターン、Rfはリスクフリーレート(一般的には米国債の利回りなどに基づくが、無視されることもある)、Rmは市場のリターンを指します。共分散および分散は、通常、日次リターンを用いて計算されます。
本記事で後ほど使用する重要な指標のひとつが、VWAP(出来高加重平均価格:Volume Weighted Average Price)です。計算式は以下のとおりです。
VWAPの基本的な考え方は、ある期間における真の平均取引コストを反映する価格を、出来高で加重して算出することにあります。単純な平均価格とは異なり、VWAPは取引量の多い価格により多くの重みを与えるため、より公正なベンチマークとなります。
アルゴリズム取引におけるVWAPの一般的な用途は以下のとおりです。
- トレンドフィルターとして使用
- トレーリングストップとして使用
- シグナル生成に使用(例:価格がVWAPをクロスしたときにエントリー)
VWAPの計算は、通常市場オープンの最初のローソク足から開始します。上記の式では、Piはi番目のローソク足の価格(通常は終値)を、Viはそのローソク足の取引量を表します。なお、CFDブローカーによっては流動性プロバイダーの違いにより取引量に差異が出る可能性がありますが、相対的な重み付けは概ね一貫しているため、VWAPの信頼性に大きな影響はありません。
本記事では、leverage space risk model(レバレッジ空間リスクモデル)を実装します。この手法では、各取引において口座残高の一定割合をリスクに投じるというルールを採用しており、ストップロスに到達した際にのみ損失が確定します。ストップロスの幅は、資産価格に対する一定のパーセンテージとし、価格の変動性に適応させます。取引ごとのリスクは、シンプルさを保つために概数(キリのよい数字)で設定し、最大ドローダウンを約20%以内に抑えることを目標とします。各戦略は、2020年1月1日から2025年1月1日までの5年間にわたってテストをおこない、直近の相場環境における収益性を評価するのに十分なデータを収集します。徹底的な統計的な分析では、累積リターン(パーセンテージ)に基づくバイアンドホールド戦略との比較および、個別のパフォーマンス指標を用いた評価を実施します。
戦略1:寄り付き足の方向
最初に紹介する戦略は、Concretum Groupの論文『Can Day Trading Really Be Profitable?』で提案された、クラシックなオープニングブレイクアウト戦略です。この戦略のシグナルルールの背景には、短期的な価格モメンタムを捉えつつ、デイトレーダーにとって実用的かつリスク管理を重視した設計という意図があります。著者たちは、市場オープン直後に見られる高いボラティリティと方向性のあるモメンタムを活用するためにORB(オープニングレンジブレイクアウト)手法を選択しています。この時間帯は、機関投資家の活動が表面化しやすく、個人トレーダーがその方向性を1日のトレンド判断の手がかりとして利用できる重要なウィンドウとされています。
論文を精査した結果、元の戦略を改善できるポイントがいくつかあることが分かりました。元の戦略では、最初の5分足の高値または安値をストップロスとし、10Rの利益確定(テイクプロフィット)を設定していました。この方法は理論上は有効でも、実運用では個人トレーダーにとって非現実的でした。初動5分足を基準としたタイトなストップロスは、相対的な取引コストを増加させる傾向がありました。また、日中に全ポジションをクローズするルールのもとでは、10Rという大きな利益目標はほとんど達成されず、過剰設定となっていました。さらに、元の戦略にはレジーム(市場環境)に基づくフィルターが存在していなかったため、移動平均を使ったフィルターを加えることで、戦略の信頼性を高めることができると考えました。
修正版シグナルルール:
- 市場オープンから5分後、最初の5分足が陽線(ブル)かつその終値が350期間移動平均より上なら買いエントリー。
- 市場オープンから5分後、最初の5分足が陰線(ベア)かつその終値が350期間移動平均より下なら売りエントリー。
- 市場クローズ5分前にすべてのポジションを決済。
- ストップロスはエントリー価格から1%。
- 1回の取引でリスクをとる資金は全体残高の2%。
EAの完全なMQL5コード:
//USTEC-M5 #include <Trade/Trade.mqh> CTrade trade; input int startHour = 18; input int startMinute = 35; input int endHour = 23; input int endMinute = 55; input double risk = 2.0; input double slp = 0.01; input int MaPeriods = 350; input int Magic = 0; int barsTotal = 0; int handleMa; double lastClose=0; double lastOpen = 0; double lot = 0.1; //+------------------------------------------------------------------+ //|Initialization function | //+------------------------------------------------------------------+ int OnInit() { trade.SetExpertMagicNumber(Magic); handleMa = iMA(_Symbol,PERIOD_CURRENT,MaPeriods,0,MODE_SMA,PRICE_CLOSE); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //|Deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { } //+------------------------------------------------------------------+ //|On tick function | //+------------------------------------------------------------------+ void OnTick() { int bars = iBars(_Symbol, PERIOD_CURRENT); if(barsTotal != bars){ barsTotal=bars; double ma[]; CopyBuffer(handleMa,0,1,1,ma); if(MarketOpen()){ lastClose = iClose(_Symbol,PERIOD_CURRENT,1); lastOpen = iOpen(_Symbol,PERIOD_CURRENT,1); if(lastClose<lastOpen&&lastClose<ma[0])executeSell(); if (lastClose>lastOpen&&lastClose>ma[0]) executeBuy(); } if(MarketClose()){ for(int i = PositionsTotal()-1; i>=0; i--){ ulong pos = PositionGetTicket(i); string symboll = PositionGetSymbol(i); if(PositionGetInteger(POSITION_MAGIC) == Magic&&symboll== _Symbol)trade.PositionClose(pos); } } } } //+------------------------------------------------------------------+ //| Detect if market is opened | //+------------------------------------------------------------------+ bool MarketOpen() { datetime currentTime = TimeTradeServer(); MqlDateTime timeStruct; TimeToStruct(currentTime, timeStruct); int currentHour = timeStruct.hour; int currentMinute = timeStruct.min; if (currentHour == startHour &¤tMinute==startMinute)return true; else return false; } //+------------------------------------------------------------------+ //| Detect if market is closed | //+------------------------------------------------------------------+ bool MarketClose() { datetime currentTime = TimeTradeServer(); MqlDateTime timeStruct; TimeToStruct(currentTime, timeStruct); int currentHour = timeStruct.hour; int currentMinute = timeStruct.min; if (currentHour == endHour && currentMinute == endMinute)return true; else return false; } //+------------------------------------------------------------------+ //| Sell execution function | //+------------------------------------------------------------------+ void executeSell() { double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); bid = NormalizeDouble(bid,_Digits); double sl = bid*(1+slp); sl = NormalizeDouble(sl, _Digits); lot = calclots(bid*slp); trade.Sell(lot,_Symbol,bid,sl); } //+------------------------------------------------------------------+ //| Buy execution function | //+------------------------------------------------------------------+ void executeBuy() { double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); ask = NormalizeDouble(ask,_Digits); double sl = ask*(1-slp); sl = NormalizeDouble(sl, _Digits); lot = calclots(ask*slp); trade.Buy(lot,_Symbol,ask,sl); } //+------------------------------------------------------------------+ //| Calculate lot size based on risk and stop loss range | //+------------------------------------------------------------------+ double calclots(double slpoints) { double riskAmount = AccountInfoDouble(ACCOUNT_BALANCE) * risk / 100; double ticksize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE); double tickvalue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE); double lotstep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); double moneyperlotstep = slpoints / ticksize * tickvalue * lotstep; double lots = MathFloor(riskAmount / moneyperlotstep) * lotstep; lots = MathMin(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX)); lots = MathMax(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN)); return lots; }
典型的な取引は次のようになります。
バックテスト結果:
移動平均フィルターを使用しない場合、元の戦略ルールでは毎営業日1回の取引が発生します。フィルターを追加することで、取引回数は約半分に減少しました。平均保有時間が取引セッション全体にわたるため、戦略のパフォーマンスはある程度マクロな市場トレンドの影響を反映します。実際に、買いポジションがより多く発生し、勝率も高くなる傾向が見られました。全体として、この戦略は1.23のプロフィットファクターと2.81のシャープレシオを達成しました。これは、非常に堅実なパフォーマンスを示すものです。また、使用しているルールは非常にシンプルで過剰適合のリスクが低く、このことから、バックテストで得られた良好な結果が実運用でも再現される可能性が高いことを示唆しています。
EAは、過去5年間にわたってUSTECのバイアンドホールド戦略を大きく上回る成績を収めました。特に注目すべきは、最大ドローダウンを18%に抑えており、これはベンチマークの半分程度である点です。資産曲線は全体的に滑らかで、唯一の停滞期は2022年後半から2023年前半にかけての短期間にとどまっており、この時期はちょうどUSTECが大きなドローダウンに直面していた時期でもあります。
Alpha: 1.6017 Beta: 0.0090
ベータが0.9%であるということは、この戦略の日次リターンが原資産との相関性がわずか0.9%しかないことを示しており、戦略の優位性は主にそのルール自体に起因しており、市場のトレンドに依存していないことが分かります。ドローダウンとリターンのブレは少なく、2020年のコロナショックのような極端な市場環境(レジーム)にも耐性があることを示唆しています。ほとんどの月がプラス収益であり、損失が出た月も軽微で、最悪の月間ドローダウンは10.2%にとどまりました。総じて、これは実運用に適しており、かつ利益を期待できる戦略であると評価できます。
戦略2:VWAPトレンドフォロー
2つ目の戦略は、市場オープン時のトレンドフォロー型戦略であり、論文『Volume Weighted Average Price (VWAP) The Holy Grail for Day Trading Systems』で紹介されたものです。 この戦略のシグナルルールの背景には、VWAPを出来高加重の明確なベンチマークとして活用し、日中のトレンドを見極めるという狙いがあります。価格がVWAPの上でクローズすればロング(買い)、下でクローズすればショート(売り)というシンプルなルールでエントリーを判断し、ノイズを除外しつつ明確なモメンタムを捉えることを目指します。このようなシンプルな構造は、デイトレーダーにとって実行可能で再現性の高いシグナルを提供するという利点があります。 この古典的なトレンドフォロー戦略は、特に高ボラティリティ環境で優れたパフォーマンスを発揮し、長期のトレンドに乗ることで高いリスクリワードを実現します。株式市場が開いている5時間の間に、インデックスは大きく動く傾向があり、この時間的流動性が本戦略の成功を支える重要な要素となっています。
元の論文では、1分足の時間枠で取引されており、それが最も効果的であると主張されていました。しかし、私のテストでは、15分足時間枠の方が本戦略には適していることがわかりました。これは、おそらくCFD取引の方がETFに比べて取引コストが高く、頻繁な売買に不向きであることが原因です。また、元の論文ではストップロスが設定されていませんでしたが、私たちのアプローチでは時間枠がより長いため、ストップロスを導入しています。これは、事故的な損失を回避するセーフティネットかつリスク計算の基準値として機能します。最後に、前述の戦略と同様に移動平均を用いたトレンドフィルターも追加しています。
修正版シグナルルール:
- 市場オープン後、ポジション未保有の状態で、直近15分足の終値がVWAPおよび300期間移動平均を上回っている場合は買いエントリー
- 市場オープン後、ポジション未保有の状態で、直近15分足の終値がVWAPおよび300期間移動平均を下回っている場合は売りエントリー
- ストップロスはエントリー価格から0.8%
- 1回の取引でリスクをとる資金は全体残高の2%。
EAの完全なMQL5コード:
//USTEC-M15 #include <Trade/Trade.mqh> CTrade trade; input int startHour = 18; input int startMinute = 35; input int endHour = 23; input int endMinute = 45; input double risk = 2.0; input double slp = 0.008; input int MaPeriods = 300; input int Magic = 0; int barsTotal = 0; int handleMa; double lastClose=0; double lot = 0.1; //+------------------------------------------------------------------+ //|Initialization function | //+------------------------------------------------------------------+ int OnInit() { trade.SetExpertMagicNumber(Magic); handleMa = iMA(_Symbol,PERIOD_CURRENT,MaPeriods,0,MODE_SMA,PRICE_CLOSE); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //|Deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { } //+------------------------------------------------------------------+ //|On tick function | //+------------------------------------------------------------------+ void OnTick() { int bars = iBars(_Symbol, PERIOD_CURRENT); if(barsTotal != bars){ barsTotal=bars; bool NotInPosition = true; double ma[]; CopyBuffer(handleMa,0,1,1,ma); if(MarketOpened()&&!MarketClosed()){ lastClose = iClose(_Symbol,PERIOD_CURRENT,1); int startIndex = getSessionStartIndex(); double vwap = getVWAP(startIndex); for(int i = PositionsTotal()-1; i>=0; i--){ ulong pos = PositionGetTicket(i); string symboll = PositionGetSymbol(i); if(PositionGetInteger(POSITION_MAGIC) == Magic&&symboll== _Symbol){ if((PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY&&lastClose<vwap)||(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_SELL&&lastClose>vwap))trade.PositionClose(pos); else NotInPosition=false; } } if(lastClose<vwap&&NotInPosition&&lastClose<ma[0])executeSell(); if(lastClose>vwap&&NotInPosition&&lastClose>ma[0]) executeBuy(); } if(MarketClosed()){ for(int i = PositionsTotal()-1; i>=0; i--){ ulong pos = PositionGetTicket(i); string symboll = PositionGetSymbol(i); if(PositionGetInteger(POSITION_MAGIC) == Magic&&symboll== _Symbol)trade.PositionClose(pos); } } } } //+------------------------------------------------------------------+ //| Detect if market is opened | //+------------------------------------------------------------------+ bool MarketOpened() { datetime currentTime = TimeTradeServer(); MqlDateTime timeStruct; TimeToStruct(currentTime, timeStruct); int currentHour = timeStruct.hour; int currentMinute = timeStruct.min; if (currentHour >= startHour &¤tMinute>=startMinute)return true; else return false; } //+------------------------------------------------------------------+ //| Detect if market is closed | //+------------------------------------------------------------------+ bool MarketClosed() { datetime currentTime = TimeTradeServer(); MqlDateTime timeStruct; TimeToStruct(currentTime, timeStruct); int currentHour = timeStruct.hour; int currentMinute = timeStruct.min; if (currentHour >= endHour && currentMinute >= endMinute)return true; else return false; } //+------------------------------------------------------------------+ //| Sell execution function | //+------------------------------------------------------------------+ void executeSell() { double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); bid = NormalizeDouble(bid,_Digits); double sl = bid*(1+slp); sl = NormalizeDouble(sl, _Digits); lot = calclots(bid*slp); trade.Sell(lot,_Symbol,bid,sl); } //+------------------------------------------------------------------+ //| Buy execution function | //+------------------------------------------------------------------+ void executeBuy() { double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); ask = NormalizeDouble(ask,_Digits); double sl = ask*(1-slp); sl = NormalizeDouble(sl, _Digits); lot = calclots(ask*slp); trade.Buy(lot,_Symbol,ask,sl); } //+------------------------------------------------------------------+ //| Get VWAP function | //+------------------------------------------------------------------+ double getVWAP(int startCandle) { double sumPV = 0.0; // Sum of (price * volume) long sumV = 0.0; // Sum of volume // Loop from the starting candle index down to 1 (excluding current candle) for(int i = startCandle; i >= 1; i--) { // Calculate typical price: (High + Low + Close) / 3 double high = iHigh(_Symbol, PERIOD_CURRENT, i); double low = iLow(_Symbol, PERIOD_CURRENT, i); double close = iClose(_Symbol, PERIOD_CURRENT, i); double typicalPrice = (high + low + close) / 3.0; // Get volume and update sums long volume = iVolume(_Symbol, PERIOD_CURRENT, i); sumPV += typicalPrice * volume; sumV += volume; } // Calculate VWAP or return 0 if no volume if(sumV == 0) return 0.0; double vwap = sumPV / sumV; // Plot the dot datetime currentBarTime = iTime(_Symbol, PERIOD_CURRENT, 0); string objName = "VWAP" + TimeToString(currentBarTime, TIME_MINUTES); ObjectCreate(0, objName, OBJ_ARROW, 0, currentBarTime, vwap); ObjectSetInteger(0, objName, OBJPROP_COLOR, clrGreen); // Green dot ObjectSetInteger(0, objName, OBJPROP_STYLE, STYLE_DOT); // Dot style ObjectSetInteger(0, objName, OBJPROP_WIDTH, 1); // Size of the dot return vwap; } //+------------------------------------------------------------------+ //| Find the index of the candle corresponding to the session open | //+------------------------------------------------------------------+ int getSessionStartIndex() { int sessionIndex = 1; // Loop over bars until we find the session open for(int i = 1; i <=1000; i++) { datetime barTime = iTime(_Symbol, PERIOD_CURRENT, i); MqlDateTime dt; TimeToStruct(barTime, dt); if(dt.hour == startHour && dt.min == startMinute-5) { sessionIndex = i; break; } } return sessionIndex; } //+------------------------------------------------------------------+ //| Calculate lot size based on risk and stop loss range | //+------------------------------------------------------------------+ double calclots(double slpoints) { double riskAmount = AccountInfoDouble(ACCOUNT_BALANCE) * risk / 100; double ticksize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE); double tickvalue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE); double lotstep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); double moneyperlotstep = slpoints / ticksize * tickvalue * lotstep; double lots = MathFloor(riskAmount / moneyperlotstep) * lotstep; lots = MathMin(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX)); lots = MathMax(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN)); return lots; }
典型的な取引は次のようになります。
バックテスト結果:
最初のオープニングブレイクアウト戦略と比較すると、この戦略はより頻繁に取引をおこない、1日に平均1回以上の取引を実施しています。この取引回数の増加は、市場時間中に価格が再びVWAPをクロスした際に再エントリーを許可していることに起因します。勝率は42%であり、50%を下回っています。これはダイナミックなトレーリングストップを用いるトレンドフォロー手法としては一般的な数値です。この設定は、リスクリワード比率の高い取引を優先する一方で、ストップロスにかかるリスクも増加させる傾向があります。パフォーマンス指標としては、シャープレシオが3.57、プロフィットファクターが1.26と非常に高い水準を示しており、優れた成果を上げています。
この戦略は、バイアンドホールド戦略を大きく上回り、5年間で501%のリターンを達成しました。最大ドローダウンは16%に抑えられており、最も悪い時期は2021年後半でした。これはUSTECの最悪期とは異なっており、パフォーマンスの非相関性を示唆しています。
Alpha: 4.8714 Beta: 0.0985
この戦略のベータ値は、最初の戦略と同じく低く、原資産との相関が低いことを示しています。特に注目すべきは、最大ドローダウンはほぼ同じ水準ながら、アルファが最初の戦略の3倍に達している点です。この優位性は、より頻繁な取引、より短い保有期間、および同一日のうちにロングとショート両方の機会を活かした内部分散効果から生まれると考えられます。また、月次の成績表からも、ドローダウンやリターンが月ごとに均等かつ一貫して分布しており、安定したパフォーマンスが確認されています。
戦略3:Concretum Bandsブレイクアウト
3つ目の戦略は、市場オープン時間中に取引されるノイズレンジブレイクアウト戦略です。この戦略は最初に、論文『Beat the Market An Effective Intraday Momentum Strategy for S&P500 ETF (SPY)』で紹介され、その後X(旧Twitter)で話題となりました。 Concretum Bandsブレイクアウト戦略のシグナルルールの背景には、日中取引における需給のアンバランスから生じる重要な価格変動を捉えることを目的としています。この戦略では、前日の終値または当日の始値を基に、ボラティリティに応じた調整を加えたボラティリティバンドを用いています。これにより、価格のランダムな変動(ノイズ)が発生する「ノイズエリア」を定義します。ルールは、市場のノイズを除外し、確率の高いモメンタムの転換を捉え、変動性の違いに対応することを目指しています。つまり、一時的な価格の揺らぎではなく、本物のトレンドの始まりに沿った取引をおこなうためのフィルターとして機能します。
バンドの計算は次のとおりです。
元の論文のシグナルルールは非常によく練られているため、本記事では大きな変更は加えません。取引対象資産は引き続きUSTECを使用し、リスク管理の方法も簡潔にするため同じアプローチを維持します。これにより、論文の結果とは異なる結果になる可能性があります。シグナルルールは以下の通りです。
- 市場オープン後、1分足が上限バンドを上抜けたら買いエントリー
- 市場オープン後、1分足が下限バンドを下抜けたら売りエントリー
- 市場クローズ時にすべてのポジションを決済
- ストップロスはエントリー価格から1%に設定し、さらにVWAPをトレーリングストップとして利用
- 1回の取引あたりのリスクは口座残高の4%
EAの完全なMQL5コード:
//USTEC-M1 #include <Trade/Trade.mqh> CTrade trade; input int startHour = 18; input int startMinute = 35; input int endHour = 23; input int endMinute = 55; input double risk = 4.0; input double slp = 0.01; input int Magic = 0; input int maPeriod = 400; int barsTotal = 0; int handleMa; double lastClose=0; double lastOpen = 0; double lot = 0.1; //+------------------------------------------------------------------+ //|Initialization function | //+------------------------------------------------------------------+ int OnInit() { trade.SetExpertMagicNumber(Magic); handleMa = iMA(_Symbol,PERIOD_CURRENT,maPeriod,0,MODE_SMA,PRICE_CLOSE); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //|Deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { } //+------------------------------------------------------------------+ //|On tick function | //+------------------------------------------------------------------+ void OnTick() { int bars = iBars(_Symbol, PERIOD_CURRENT); if(barsTotal != bars){ barsTotal=bars; bool NotInPosition = true; double ma[]; CopyBuffer(handleMa,0,1,1,ma); if(MarketOpened()&&!MarketClosed()){ lastClose = iClose(_Symbol,PERIOD_CURRENT,1); lastOpen = iOpen(_Symbol,PERIOD_CURRENT,1); int startIndex = getSessionStartIndex(); double vwap = getVWAP(startIndex); for(int i = PositionsTotal()-1; i>=0; i--){ ulong pos = PositionGetTicket(i); string symboll = PositionGetSymbol(i); if(PositionGetInteger(POSITION_MAGIC) == Magic&&symboll== _Symbol){ if((PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY&&lastClose<vwap)||(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_SELL&&lastClose>vwap))trade.PositionClose(pos); else NotInPosition=false; } } double lower = getLowerBand(); double upper = getUpperBand(); if(NotInPosition&&lastOpen>lower&&lastClose<lower&&lastClose<ma[0])executeSell(); if(NotInPosition&&lastOpen<upper&&lastClose>upper&&lastClose>ma[0]) executeBuy(); } if(MarketClosed()){ for(int i = PositionsTotal()-1; i>=0; i--){ ulong pos = PositionGetTicket(i); string symboll = PositionGetSymbol(i); if(PositionGetInteger(POSITION_MAGIC) == Magic&&symboll== _Symbol)trade.PositionClose(pos); } } } } //+------------------------------------------------------------------+ //| Detect if market is opened | //+------------------------------------------------------------------+ bool MarketOpened() { datetime currentTime = TimeTradeServer(); MqlDateTime timeStruct; TimeToStruct(currentTime, timeStruct); int currentHour = timeStruct.hour; int currentMinute = timeStruct.min; if (currentHour >= startHour &¤tMinute>=startMinute)return true; else return false; } //+------------------------------------------------------------------+ //| Detect if market is closed | //+------------------------------------------------------------------+ bool MarketClosed() { datetime currentTime = TimeTradeServer(); MqlDateTime timeStruct; TimeToStruct(currentTime, timeStruct); int currentHour = timeStruct.hour; int currentMinute = timeStruct.min; if (currentHour >= endHour && currentMinute >= endMinute)return true; else return false; } //+------------------------------------------------------------------+ //| Sell execution function | //+------------------------------------------------------------------+ void executeSell() { double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); bid = NormalizeDouble(bid,_Digits); double sl = bid*(1+slp); sl = NormalizeDouble(sl, _Digits); lot = calclots(bid*slp); trade.Sell(lot,_Symbol,bid,sl); } //+------------------------------------------------------------------+ //| Buy execution function | //+------------------------------------------------------------------+ void executeBuy() { double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); ask = NormalizeDouble(ask,_Digits); double sl = ask*(1-slp); sl = NormalizeDouble(sl, _Digits); lot = calclots(ask*slp); trade.Buy(lot,_Symbol,ask,sl); } //+------------------------------------------------------------------+ //| Get VWAP function | //+------------------------------------------------------------------+ double getVWAP(int startCandle) { double sumPV = 0.0; // Sum of (price * volume) long sumV = 0.0; // Sum of volume // Loop from the starting candle index down to 1 (excluding current candle) for(int i = startCandle; i >= 1; i--) { // Calculate typical price: (High + Low + Close) / 3 double high = iHigh(_Symbol, PERIOD_CURRENT, i); double low = iLow(_Symbol, PERIOD_CURRENT, i); double close = iClose(_Symbol, PERIOD_CURRENT, i); double typicalPrice = (high + low + close) / 3.0; // Get volume and update sums long volume = iVolume(_Symbol, PERIOD_CURRENT, i); sumPV += typicalPrice * volume; sumV += volume; } // Calculate VWAP or return 0 if no volume if(sumV == 0) return 0.0; double vwap = sumPV / sumV; // Plot the dot datetime currentBarTime = iTime(_Symbol, PERIOD_CURRENT, 0); string objName = "VWAP" + TimeToString(currentBarTime, TIME_MINUTES); ObjectCreate(0, objName, OBJ_ARROW, 0, currentBarTime, vwap); ObjectSetInteger(0, objName, OBJPROP_COLOR, clrGreen); // Green dot ObjectSetInteger(0, objName, OBJPROP_STYLE, STYLE_DOT); // Dot style ObjectSetInteger(0, objName, OBJPROP_WIDTH, 1); // Size of the dot return vwap; } //+------------------------------------------------------------------+ //| Find the index of the candle corresponding to the session open | //+------------------------------------------------------------------+ int getSessionStartIndex() { int sessionIndex = 1; // Loop over bars until we find the session open for(int i = 1; i <=1000; i++) { datetime barTime = iTime(_Symbol, PERIOD_CURRENT, i); MqlDateTime dt; TimeToStruct(barTime, dt); if(dt.hour == startHour && dt.min == 30) { sessionIndex = i; break; } } return sessionIndex; } //+------------------------------------------------------------------+ //| Get the number of bars from now to market open | //+------------------------------------------------------------------+ int getBarShiftForTime(datetime day_start, int hour, int minute) { MqlDateTime dt; TimeToStruct(day_start, dt); dt.hour = hour; dt.min = minute; dt.sec = 0; datetime target_time = StructToTime(dt); int shift = iBarShift(_Symbol, PERIOD_M1, target_time, true); return shift; } //+------------------------------------------------------------------+ //| Get the upper Concretum band value | //+------------------------------------------------------------------+ double getUpperBand() { // Get the time of the current bar datetime current_time = iTime(_Symbol, PERIOD_CURRENT, 0); MqlDateTime current_dt; TimeToStruct(current_time, current_dt); int current_hour = current_dt.hour; int current_min = current_dt.min; // Find today's opening price at 9:30 AM datetime today_start = iTime(_Symbol, PERIOD_D1, 0); int bar_at_930_today = getBarShiftForTime(today_start, 9, 30); if (bar_at_930_today < 0) return 0; // Return 0 if no 9:30 bar exists double open_930_today = iOpen(_Symbol, PERIOD_M1, bar_at_930_today); if (open_930_today == 0) return 0; // No valid price // Calculate sigma based on the past 14 days double sum_moves = 0; int valid_days = 0; for (int i = 1; i <= 14; i++) { datetime day_start = iTime(_Symbol, PERIOD_D1, i); int bar_at_930 = getBarShiftForTime(day_start, 9, 30); int bar_at_HHMM = getBarShiftForTime(day_start, current_hour, current_min); if (bar_at_930 < 0 || bar_at_HHMM < 0) continue; // Skip if bars don't exist double open_930 = iOpen(_Symbol, PERIOD_M1, bar_at_930); double close_HHMM = iClose(_Symbol, PERIOD_M1, bar_at_HHMM); if (open_930 == 0) continue; // Skip if no valid opening price double move = MathAbs(close_HHMM / open_930 - 1); sum_moves += move; valid_days++; } if (valid_days == 0) return 0; // Return 0 if no valid data double sigma = sum_moves / valid_days; // Calculate the upper band double upper_band = open_930_today * (1 + sigma); // Plot a blue dot at the upper band level string obj_name = "UpperBand_" + TimeToString(current_time, TIME_DATE|TIME_MINUTES|TIME_SECONDS); ObjectCreate(0, obj_name, OBJ_ARROW, 0, current_time, upper_band); ObjectSetInteger(0, obj_name, OBJPROP_ARROWCODE, 159); // Dot symbol ObjectSetInteger(0, obj_name, OBJPROP_COLOR, clrBlue); ObjectSetInteger(0, obj_name, OBJPROP_WIDTH, 2); return upper_band; } //+------------------------------------------------------------------+ //| Get the lower Concretum band value | //+------------------------------------------------------------------+ double getLowerBand() { // Get the time of the current bar datetime current_time = iTime(_Symbol, PERIOD_CURRENT, 0); MqlDateTime current_dt; TimeToStruct(current_time, current_dt); int current_hour = current_dt.hour; int current_min = current_dt.min; // Find today's opening price at 9:30 AM datetime today_start = iTime(_Symbol, PERIOD_D1, 0); int bar_at_930_today = getBarShiftForTime(today_start, 9, 30); if (bar_at_930_today < 0) return 0; // Return 0 if no 9:30 bar exists double open_930_today = iOpen(_Symbol, PERIOD_M1, bar_at_930_today); if (open_930_today == 0) return 0; // No valid price // Calculate sigma based on the past 14 days double sum_moves = 0; int valid_days = 0; for (int i = 1; i <= 14; i++) { datetime day_start = iTime(_Symbol, PERIOD_D1, i); int bar_at_930 = getBarShiftForTime(day_start, 9, 30); int bar_at_HHMM = getBarShiftForTime(day_start, current_hour, current_min); if (bar_at_930 < 0 || bar_at_HHMM < 0) continue; // Skip if bars don't exist double open_930 = iOpen(_Symbol, PERIOD_M1, bar_at_930); double close_HHMM = iClose(_Symbol, PERIOD_M1, bar_at_HHMM); if (open_930 == 0) continue; // Skip if no valid opening price double move = MathAbs(close_HHMM / open_930 - 1); sum_moves += move; valid_days++; } if (valid_days == 0) return 0; // Return 0 if no valid data double sigma = sum_moves / valid_days; // Calculate the lower band double lower_band = open_930_today * (1 - sigma); // Plot a red dot at the lower band level string obj_name = "LowerBand_" + TimeToString(current_time, TIME_DATE|TIME_MINUTES|TIME_SECONDS); ObjectCreate(0, obj_name, OBJ_ARROW, 0, current_time, lower_band); ObjectSetInteger(0, obj_name, OBJPROP_ARROWCODE, 159); // Dot symbol ObjectSetInteger(0, obj_name, OBJPROP_COLOR, clrRed); ObjectSetInteger(0, obj_name, OBJPROP_WIDTH, 2); return lower_band; } //+------------------------------------------------------------------+ //| Calculate lot size based on risk and stop loss range | //+------------------------------------------------------------------+ double calclots(double slpoints) { double riskAmount = AccountInfoDouble(ACCOUNT_BALANCE) * risk / 100; double ticksize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE); double tickvalue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE); double lotstep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); double moneyperlotstep = slpoints / ticksize * tickvalue * lotstep; double lots = MathFloor(riskAmount / moneyperlotstep) * lotstep; lots = MathMin(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX)); lots = MathMax(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN)); return lots; }
典型的な取引は次のようになります。
バックテスト結果:
この戦略は、最初のORB戦略とほぼ同じ頻度で取引をおこない、約2営業日に1回が平均的です。価格変動が時にノイズレンジ内に留まり、バンドを突破できないことがあるため、毎日は取引がおこなわれません。勝率は50%を下回っていますが、これはVWAPをダイナミックなトレーリングストップとして使用しているためです。プロフィットファクターは1.3、シャープレシオは5.9と、ドローダウンに対して非常に高いリターンを示しています。
この戦略は、バイアンドホールド戦略をわずかに上回る成績を示しながら、最大ドローダウンは半分に抑えられています。しかし、前述の戦略よりも比較的頻繁に大きなドローダウン期間を経験している点が特徴です。これは、パフォーマンス自体は優れているものの、新たな高値を更新するまでに長期的なドローダウンを耐えなければならない場面が多いことを示しています。
Alpha: 1.6562 Beta: -0.1183
この戦略のベータは-11%であり、原資産との相関がわずかに逆相関であることを示しています。これは、市場のトレンドとは逆方向に動く優位性を求めるトレーダーにとって好ましい結果です。他の2つの戦略と比べると、ドローダウン月が約50%と多いものの、利益が出る月のリターンはより高い傾向にあります。このパターンからは、実運用では長期間のドローダウンを耐え忍び、より大きなリターンの局面を辛抱強く待つ必要があることが示唆されます。十分なサンプル数と堅実な期間を経て、この戦略は引き続き実運用可能であると言えます。
振り返り
前回の記事では、単一の戦略ではなくモデルシステムを構築することについて探求しました。本記事でも同様の考え方を適用しています。今回紹介した3つの戦略はいずれも株式市場のオープニングレンジブレイクアウトを起点とし、各々が収益性の高いバリエーションとして実証されています。また、学術論文を自分たちの知識と直感を組み合わせて応用し、戦略の優位性を見つけるという洞察も共有しました。このアプローチは、堅牢な取引概念を発掘し、自身の理解を深めるうえで非常に有効です。
3つの収益性のある戦略を手に入れた今、次に考えるべきはポートフォリオの視点です。それらの合算した結果、相関関係、全体の最大ドローダウンを調査し、同時に運用する前に慎重に評価する必要があります。アルゴリズム取引において、分散投資こそが真の「ホーリーグレイル(聖杯)」と言えます。これは、異なる期間における複数の戦略のドローダウンを相殺する効果があり、ある意味では、許容できるドローダウンによって最大リターンが制限されるため、多様な戦略を組み合わせることで同じドローダウン水準を維持しつつ、エクスポージャーを増やし、リターンを高めることができます。ただし、リスクを無限に拡大できるわけではなく、最小リスクは個々の取引のリスクを下回ることはありません。
分散投資を実現する一般的な方法としては以下があります。
- 同一戦略モデルを異なる相関の低い資産に分散して運用する
- 単一資産に対して異なる戦略モデルを運用する
- オプション、裁定取引、個別株選択など、異なる取引手法に資金を振り分ける
重要なのは、分散が多ければ良いというわけではなく、相関が低い分散が重要であるということです。たとえば、同じ戦略をすべての暗号通貨市場に適用するのは理想的ではありません。なぜなら、ほとんどの暗号資産は広範囲で非常に高い相関を持っているからです。さらに、バックテスト上の分散だけに頼るのも誤解を生みやすいです。なぜなら、相関は日次リターンや月次リターンなどの期間によって変化し、市場の急激なレジームシフトの際には、戦略間の相関が予期せず歪んだり変動したりするからです。そのため、一部のトレーダーはバックテスト結果の相関だけでなく、実運用の結果の相関も併せて評価し、戦略の優位性が減少していないか確認することを好みます。
このような知識を踏まえたうえで、以下に3つの戦略の合成パフォーマンスのバックテスト統計を示します。
資産とドローダウンの曲線は、異なる戦略が様々な期間においてお互いのドローダウンを相殺し合っている様子を視覚的に示しています。最大ドローダウンは約10%に抑えられており、これは個々の戦略の最大ドローダウン(いずれも15%以上)と比べて著しく低い水準です。
ドローダウンとリターンは月ごとに均等に分布しているように見え、特定の極端な相場環境がバックテストのパフォーマンスに偏って影響を与えているわけではないことを示唆しています。これは、3,000以上のサンプル数と、取引ごとに一貫したリスク配分がおこなわれていることから、合理的な結果と言えます。
相関とは、各戦略のバックテストにおけるエクイティカーブの動きの類似度を示す指標であり、-1は正反対の動きを、1は完全に同じ動きを意味します。通常は2つの対象を比較して算出されます。ここでは、資産曲線の時間軸をx軸、リターン軸をy軸として相関を計算しています。
結論
本記事では、Concretum Groupの学術論文に基づく3つの日中オープニングレンジブレイクアウト戦略をレビューしました。まず研究の背景を概説し、記事全体で用いられる主要な概念や手法を説明しました。次に、3つの戦略の動機を探り、改善点を明確にし、シグナルルール、MQL5コード、バックテストの統計解析を提示しました。最後に、このプロセスを振り返り、分散投資の重要性を紹介し、3戦略の統合パフォーマンスを分析しました。
本記事は、戦略開発の真の堅牢性についての洞察を提供しています。より深い統計解析は、戦略のパフォーマンス全体像とポートフォリオ内での役割を広い視点から捉えることを可能にします。これらすべての取り組みは、実運用前に理解を深め、自信を持つためのものです。読者の皆様には、紹介したフレームワークを用いて研究プロセスを再現し、EAの開発に挑戦することをお勧めします。
ファイルの表
ファイル名 | 説明 |
---|---|
ORB1.mq5 | 最初の戦略のMQL5 EAスクリプト |
ORB2.mq5 | 2番目の戦略のMQL5 EAスクリプト |
ORB3.mq5 | 3番目の戦略のMQL5 EAスクリプト |
MetaQuotes Ltdにより英語から翻訳されました。
元の記事: https://www.mql5.com/en/articles/17745
警告: これらの資料についてのすべての権利はMetaQuotes Ltd.が保有しています。これらの資料の全部または一部の複製や再プリントは禁じられています。
この記事はサイトのユーザーによって執筆されたものであり、著者の個人的な見解を反映しています。MetaQuotes Ltdは、提示された情報の正確性や、記載されているソリューション、戦略、または推奨事項の使用によって生じたいかなる結果についても責任を負いません。





- 無料取引アプリ
- 8千を超えるシグナルをコピー
- 金融ニュースで金融マーケットを探索
Ps.ORB3では、マーケットのオープン時間を9:30にハードコードしました。ORB3では、マーケットオープン時間を9:30にハードコードしました。
計算時間を変更することで、ストラテジーをさらに最適化できるかもしれません。)
OMG、これを見逃すなんて信じられない。そうあるべきだ:
うっかりミスで本当に申し訳ない。注意深く読んで、指摘してくれてありがとう。
オープンマーケットとクローズドマーケットを1つの関数に統合しましたので、ご心配なく。
もう一つ、バックテストにブローカーのOHLCデータを使用していますが、遅延はありません。これらのバックテストは、スリッページとリクオートのためのランダムな遅延がある実際のティックデータで行われたバックテストと比較すると、少し楽観的なようです。
ご苦労様でした!
大丈夫、オープンマーケットとクローズドマーケットはすでに一つの機能に統合してある。
もう一つ、バックテストにはブローカーのOHLCデータを使用し、遅延はありません。これらのバックテストは、スリッページとリクオートのためのランダムな遅延を持つ実際のティックデータで行われたバックテストと比較して、少し楽観的なようです。
ご苦労様でした!
ご改造ありがとうございます!私のGithubですべてのコードを更新しました。
ご心配いただいている点についてですが、取引ロジックは新しいバーごとに発生し、ティックの動きには関与しません。その上、平均保有時間は数時間のようなもので、スリッページが大きな問題になることはないと思います。5年以上リアルティックのデータを提供しているブローカーはほとんどありませんし、OHLCは1分足で十分です。