MQL5 保本與移動停損:PositionModify、ATR 距離、停損只往獲利方向搬

開倉時設好的停損停利,之後就不動了:要嘛碰停損賠一筆固定的錢,要嘛碰停利賺一筆固定的錢。這篇加兩個常見的出場:保本是賺到一定程度就把停損搬到進場價附近,之後最差是平手;移動停損是停損跟著價格走,賺到的鎖一部分。兩個做法的共同規則只有一句:停損只往獲利方向搬,永遠不往回搬。

範例 MaCrossTrail.mq5 是餘額 1% 風險那隻均線交叉 EA(MaCrossRisk.mq5)加一個 ManagePosition(),每根新 K 線先看持倉的停損要不要搬、再看訊號;開倉多了「停利倍數 0 就不設」的處理,魔術碼另外換了一個,其他沒動。整份在我的 MT5 上編譯過,策略測試器用 2025 年 1 月的 EURUSD H1 跑了四種設定,文中日誌和數字是實際輸出。

先把範例跑起來

  1. 把 MaCrossTrail.mq5 放到 MQL5\Experts\,在 MetaEditor 打開、按 F7,0 errors。
  2. 策略測試器 Ctrl+R:EA 選 MaCrossTrail,EURUSD,H1,日期 2025.01.02 到 2025.01.31,模式「每次報價」,入金 10000,杠杆 1:100,按「開始」。
  3. 看「日誌」頁。每筆開倉之後多了「移動」「保本」兩種行,印停損從哪搬到哪;測試器自己也會對每次修改印一行 position modified。

1. 參數與交易物件

//=== 1. 參數與交易物件 ==============================================
#include <Trade\Trade.mqh>

input int            i_FastPeriod = 10;       // 快線期數
input int            i_SlowPeriod = 30;       // 慢線期數
input ENUM_MA_METHOD i_MaMethod   = MODE_EMA; // 均線種類
input int            i_AtrPeriod  = 14;       // ATR 期數
input double         i_RiskPct    = 1.0;      // 每筆風險(餘額的 %)
input double         i_MaxLots    = 1.0;      // 手數上限
input double         i_SlAtr      = 2.0;      // 停損 = ATR x 倍數
input double         i_TpAtr      = 3.0;      // 停利倍數,0 不設
input double         i_BeAtr      = 1.0;      // 保本門檻倍數,0 不用
input int            i_BeLockPts  = 10;       // 保本時多鎖幾點
input double         i_TrailAtr   = 2.0;      // 移動停損倍數,0 不用
input long           i_Magic      = 20250701; // 魔術碼

CTrade   g_Trade;
int      g_FastHandle  = INVALID_HANDLE;
int      g_SlowHandle  = INVALID_HANDLE;
int      g_AtrHandle   = INVALID_HANDLE;
datetime g_LastBarTime = 0;

多三個參數。i_BeAtr:賺到幾倍 ATR 就保本,保本價是進場價再往獲利方向多 i_BeLockPts 點,把手續費蓋掉。i_TrailAtr:移動停損跟現價保持幾倍 ATR 的距離。兩個都設 0 就是關掉,跑出來會跟 MaCrossRisk 一模一樣,文末的對照就是這樣跑的。i_TpAtr 也多了一個意思:設 0 就不掛停利,讓移動停損自己決定什麼時候出。

指標 handle、算手數、查保證金、找持倉那幾段都沒改,檔案裡有,這篇不貼。

2. 開倉:停利倍數 0 就不設

   bool   useTp   = (i_TpAtr > 0);
   ...
   if(useTp && tpDist < minDist) tpDist = minDist;
   ...
   double tp = 0;                                 // 0 = 不設停利
   if(useTp)
      tp = NormalizeDouble(isBuy ? ask + tpDist : bid - tpDist,
                           _Digits);

CTrade 的 Buy()Sell() 停利傳 0 就是不設。原本的寫法直接把 i_TpAtr 設 0 拿不到這個效果:停利距離 0 會被「不能小於 StopsLevel」那行補成最小距離,掛出一個貼著現價的停利。所以補正那行和算 tp 那幾行都看 useTp

3. 保本與移動停損

//=== 3. 保本與移動停損:算出想要的停損價,比現在的好才改 ============
void ManagePosition(ulong ticket, int type, double atr)
{
   if(!PositionSelectByTicket(ticket))
      return;
   double open  = PositionGetDouble(POSITION_PRICE_OPEN);
   double sl    = PositionGetDouble(POSITION_SL);
   double tp    = PositionGetDouble(POSITION_TP);
   bool   isBuy = (type == POSITION_TYPE_BUY);
   double dir   = isBuy ? 1 : -1;                 // 多 +1、空 -1
   double price = SymbolInfoDouble(_Symbol, isBuy ? SYMBOL_BID
                                                  : SYMBOL_ASK);
   double gain  = dir * (price - open);           // 現在賺的價差

   // 兩個候選:保本價、移動停損價,留對自己最有利的
   double want = sl;                              // 目前的停損
   if(sl == 0)                                   // 沒停損:從最差起算
      want = isBuy ? 0 : DBL_MAX;
   string why = "";
   if(i_BeAtr > 0 && gain >= atr * i_BeAtr)
   {
      double be = open + dir * i_BeLockPts * _Point;
      if(dir * (be - want) > 0) { want = be; why = "保本"; }
   }
   if(i_TrailAtr > 0)
   {
      double tr = price - dir * atr * i_TrailAtr;
      if(dir * (tr - want) > 0) { want = tr; why = "移動"; }
   }
   want = NormalizeDouble(want, _Digits);
   if(why == "")
      return;                                     // 沒有比現在好
   if(sl != 0 && dir * (want - sl) < _Point * 0.5)
      return;                                     // 好不到 1 點,不改

   // 新停損離現價至少要 StopsLevel,太近伺服器會拒
   long stops = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL);
   if(dir * (price - want) < stops * _Point)
      return;

   if(!g_Trade.PositionModify(ticket, want, tp) ||
      g_Trade.ResultRetcode() != TRADE_RETCODE_DONE)
   {
      Print("改停損失敗 ", g_Trade.ResultRetcode(), " ",
            g_Trade.ResultRetcodeDescription());
      return;
   }
   Print(why, " #", ticket, "  停損 ", DoubleToString(sl, _Digits),
         " -> ", DoubleToString(want, _Digits),
         "  現價 ", DoubleToString(price, _Digits));
}

dir 是 +1(多)或 −1(空),乘上去之後多單空單共用同一套式子:dir * (a - b) > 0 就是「a 比 b 對自己更有利」,多單是 a 比 b 高、空單是 a 比 b 低。gain 是現在賺的價差,用平倉會用到的那邊的價,多單看 Bid、空單看 Ask。

接著算兩個候選停損價:保本價(賺夠了才有)和移動停損價(現價往回退 ATR 倍數),want 從現在的停損起算,候選價要比它更有利才換上去。那兩個 if 就是停損只往獲利方向搬:候選價比現在的停損差,就什麼都不做。差不到 1 點的也不改,省得每根 K 線都丟一筆修改給伺服器。

改之前看 StopsLevel:新停損離現價要有那個距離,太近伺服器會拒。然後 PositionModify(ticket, sl, tp) 改停損,停利要把原值一起傳回去,傳 0 會把原本的停利拿掉。

這個函式沒有記住任何東西:有沒有保本過、上次搬到哪,全部從持倉本身讀回來。EA 重啟、換台電腦接著跑,看到的持倉是什麼就從什麼開始。用一個「已經保本」的布林變數也做得到,但重啟就丟了。

4. OnTick:先管持倉、再看訊號

//=== 4. OnTick:新 K 線先管持倉、再看訊號 ==========================
void OnTick()
{
   ShowStatus();
   if(!IsNewBar())
      return;

   double fast[], slow[], atr[];
   ArraySetAsSeries(fast, true);
   ArraySetAsSeries(slow, true);
   ArraySetAsSeries(atr,  true);
   if(CopyBuffer(g_FastHandle, 0, 0, 3, fast) < 3 ||
      CopyBuffer(g_SlowHandle, 0, 0, 3, slow) < 3 ||
      CopyBuffer(g_AtrHandle,  0, 0, 3, atr)  < 3)
   {
      Print("指標資料還沒準備好,錯誤碼 ", GetLastError());
      g_LastBarTime = 0;
      return;
   }

   ulong ticket = 0;
   int   have   = FindMyPosition(ticket);
   if(have >= 0)
      ManagePosition(ticket, have, atr[1]);

一根 K 線只看一次:新 K 線的第一個 tick,用上一根收完的 ATR 和那時的現價算。想要每個 tick 都跟著搬,得把 CopyBuffer 和這個呼叫搬到 IsNewBar() 前面,而且「差不到 1 點不改」要放寬,不然一秒鐘會丟好幾筆修改。

移動停損的距離用當下的 ATR,波動縮小距離跟著縮,所以會出現價格沒漲、停損卻往上收的情況,下面的日誌有例子。要固定距離就在開倉時把 ATR 記下來。

執行結果

EURUSD 2025 年 1 月,預設參數(保本 1 ATR、移動停損 2 ATR、停利 3 ATR),14 筆,進場時間和價格跟 MaCrossRisk 逐筆相同,手數因餘額不同差一步。拿 #6 這筆看整個過程:

開多 #6  0.27 手 @1.02483  停損 380 點  風險 102.60
移動 #6  停損 1.02103 -> 1.02123  現價 1.02490
移動 #6  停損 1.02123 -> 1.02148  現價 1.02487
移動 #6  停損 1.02148 -> 1.02169  現價 1.02504
移動 #6  停損 1.02169 -> 1.02220  現價 1.02525
移動 #6  停損 1.02220 -> 1.02233  現價 1.02543
移動 #6  停損 1.02233 -> 1.02290  現價 1.02618
保本 #6  停損 1.02290 -> 1.02493  現價 1.02680
移動 #6  停損 1.02493 -> 1.02613  現價 1.02949
移動 #6  停損 1.02613 -> 1.02635  現價 1.02992

開多之後停損跟著往上搬了 6 次,賺到 1 ATR 時跳到保本價 1.02493(進場 1.02483 加 10 點),之後再跟 2 次,最後碰停利出場。

第 2、3 行就是上面說的情況:現價從 1.02490 掉到 1.02487,停損卻從 1.02123 搬到 1.02148,因為這根的 ATR 比上一根小。

整個月 EA 印了 62 行修改,測試器日誌也有 62 行 position modified,一一對應。修改不是成交,報告的交易總計還是 14。

同一個月、同一套訊號,四種設定的 14 筆各怎麼結束。「獲利側」是停損已經搬過進場價才被打到,也就是保本或移動停損收掉的;「虧損側」是停損還在進場價另一邊;「反向/測試結束」是被反向訊號平掉、或測試結束時強制平倉:

設定停利停損在獲利側停損在虧損側反向/測試結束
固定停損停利(對照)7025
+保本5324
+保本+移動停損5333
+保本+移動停損,不設停利0833

報告數字:

設定總淨盈利 (USD)盈利因子最大權益數虧損
固定停損停利(對照)656.512.51441.66 (4.19%)
+保本387.881.96461.90 (4.38%)
+保本+移動停損415.992.11433.79 (4.11%)
+保本+移動停損,不設停利197.571.53435.01 (4.15%)

三種改法這個月都輸給固定停損停利。只加保本那趟:8 筆保本過,3 筆最後在保本價出場。其中 #18、#20 原本是到停利各賺 148.20、152.64 塊,現在先回檔碰到保本價,各賺 3.90、4.70 塊(正好 10 點乘手數,扣掉手續費還是正的);#8 原本是被反向訊號平掉賠 28.08,變成賺 3.60。對照趟碰停損的 2 筆,保本一筆也沒救到,一樣賠。

再加移動停損:#14 開倉時印的風險是 102.48,停損先跟上來一段才被打到,賠 66.49;#16 原本是被反向訊號平掉賠 59.07,停損跟上來之後在虧損側被打到,賠 66.99。一筆少賠、一筆變糟。

不設停利那趟跟上一趟只差停利,最差。上一趟到停利的 5 筆,這趟全部變成在獲利側被移動停損收掉:只有 #6 賺更多(153.90 變 173.42),其他 4 筆各少 40 到 78 塊;其餘 9 筆出場一樣。價格每次都到了原本停利的位置又回頭,要退 2 ATR 才出場,退掉的就是那段。1 月的 EURUSD 沒有那種一路走不回頭的趨勢,「讓獲利跑」跑不出去。

這不是保本和移動停損沒用的結論,是 14 筆的一個月看不出來。它們改變的是每一筆怎麼結束,整體是好是壞看行情有沒有給趨勢。要不要用、距離幾倍 ATR,把同一段長一點的資料四種設定各跑一次比報告,不要憑直覺覺得保本一定比較安全。

本篇檔案:MaCrossTrail.mq5,放到 MQL5\Experts\ 底下,在 MetaEditor 打開按 F7 就能用。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

這個網站採用 Akismet 服務減少垃圾留言。進一步了解 Akismet 如何處理網站訪客的留言資料