両建てグリッドEAをゼロから作る(第4回:サブ玉で負け玉を1本ずつ剥がす)

この連載は研究用です。 読み物として楽しんでもらえれば。

前回のおさらい

第3回のEAは、抜けた方向へ玉を足し、全体がプラスになったらまとめて決済しました。122勝1敗まで勝率は上がりましたが、その1敗は、往復ビンタで上限の20本まで積み上がり、そのまま固まったものでした。

剥がし型には、この「積み上がった玉」を処理するための仕組みがもう1つあります。第0回で見た⑤剥がし型の名前のもとになった「剥がし」です。今回はこれを足して、剥がし型を完成させます。 

第4回:サブ玉を建てて、その稼ぎでメイン玉を1本ずつ消す

これまでの玉をまとめて メイン玉、負けを回収するために新しく足す玉を サブ玉 と呼びます。

項目設定
メイン玉第3回と同じ(ADXでエントリー、+5ドルで利食い、5ドル逆行でロック、5ドル抜けで追加、上限20本)
サブ玉を始める条件メイン玉が 8本以上になったとき
サブ玉の中心メイン玉の建値の一番高いところと一番安いところの、ちょうど真ん中
サブ玉を建てる中心から 5ドル抜けたら、その方向に 0.02ロット(メイン玉の2倍)。さらに5ドル進むごとに1本追加、上限5本
剥がす「メイン玉で一番負けている1本」と「サブ玉全部」の損益の合計が +1ドル以上になったら、そのメイン玉の1本とサブ玉を全部決済。中心を引き直して続ける
まとめて決済メイン玉とサブ玉を合わせた損益が +5ドル以上で全部決済(第3回と同じ)
剥がしの2つの結末

ねらいは左図です。メイン玉がどちらかに抜けたとき、サブ玉が同じ方向に大きめのロットで乗って稼ぎます。その稼ぎで、メイン玉の一番ひどい含み損を1本だけ消す。これを繰り返せば、積み上がったメイン玉が少しずつ減っていくはずです。

(ここでの値は、この連載で決めたものです。サブ玉を何本目から始めるか、ロットをどう決めるかなどは作り方しだいで、別の値は第12回で回します。) 

結果:損は3倍、DDも3倍

条件は第0回の表のとおりです(XAUUSD、2025-01-01〜2026-07-01、リアルティック、メイン玉は0.01ロット、初期証拠金 50,000 USD)。

項目第3回第4回
1サイクル(エントリーから全部決済まで)123回48回
うちプラスで終わったサイクル122回(99%)47回(98%、合計 +240 USD)
損益合計−3,279 USD−10,009 USD
最大DD(含み損込み)−6,414 USD(12.8%)−19,061 USD(38%)
同時に持った玉最大20本・0.20ロット最大24本・0.28ロット
試行回数 N1920
DSR0.0020.001(N=100 換算 0.000)
資産の推移

確定した残高(黒)は、2025年2月で止まっています。そこから1年4か月、一度も決済がありません。含み損込みの線(灰色)は第3回より早く、深く沈み、2026年1月末には 31,179 USD まで下がりました。

剥がしを足したら、損もDDも第3回の約3倍になりました。 

剥がしは、ちゃんと動いていた

最初の2か月は、ねらいどおりに動いています。剥がしは4回ありました。

日時(サーバー時間)消した玉剥がしの損益
2025-01-21 17:01メイン玉1本 + サブ玉3本+1.10 USD
2025-01-30 17:43メイン玉1本 + サブ玉4本+1.25 USD
2025-02-03 16:50メイン玉1本 + サブ玉3本+1.74 USD
2025-02-28 15:55メイン玉1本 + サブ玉4本+1.02 USD

たとえば2月28日は、メイン玉の中で一番負けていた買いの1本(スワップ込み −110.28 USD)を、サブ玉の売り4本の利益(合計 +111.30 USD)で消しています。 

何が起きたか:サブ玉は「損切りのない大きめの賭け」

問題は、その2月28日の直後です。

固まったサイクルの始まり
  • 2月11〜20日:上下に振られて、メイン玉が上限の20本まで積み上がる(第3回と同じ往復ビンタ)
  • 2月28日:Goldが2,850ドル台まで下げたところで剥がしが成功。メイン玉が19本になる
  • 剥がした直後に中心を引き直すと、価格はすでに5ドル下に抜けていたので、サブ玉の売りがすぐに建つ。メイン玉も1本足されて、また20本
  • 同じ日のうちにサブ玉の売りが4本(0.08ロット)そろう。安値は2,833ドル

ところが、Goldはここで底を打ちました。そこから上がり続け、テスト終了時には4,000ドル台です。

  • サブ玉には損切りがない。剥がしもまとめ決済も「プラスになったら」しか動かない
  • メイン玉は上限の20本で、もう追加もできない
  • 0.02ロットの売りが残っているので、上がれば上がるほどメイン玉の2倍の速さで損が増える

2月28日の17時を最後に、EAは1年4か月、1本も建てていません。 テスト終了で強制決済したときの内訳は、サブ玉の売り4本だけで −8,760 USD、メイン玉の20本は差し引き −1,488 USD でした(メイン玉はスワップが −3,169 USD)。

剥がしは、メイン玉の含み損を「消す」仕組みではなく、一方向に大きめに賭けて、当たったらメイン玉を1本消す仕組みです。外れたときの損は、メイン玉より大きなロットで積み上がります。 

第4回のまとめ

  • 剥がしは、トレンドが続く間は、負けているメイン玉を1本ずつきれいに消してくれる
  • ただしサブ玉には損切りがないので、外れるとメイン玉より大きなロットで含み損が増える
  • 今回の期間では、第3回より早く・大きく負けた。回収の仕組みを足しても、損がなくなるわけではない

ここまでで、第0回の⑤剥がし型の部品はすべてそろいました。剥がし型は、どう作ってもサブ玉に損切りがない(入れると剥がしの意味がなくなる)のがポイントです。 

次回

次回からは番外編です。第0回で紹介した残りの型を、同じ条件で1つずつ回します。最初は、いちばん素直な ①同時両建て型 です。 

試行回数について

今回は N=20 です。記事にする前に、同じEAを別の初期証拠金で回したことがあります。ロットが固定なのでトレードは1本残らず同じになり、試行としては同じ1回と数えています。 

ソースコード

まーまだ使う人はいないとはず。ログ出力の hg_log.mqh は第1回と同じです。メイン玉とサブ玉はマジックナンバーを分けています(サブ玉はメイン玉の +1)。ファイル名の番号(HG06)は作った順の通し番号で、回の番号とはずれています。

HG06_Recovery.mq5

//+------------------------------------------------------------------+
//| HG06_Recovery.mq5                                                 |
//| Hedged Grid series, chapter 4: sub group that removes the         |
//| worst losing position one at a time.                              |
//| Main  : same as HG04 (entry, lock, breakout adds,                 |
//|         basket close of everything at InpBasketTarget)            |
//| Sub   : once the main group has InpSubStart positions, take the   |
//|         middle of its highest and lowest open prices. When price  |
//|         moves InpSubDistance away from it, open InpSubLot in that |
//|         direction, and add one more every InpSubDistance.         |
//| Peel  : when (worst main position + all sub positions) >=         |
//|         InpPeelTarget, close them all, then re-center.            |
//| Research code for the fxwolf2.com blog. Not for live trading.     |
//+------------------------------------------------------------------+
#property copyright "fxwolf2.com"
#property version   "1.00"
#property strict

#include <Trade\Trade.mqh>
#include "hg_log.mqh"

input double InpLot          = 0.01;   // Main lot size
input int    InpAdxPeriodM1  = 14;     // ADX period (M1)
input double InpAdxLevelM1   = 22.0;   // ADX threshold (M1)
input int    InpAdxPeriodHTF = 14;     // ADX period (higher timeframe)
input double InpAdxLevelHTF  = 22.0;   // ADX threshold (higher timeframe)
input ENUM_TIMEFRAMES InpHTF = PERIOD_M30; // Higher timeframe
input double InpTakeProfit   = 5.0;    // Take profit of a single position (USD)
input double InpLockDistance = 5.0;    // Adverse move that triggers the lock (USD)
input double InpAddDistance  = 5.0;    // Main breakout distance beyond the outermost open price (USD)
input double InpBasketTarget = 5.0;    // Close all when floating profit reaches this (USD)
input int    InpMaxPositions = 20;     // Main position cap
input int    InpSubStart      = 8;      // Main positions needed before the sub group starts
input double InpSubLot        = 0.02;   // Sub lot size
input double InpSubDistance   = 5.0;    // Sub breakout distance from the center / between sub adds (USD)
input int    InpSubMax        = 5;      // Sub position cap
input double InpPeelTarget   = 1.0;    // Peel when worst main + all sub reaches this (USD)
input long   InpMagic        = 260806; // Magic number (main); sub uses InpMagic + 1
input string InpTag          = "ch06"; // Log file tag

CTrade   trade;
int      hAdxM1 = INVALID_HANDLE, hAdxHTF = INVALID_HANDLE;
datetime lastEntryBar = 0;
bool     subMode = false;               // Sub group is active for the current cycle
double   subCenter = 0;

int OnInit()
{
   hAdxM1  = iADX(_Symbol, PERIOD_M1, InpAdxPeriodM1);
   hAdxHTF = iADX(_Symbol, InpHTF, InpAdxPeriodHTF);
   if(hAdxM1 == INVALID_HANDLE || hAdxHTF == INVALID_HANDLE) return INIT_FAILED;
   trade.SetTypeFillingBySymbol(_Symbol);
   return INIT_SUCCEEDED;
}

void OnDeinit(const int reason)
{
   HGLog_Deinit(InpTag);
}

// Direction from one ADX handle on the last closed bar: 1=buy, -1=sell, 0=none
int AdxSide(const int handle, const double level)
{
   double adx[1], plus[1], minus[1];
   if(CopyBuffer(handle, 0, 1, 1, adx)   != 1) return 0;
   if(CopyBuffer(handle, 1, 1, 1, plus)  != 1) return 0;
   if(CopyBuffer(handle, 2, 1, 1, minus) != 1) return 0;
   if(adx[0] < level) return 0;
   if(plus[0] > minus[0]) return 1;
   if(minus[0] > plus[0]) return -1;
   return 0;
}

bool Ours(const ulong t, const long magic)
{
   return t > 0 && PositionGetString(POSITION_SYMBOL) == _Symbol
          && PositionGetInteger(POSITION_MAGIC) == magic;
}

double Net(const ulong t)
{
   PositionSelectByTicket(t);
   return PositionGetDouble(POSITION_PROFIT) + PositionGetDouble(POSITION_SWAP);
}

// Scan one group: count, floating profit, outermost open prices, last ticket,
// worst position (lowest net) and the open price of the latest position
int Scan(const long magic, double &floating, double &hiOpen, double &loOpen,
         ulong &last, ulong &worst, double &lastOpen)
{
   int n = 0;
   double worstNet = DBL_MAX;
   datetime lastTime = 0;
   floating = 0; hiOpen = -DBL_MAX; loOpen = DBL_MAX; last = 0; worst = 0; lastOpen = 0;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      ulong t = PositionGetTicket(i);
      if(!Ours(t, magic)) continue;
      n++;
      last = t;
      double net = PositionGetDouble(POSITION_PROFIT) + PositionGetDouble(POSITION_SWAP);
      floating += net;
      if(net < worstNet) { worstNet = net; worst = t; }
      double op = PositionGetDouble(POSITION_PRICE_OPEN);
      hiOpen = MathMax(hiOpen, op);
      loOpen = MathMin(loOpen, op);
      datetime ot = (datetime)PositionGetInteger(POSITION_TIME);
      if(ot >= lastTime) { lastTime = ot; lastOpen = op; }
   }
   return n;
}

void CloseGroup(const long magic)
{
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      ulong t = PositionGetTicket(i);
      if(Ours(t, magic)) trade.PositionClose(t);
   }
}

int SubSide()
{
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      ulong t = PositionGetTicket(i);
      if(Ours(t, InpMagic + 1))
         return PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY ? 1 : -1;
   }
   return 0;
}

void Open(const long magic, const int side, const double lot, const double tp,
          const string what, const MqlTick &tick)
{
   trade.SetExpertMagicNumber(magic);
   string c = StringFormat("HG06 %s %s sp=%.2f", what, side == 1 ? "buy" : "sell", tick.ask - tick.bid);
   if(side == 1) trade.Buy(lot, _Symbol, tick.ask, 0, tp, c);
   else          trade.Sell(lot, _Symbol, tick.bid, 0, tp, c);
}

void OnTick()
{
   HGLog_OnTick();
   MqlTick tick;
   if(!SymbolInfoTick(_Symbol, tick)) return;

   double f1, hi1, lo1, lastOpen1, f2, hi2, lo2, lastOpen2;
   ulong last1, worst1, last2, worst2;
   int n1 = Scan(InpMagic,     f1, hi1, lo1, last1, worst1, lastOpen1);
   int n2 = Scan(InpMagic + 1, f2, hi2, lo2, last2, worst2, lastOpen2);

   if(n1 == 0 && n2 > 0) { CloseGroup(InpMagic + 1); return; }   // sub never stays alone
   if(n1 == 0) subMode = false;

   if(n1 == 1 && n2 == 0)
   {
      // Single position: take profit is on the order; lock after an adverse move
      PositionSelectByTicket(last1);
      bool   isBuy = PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY;
      double open  = PositionGetDouble(POSITION_PRICE_OPEN);
      if(isBuy && tick.bid <= open - InpLockDistance)
      {
         trade.PositionModify(last1, 0, 0);
         Open(InpMagic, -1, InpLot, 0, "lock", tick);
      }
      if(!isBuy && tick.ask >= open + InpLockDistance)
      {
         trade.PositionModify(last1, 0, 0);
         Open(InpMagic, 1, InpLot, 0, "lock", tick);
      }
      return;
   }
   if(n1 >= 2 || n2 > 0)
   {
      // Basket: everything (main + sub) at the target
      if(f1 + f2 >= InpBasketTarget)
      {
         CloseGroup(InpMagic + 1); CloseGroup(InpMagic);
         subMode = false;
         return;
      }
      // Peel: worst main position paid for by the sub group
      if(n2 > 0 && worst1 > 0 && Net(worst1) + f2 >= InpPeelTarget)
      {
         CloseGroup(InpMagic + 1);
         trade.PositionClose(worst1);
         n1 = Scan(InpMagic, f1, hi1, lo1, last1, worst1, lastOpen1);
         subCenter = n1 > 0 ? (hi1 + lo1) / 2 : 0;
         return;
      }
      // Sub group starts once the main group is large enough, and stays on until the cycle ends
      if(!subMode && n1 >= InpSubStart) { subMode = true; subCenter = (hi1 + lo1) / 2; }
      if(subMode)
      {
         if(n2 == 0)
         {
            if(tick.ask >= subCenter + InpSubDistance)      Open(InpMagic + 1,  1, InpSubLot, 0, "sub", tick);
            else if(tick.bid <= subCenter - InpSubDistance) Open(InpMagic + 1, -1, InpSubLot, 0, "sub", tick);
         }
         else if(n2 < InpSubMax)
         {
            int s = SubSide();
            if(s == 1 && tick.ask >= lastOpen2 + InpSubDistance)       Open(InpMagic + 1,  1, InpSubLot, 0, "sub add", tick);
            else if(s == -1 && tick.bid <= lastOpen2 - InpSubDistance) Open(InpMagic + 1, -1, InpSubLot, 0, "sub add", tick);
         }
      }
      // Main breakout adds, as in HG04
      if(n1 >= 1 && n1 < InpMaxPositions)
      {
         if(tick.ask >= hi1 + InpAddDistance)      Open(InpMagic,  1, InpLot, 0, "add", tick);
         else if(tick.bid <= lo1 - InpAddDistance) Open(InpMagic, -1, InpLot, 0, "add", tick);
      }
      return;
   }

   // Flat: at most one new entry per M1 bar
   datetime bar = iTime(_Symbol, PERIOD_M1, 0);
   if(bar == lastEntryBar) return;

   int side = AdxSide(hAdxM1, InpAdxLevelM1);
   if(side == 0 || AdxSide(hAdxHTF, InpAdxLevelHTF) != side) return;

   double tp = side == 1 ? NormalizeDouble(tick.ask + InpTakeProfit, _Digits)
                         : NormalizeDouble(tick.bid - InpTakeProfit, _Digits);
   Open(InpMagic, side, InpLot, tp, "entry", tick);
   lastEntryBar = bar;
}

コメント

タイトルとURLをコピーしました