MQL5 掛單:Buy Stop 與 Sell Stop、到期時間、改單刪單,一張成交就刪另一張

市價單是用現在的價格馬上成交;掛單是先把價格、停損停利交給券商,價格到了才成交。這篇把通道突破改成用掛單進場:每根 K 線在通道上緣(前 20 根的最高點)掛一張 Buy Stop、下緣(最低點)掛一張 Sell Stop,一張成交就刪掉另一張。

範例 EA:ChannelStop.mq5,送單、找單、改單、刪單都在這一個檔;參數、手數(每筆賠餘額 1% 倒推)這些在檔案開頭,文中不貼。文中的日誌都是策略測試器的實際輸出(EURUSD H1,2025 年 1 月,每次報價)。

1. 四種掛單,各看哪個價

掛在現價上方的有兩種:Buy Stop 漲上來就買,追突破;Sell Limit 漲上來才賣,等反彈。現價下方也兩種:Sell Stop 跌下去就賣,追跌破;Buy Limit 跌下來才買,等回檔。另有 Buy Stop Limit、Sell Stop Limit(到價後改掛一張限價單),本文不用。

Stop 這類,MT5 繁中介面叫「止損單」;Limit 這類叫「限價單」。為了不跟保護持倉的停損搞混,本文把 Stop 這類叫停止單。

兩欄示意圖。左欄買單看 Ask:Buy Stop 在現價上方,漲上來就買;Buy Limit 在現價下方,跌下來才買。右欄賣單看 Bid:Sell Limit 在現價上方,漲上來才賣;Sell Stop 在現價下方,跌下去就賣。底下註明:價格一口氣跳過掛單價時,停止單成交得更差,限價單成交得更好。
四種掛單:買單看 Ask、賣單看 Bid

跟市價單一樣,買單看 Ask、賣單看 Bid。MT5 圖表的 K 線是用 Bid 畫的,所以 K 線最高點還沒到掛單價,Buy Stop 就可能已經成交:

15:19:40  order [#193 buy stop 0.57 EURUSD at 1.03119] triggered
15:19:40  買入停止單成交 @1.03119  當時 Bid 1.03118

那根 H1 K 線(1 月 10 日 15:00)最高只到 1.03118,差 1 點沒碰到掛單價;Ask 比 Bid 高一個點差,先到了。換日時點差會放大,差得更多:1 月 14 日 00:02 那張 Buy Stop 掛 1.02469,成交那一刻 Bid 還在掛單價下方 80 點,只是點差撐開就買進了。

價格一口氣跳過掛單價時,停止單用當時的價格成交,會比掛單價差;限價單則成交在掛單價或更好。另寫一隻測試 EA,在現價下方 30 點各掛一張 Buy Limit、Sell Stop;1 月 10 日 15:30:16 的一個 tick 同時越過兩張的價位:Buy Limit 掛 1.03009,成交在 1.03007,好 2 點;Sell Stop 掛 1.03008,成交在 1.03006,差 2 點。每次報價模式的 tick 是從 1 分鐘 K 線模擬的,跳幾點不代表實盤。

掛單存在券商的伺服器上:EA 停了、電腦關了,價格到了照樣成交。

2. 送出掛單:價格、停損停利、到期時間

//=== 2. 送出掛單:價格、停損停利、到期時間 ==========================
bool Done()                           // 伺服器收了這個請求
{
   uint code = g_Trade.ResultRetcode();
   return code == TRADE_RETCODE_DONE || code == TRADE_RETCODE_PLACED;
}

ENUM_ORDER_TYPE_TIME Life(datetime &expiry)
{
   expiry = 0;
   long can = SymbolInfoInteger(_Symbol, SYMBOL_EXPIRATION_MODE);
   if(i_ExpireBars <= 0 || (can & SYMBOL_EXPIRATION_SPECIFIED) == 0)
      return ORDER_TIME_GTC;          // 一直有效,直到成交或刪掉
   expiry = iTime(_Symbol, _Period, 0)          // 這根的開盤時間
            + i_ExpireBars * PeriodSeconds();   // 再往後 N 根
   return ORDER_TIME_SPECIFIED;       // 到 expiry 就失效
}

bool SendStop(ENUM_ORDER_TYPE type, double price, double sl,
              double tp, double lots)
{
   datetime expiry;
   ENUM_ORDER_TYPE_TIME life = Life(expiry);
   bool ok = (type == ORDER_TYPE_BUY_STOP)
      ? g_Trade.BuyStop(lots, price, _Symbol, sl, tp, life, expiry)
      : g_Trade.SellStop(lots, price, _Symbol, sl, tp, life, expiry);
   if(!ok || !Done())
   {
      Print("掛單失敗 ", g_Trade.ResultRetcode(), " ",
            g_Trade.ResultRetcodeDescription());
      return false;
   }
   return true;
}

CTrade 的 BuyStop、SellStop 依序是手數、掛單價、商品、停損、停利、期限種類、到期時間。停損停利跟著掛單走,成交後就是這筆持倉的停損停利。回 true 只代表請求送出去了,Done() 再看回傳碼:10009 是完成,10008 是單子已放上,都算收了。

期限種類常用三種:ORDER_TIME_GTC 一直有效,直到成交或刪掉;ORDER_TIME_DAY 到當天結束;ORDER_TIME_SPECIFIED 到指定的到期時間。

券商收哪幾種,看 SYMBOL_EXPIRATION_MODE。它是幾個旗標加起來的數,都是 SYMBOL_EXPIRATION_ 開頭:GTC 1、DAY 2、SPECIFIED 4、SPECIFIED_DAY 8(到指定那天結束);實測 Tickmill 的 EURUSD 是 15=1+2+4+8,四種都收。can & SYMBOL_EXPIRATION_SPECIFIED 的 & 是位元運算,取出 4 那一位,結果 0 就是不收,跟參數 datetime &expiry 的 &(傳參考)是兩回事。查的時候用 SYMBOL_EXPIRATION_*,送單才用 ORDER_TIME_*,兩組數值不同,不能混用。

範例的 i_ExpireBars 預設 2:到期時間設在兩根 K 線後。EA 正常時每根 K 線都會改單或刪掉重掛,到期時間跟著往後推;EA 當掉或斷線,單子最多再掛兩根 K 線就自己消失(前提是券商收指定到期時間;不收的話 Life() 退回 GTC,就沒有這層保護)。EA 不在的這段時間,沒人會在一張成交後刪另一張,兩張都可能成交,所以別掛太久。

掛錯邊(Buy Stop 掛在 Ask 下方)會被拒,回 10015;到期時間在過去或離現在太近,回 10022。下面兩行是另一隻測試 EA 故意送錯的結果:

failed buy stop 0.1 EURUSD at 1.03595 [Invalid price]
failed buy stop 0.1 EURUSD at 1.06600 [Invalid expiration]

在測試器裡實測,到期時間設在 90 秒後被拒,120 秒後才收。

3. 找自己的掛單

//=== 3. 找自己的掛單 ================================================
ulong FindMyOrder(ENUM_ORDER_TYPE type)
{
   for(int i = OrdersTotal() - 1; i >= 0; i--)
   {
      ulong t = OrderGetTicket(i);    // 順便選取這張,下面才讀得到
      if(t == 0)
         continue;
      if(OrderGetString(ORDER_SYMBOL) != _Symbol ||
         OrderGetInteger(ORDER_MAGIC) != i_Magic)
         continue;
      if(OrderGetInteger(ORDER_TYPE) == type)
         return t;
   }
   return 0;
}

bool DeleteMyOrder(ENUM_ORDER_TYPE type, string why)
{
   ulong t = FindMyOrder(type);
   if(t == 0)
      return true;                    // 本來就沒有
   if(g_Trade.OrderDelete(t) && Done())
   {
      Print("刪掉 #", t, ":", why);
      return true;
   }
   Print("刪單失敗 ", g_Trade.ResultRetcode(), " ",
         g_Trade.ResultRetcodeDescription());
   return false;
}

掛單還沒成交就不是持倉,PositionsTotal 找不到,要用 OrdersTotal 和 OrderGetTicket。OrderGetTicket 回傳訂單編號(ticket),同時選取這張單,之後 OrderGetString、OrderGetInteger、OrderGetDouble 讀的都是它。跟找持倉一樣,用商品和魔術碼認出自己的單,再看類型。

刪單是 OrderDelete(ticket),測試器日誌會印 order canceled。DeleteMyOrder 回傳有沒有刪成功(沒有要刪的也算成功),下一節會用到。

4. 每根新 K 線:改價,或刪掉重掛

//=== 4. 每根新 K 線:改價,或刪掉重掛 ===============================
void PlaceOrMove(ENUM_ORDER_TYPE type, double price, double atr)
{
   bool   isBuy = (type == ORDER_TYPE_BUY_STOP);
   double now   = SymbolInfoDouble(_Symbol,
                                   isBuy ? SYMBOL_ASK : SYMBOL_BID);
   long   stops = SymbolInfoInteger(_Symbol,
                                   SYMBOL_TRADE_STOPS_LEVEL);
   // 停止單要掛在現價外面,而且至少隔 StopsLevel
   if((isBuy ? price - now : now - price) <= stops * _Point)
   {
      DeleteMyOrder(type, "現價已經過了通道線");
      return;
   }
   double dir = isBuy ? 1 : -1;
   double sl  = NormalizeDouble(price - dir * atr * i_SlAtr, _Digits);
   double tp  = NormalizeDouble(price + dir * atr * i_TpAtr, _Digits);
   double lots = CalcLots(MathAbs(price - sl));

   ulong t = FindMyOrder(type);
   if(t > 0 && MathAbs(OrderGetDouble(ORDER_VOLUME_CURRENT) - lots)
               < 1e-8)
   {
      // 手數沒變:改價、停損停利、到期時間
      datetime expiry;
      ENUM_ORDER_TYPE_TIME life = Life(expiry);
      if(!g_Trade.OrderModify(t, price, sl, tp, life, expiry) ||
         !Done())
         Print("改單失敗 ", g_Trade.ResultRetcode(), " ",
               g_Trade.ResultRetcodeDescription());
      return;
   }
   if(!DeleteMyOrder(type, "手數要換"))   // OrderModify 改不了手數
      return;                         // 舊的刪不掉,就別再掛一張
   if(lots > 0)
      SendStop(type, price, sl, tp, lots);
}

void OnTick()
{
   datetime bar = iTime(_Symbol, _Period, 0);
   if(bar == g_LastBar)
      return;
   g_LastBar = bar;
   if(HavePosition())                 // 有倉就不掛,交給停損停利
   {
      DeleteMyOrder(ORDER_TYPE_BUY_STOP, "已經有持倉");   // 平常早就
      DeleteMyOrder(ORDER_TYPE_SELL_STOP, "已經有持倉");  // 刪掉了
      return;
   }

   g_High.Refresh();
   g_Low.Refresh();
   g_Atr.Refresh();
   int    at;                         // MaxValue 順便回傳位置,不用
   double top = g_High.MaxValue(1, i_Channel, at);  // 前 N 根的
   double bot = g_Low.MinValue(1, i_Channel, at);   // 最高、最低
   double atr = g_Atr.Main(1);
   if(top == EMPTY_VALUE || bot == EMPTY_VALUE || atr == EMPTY_VALUE)
   {
      g_LastBar = 0;                  // 資料還沒好,下個 tick 重來
      return;
   }
   PlaceOrMove(ORDER_TYPE_BUY_STOP, top, atr);
   PlaceOrMove(ORDER_TYPE_SELL_STOP, bot, atr);
}

通道線每根 K 線都可能移動;ATR 一變,停損距離就變,照每筆風險 1% 算出來的手數也跟著變。

OrderModify 能改掛單價、停損停利、到期時間,改不了手數:CTrade 的 OrderModify 沒有手數參數。另寫測試 EA,把一張 0.1 手的掛單用 OrderSend 直接送改單請求(TRADE_ACTION_MODIFY)、手數寫 0.20:測試器裡回 10009 成功,價格改了,手數還是 0.1,不報錯。

所以手數沒變就改單,變了就刪掉重掛;舊的刪不掉就先不掛,免得多出一張。這個月改單 124 次、刪掉重掛 318 次。

停止單要掛在現價外面,還要隔商品的 StopsLevel 點(券商規定掛單價離現價至少幾點,Tickmill 是 0)。新 K 線一開盤就已經在通道外,這一邊就不掛,舊單也刪掉,不去追已經跑掉的價。

有持倉就不掛新單,交給停損停利出場。

5. 一張成交,就刪掉另一張

//=== 5. 一張成交,就刪掉另一張 ======================================
void OnTradeTransaction(const MqlTradeTransaction &trans,
                        const MqlTradeRequest &request,
                        const MqlTradeResult &result)
{
   if(trans.type != TRADE_TRANSACTION_DEAL_ADD)
      return;                         // 只看「多了一筆成交」
   ulong deal = trans.deal;
   if(!HistoryDealSelect(deal) ||
      HistoryDealGetInteger(deal, DEAL_MAGIC) != i_Magic ||
      HistoryDealGetString(deal, DEAL_SYMBOL) != _Symbol ||
      HistoryDealGetInteger(deal, DEAL_ENTRY) != DEAL_ENTRY_IN)
      return;                         // 不是這隻 EA 的進場
   bool isBuy = (HistoryDealGetInteger(deal, DEAL_TYPE)
                 == DEAL_TYPE_BUY);
   Print(isBuy ? "買入停止單" : "賣出停止單", "成交 @",
         DoubleToString(HistoryDealGetDouble(deal, DEAL_PRICE),
                        _Digits), "  當時 Bid ", DoubleToString(
         SymbolInfoDouble(_Symbol, SYMBOL_BID), _Digits));
   DeleteMyOrder(isBuy ? ORDER_TYPE_SELL_STOP : ORDER_TYPE_BUY_STOP,
                 "另一邊成交了");
}

MT5 的單子類型裡沒有「一張成交就取消另一張」(OCO)這種,要 EA 自己刪,而且要馬上刪:等到下一根 K 線才刪,價格掉頭碰到通道另一邊,另一張就可能也成交。

OnTradeTransaction 是交易有變化時 MT5 叫的函數。收到 TRADE_TRANSACTION_DEAL_ADD(多了一筆成交),用 HistoryDealSelect 選出這筆成交,確認是這隻 EA 的進場,就刪掉另一邊:

00:05:04  order [#3 sell stop 0.32 EURUSD at 1.03440] triggered
00:05:04  賣出停止單成交 @1.03433  當時 Bid 1.03433
00:05:04  order canceled [#2 buy stop 0.32 EURUSD at 1.04245]
00:05:04  刪掉 #2:另一邊成交了

6. 在測試器跑一個月

測試設定:EURUSD H1,2025.01.02 到 2025.01.31,每次報價,入金 10000,杠杆 1:100。整個月 34 張停止單成交:21 張正好在掛單價,13 張比掛單價差、最多差 10 點,沒有一張更好。

過期的單,測試器要等下一根 K 線開盤才刪。另一隻測試 EA 送一張 00:32 到期的單:測試週期選 H1,01:00 才刪;選 M15,00:45 就刪。「幾分鐘後失效」這種邏輯,測試器驗不準。

週末也一樣:週五 23:00 改單,到期時間是週六 01:00,週一第一個 tick 才刪:

01.10 23:00:00  order modified [#206 buy stop 0.22 EURUSD at 1.03119]
01.10 23:00:00  order modified [#207 sell stop 0.22 EURUSD at 1.02109]
01.13 00:05:00  order expired [#206 buy stop 0.22 EURUSD at 1.03119]
01.13 00:05:00  order expired [#207 sell stop 0.22 EURUSD at 1.02109]

EA 在同一個 tick 重新掛上新的兩張。

餘額 10000 跑到 9088.53:掛單只決定怎麼進場,這個通道突破在這個月是賠的。

本篇檔案:ChannelStop.mq5 放 MQL5\Experts\,在 MetaEditor 打開按 F7。

發佈留言

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

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