MQL5 手數與風險控管:每筆固定風險、商品規格、保證金檢查

固定 0.10 手有個問題:停損距離每筆不一樣,賠的錢也就每筆不一樣。這篇把手數改成由停損距離倒推,每筆最多賠餘額的 1%,順便講算手數要用到的商品規格,還有下單前怎麼確認保證金夠不夠。

範例 MaCrossRisk.mq5 是把固定 0.10 手的均線交叉 EA(MaCrossTrade.mq5:EMA 10/30 交叉進場、ATR 倍數停損停利、反向訊號先平再開)只換掉手數這一塊,魔術碼另外換了一個,其他都沒動。整份在我的 MT5 上編譯過、策略測試器跑過 2025 年 1 月的 EURUSD H1 和另外幾種情境,文中日誌和數字是實際輸出。

先把範例跑起來

  1. 把 MaCrossRisk.mq5 放到 MQL5\Experts\,在 MetaEditor 打開、按 F7,0 errors。
  2. 策略測試器 Ctrl+R:EA 選 MaCrossRisk,EURUSD,H1,日期 2025.01.02 到 2025.01.31,模式「每次報價」,入金 10000,杠杆 1:100,按「開始」。
  3. 看「日誌」頁。開頭兩行「規格」是這個商品算手數要用的數字,之後每筆開倉都印手數、停損幾點、這筆最多賠多少。

固定手數,其實不知道自己在賠多少

同一個月、同一套訊號,固定 0.10 手的 MaCrossTrade 停損距離從 161 點到 434 點,一筆碰停損賠 16.1 到 43.4 塊,是 10000 塊帳戶的 0.16% 到 0.43%。差快三倍,而且波動大的時候停損拉遠、賠得更多,剛好是最不該加碼的時候。

反過來想:先決定一筆最多賠多少,比如餘額的 1%,再用停損距離算手數。停損遠就開小、停損近就開大,每筆賠的錢一樣。

算手數要用的商品規格

一手賠多少錢,看三個規格:SYMBOL_TRADE_TICK_SIZE 是價格最小跳動一格(tick size,下面簡稱 tick),SYMBOL_TRADE_TICK_VALUE_LOSS 是一手跳一格賠多少(通常是帳戶幣別,已經換算過),SYMBOL_TRADE_CONTRACT_SIZE 是一手的合約量。

手數本身有最小、最大、步進三個限制,SYMBOL_VOLUME_MINSYMBOL_VOLUME_MAXSYMBOL_VOLUME_STEP,不照規格送單會被拒,錯誤碼 10014。範例 EA 啟動時把這些印出來,三個商品在我的 Tickmill 帳號上:

商品合約tick每 tick (USD)最小/步進 (手)
EURUSD1000000.000011.00000.01/0.01
XAUUSD1000.011.00000.01/0.01
USDJPY1000000.0010.63570.01/0.01

EURUSD 一手 100000 歐元、跳 0.00001 賠 1 塊;黃金一手 100 盎司、跳 0.01 也是 1 塊;USDJPY 一手跳 0.001 是 100 日圓,換成美金 0.6357 塊,這個數跟著匯率走,所以每次算手數都要重新讀。

tick 不一定等於 _Point:有些商品一跳是好幾個 point,例如指數期貨報價到 0.01、一跳卻是 0.25,算錢要用 tick,不能用 _Point。這裡三個商品剛好相等,別的商品、別家券商不一定。

這些數字在 MT5 主視窗也看得到:「市場報價」(Ctrl+M)對商品按右鍵、「規格」。

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;      // 停利 = ATR x 倍數
input long           i_Magic      = 20250601; // 魔術碼

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

原本固定手數的 i_Lots 換成 i_RiskPct(每筆賠餘額的百分之幾)和 i_MaxLots(算出來再大也不超過這個)。上限是保險:餘額大、停損又很近的時候,算出來的手數可能大到不合理。

2. OnInit:把規格印出來

//=== 2. OnInit:建指標、印出商品規格 ================================
int OnInit()
{
   g_FastHandle = iMA(_Symbol, _Period, i_FastPeriod, 0,
                      i_MaMethod, PRICE_CLOSE);
   g_SlowHandle = iMA(_Symbol, _Period, i_SlowPeriod, 0,
                      i_MaMethod, PRICE_CLOSE);
   g_AtrHandle  = iATR(_Symbol, _Period, i_AtrPeriod);
   if(g_FastHandle == INVALID_HANDLE ||
      g_SlowHandle == INVALID_HANDLE ||
      g_AtrHandle  == INVALID_HANDLE)
   {
      Print("指標建立失敗,錯誤碼 ", GetLastError());
      return INIT_FAILED;
   }
   g_Trade.SetExpertMagicNumber(i_Magic);
   g_Trade.SetDeviationInPoints(20);
   g_Trade.SetTypeFillingBySymbol(_Symbol);

   // 算手數要用到的規格,先印出來看
   double minLot  = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
   double maxLot  = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);
   double stepLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
   double tickSz  = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
   double tickVal = SymbolInfoDouble(_Symbol,
                                     SYMBOL_TRADE_TICK_VALUE_LOSS);
   Print("規格 ", _Symbol, "  最小 ", DoubleToString(minLot, 2),
         " 手  最大 ", DoubleToString(maxLot, 2),
         " 手  步進 ", DoubleToString(stepLot, 2), " 手");
   Print("  合約 ", DoubleToString(SymbolInfoDouble(_Symbol,
         SYMBOL_TRADE_CONTRACT_SIZE), 0),
         "  tick ", DoubleToString(tickSz, _Digits),
         "  每 tick ", DoubleToString(tickVal, 4), " ",
         AccountInfoString(ACCOUNT_CURRENCY));
   Print("EA 啟動:每筆風險 ", DoubleToString(i_RiskPct, 1),
         "% 餘額,上限 ", DoubleToString(i_MaxLots, 2), " 手");
   return INIT_SUCCEEDED;
}

指標 handle 和 CTrade 的三行設定跟 MaCrossTrade 一樣。多的是把上面那幾個規格讀出來印一遍,用 DoubleToString() 指定小數位,tick 用商品的 _Digits 位才看得到 0.00001。實盤前看一眼這兩行,規格跟想的不一樣馬上知道。

3. 依風險比例算手數

//=== 3. 依風險比例算手數 ============================================
// slDist 是停損距離(價格差)。回傳手數,算不出可用的手數就回 0;
// risk 帶回這筆手數真正會賠的錢(捨去到步進之後)
double CalcLots(double slDist, double &risk)
{
   double balance   = AccountInfoDouble(ACCOUNT_BALANCE);
   double riskMoney = balance * i_RiskPct / 100.0;   // 這筆最多賠多少
   double tickSize  = SymbolInfoDouble(_Symbol,
                                       SYMBOL_TRADE_TICK_SIZE);
   double tickValue = SymbolInfoDouble(_Symbol,
                                       SYMBOL_TRADE_TICK_VALUE_LOSS);
   double minLot    = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
   double maxLot    = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);
   double stepLot   = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
   risk = 0;
   if(tickSize <= 0 || tickValue <= 0 || stepLot <= 0 || slDist <= 0)
   {
      Print("規格讀不到或停損距離為 0,這筆不做");
      return 0;
   }

   double lossPerLot = slDist / tickSize * tickValue; // 1 手賠多少
   double lots = riskMoney / lossPerLot;
   long steps = (long)MathFloor(lots / stepLot + 1e-9); // 捨去到步進
   lots = NormalizeDouble(steps * stepLot, 8);          // 修浮點尾數
   if(lots < minLot)
   {
      Print("風險 ", DoubleToString(riskMoney, 2), " 連最小手數 ",
            DoubleToString(minLot, 2), " 手都不夠,這筆不做");
      return 0;
   }
   lots = MathMin(lots, MathMin(maxLot, i_MaxLots));
   risk = lots * lossPerLot;
   return lots;
}

四步:餘額乘以風險比例是這筆最多賠的錢;停損距離除以 tick 是停損有幾格,乘每格的錢是一手碰停損賠多少;兩者相除是手數;最後 MathFloor() 無條件捨去到步進,捨去不能四捨五入,進位就超過設定的風險了。

捨去之後如果連最小手數都不到,這筆就不做,回傳 0。硬開 0.01 手等於賠超過設定的比例,小帳戶會常遇到,下面有實跑的例子。算出來的手數再跟商品最大手數和 i_MaxLots 取小;risk 用參考帶回這筆真正會賠的錢,印出來對帳用,它不含手續費、隔夜利息和滑點。

除法加的 1e-9 和 NormalizeDouble() 都是在擋浮點數:0.29 除以 0.01 會算成 28.999…、29 乘 0.01 會變 0.29000000000000004,不修掉就可能少一步或被券商拒。

4. 開倉前先問保證金

//=== 4. 開倉前先問保證金夠不夠 ======================================
bool EnoughMargin(ENUM_ORDER_TYPE type, double lots, double price)
{
   double need = 0;
   if(!OrderCalcMargin(type, _Symbol, lots, price, need))
   {
      Print("OrderCalcMargin 失敗,錯誤碼 ", GetLastError());
      return false;
   }
   double freeMargin = AccountInfoDouble(ACCOUNT_MARGIN_FREE);
   if(need > freeMargin)
   {
      Print("保證金不夠:", DoubleToString(lots, 2), " 手要 ",
            DoubleToString(need, 2), ",可用 ",
            DoubleToString(freeMargin, 2), ",這筆不做");
      return false;
   }
   return true;
}

OrderCalcMargin() 問這張單要押多少保證金,ACCOUNT_MARGIN_FREE 是帳戶現在還能押多少。不夠就不送,省得被伺服器退 10019(not enough money)。入金 10000、1:100 的回測碰不到這條;實盤同時跑好幾隻 EA、或券商調低槓桿時才會,文末有把杠杆改 1:1 逼它出現的例子。

5. 開倉:先算手數、再查保證金、才下單

//=== 5. 開倉:先算手數、再查保證金、才下單 ==========================
bool OpenPosition(int dir, double atr)
{
   double ask     = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
   double bid     = SymbolInfoDouble(_Symbol, SYMBOL_BID);
   double slDist  = atr * i_SlAtr;
   double tpDist  = atr * i_TpAtr;
   long stops = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL);
   double minDist = stops * _Point + (ask - bid);
   if(slDist < minDist) slDist = minDist;
   if(tpDist < minDist) tpDist = minDist;

   // 先把停損價修到商品的小數位,再用修過的價差算手數
   bool   isBuy = (dir == POSITION_TYPE_BUY);
   double entry = isBuy ? ask : bid;
   double sl = NormalizeDouble(isBuy ? ask - slDist : bid + slDist,
                               _Digits);
   double tp = NormalizeDouble(isBuy ? ask + tpDist : bid - tpDist,
                               _Digits);
   slDist = MathAbs(entry - sl);

   double risk = 0;
   double lots = CalcLots(slDist, risk);
   if(lots <= 0)
      return false;
   if(!EnoughMargin(isBuy ? ORDER_TYPE_BUY : ORDER_TYPE_SELL,
                    lots, entry))
      return false;

   bool ok = isBuy ? g_Trade.Buy(lots, _Symbol, 0, sl, tp)
                   : g_Trade.Sell(lots, _Symbol, 0, sl, tp);
   if(!ok || g_Trade.ResultRetcode() != TRADE_RETCODE_DONE)
   {
      Print("下單失敗 ", g_Trade.ResultRetcode(), " ",
            g_Trade.ResultRetcodeDescription());
      return false;
   }
   Print(isBuy ? "開多" : "開空", " #", g_Trade.ResultOrder(),
         "  ", DoubleToString(lots, 2), " 手 @",
         DoubleToString(g_Trade.ResultPrice(), _Digits),
         "  停損 ", DoubleToString(slDist / _Point, 0), " 點",
         "  風險 ", DoubleToString(risk, 2));
   return true;
}

這裡的順序是:先把停損停利價用 NormalizeDouble() 修到商品的小數位,再拿修過的價差去算手數。這樣印出來的「風險」就是停損真的被打到時會賠的數,不會差一個尾數。tick 比 _Point 粗的商品,價位還要再對齊 tick。OnTick、找持倉、平倉那幾段沒改,檔案裡有,這篇不貼。

執行結果

EURUSD 2025 年 1 月,14 筆,和固定 0.10 手的 MaCrossTrade 訊號、進場價、停損距離逐筆相同,只有手數不一樣。EA 印的行:

規格 EURUSD  最小 0.01 手  最大 100.00 手  步進 0.01 手
  合約 100000  tick 0.00001  每 tick 1.0000 USD
EA 啟動:每筆風險 1.0% 餘額,上限 1.00 手
開多 #2  0.34 手 @1.03042  停損 288 點  風險 97.92
開空 #4  0.26 手 @1.03545  停損 389 點  風險 101.14
開多 #6  0.27 手 @1.02483  停損 380 點  風險 102.60
開空 #8  0.36 手 @1.02889  停損 284 點  風險 102.24
平倉 #8  浮動盈虧 -28.08
開多 #10  0.31 手 @1.02967  停損 331 點  風險 102.61
平倉 #10  浮動盈虧 -37.20
開空 #12  0.64 手 @1.02847  停損 161 點  風險 103.04
平倉 #12  浮動盈虧 -79.36
開多 #14  0.61 手 @1.02971  停損 168 點  風險 102.48
開空 #16  0.33 手 @1.02864  停損 304 點  風險 100.32
平倉 #16  浮動盈虧 -59.07
開多 #18  0.38 手 @1.03043  停損 260 點  風險 98.80
開空 #20  0.48 手 @1.04039  停損 212 點  風險 101.76
開多 #22  0.30 手 @1.04355  停損 340 點  風險 102.00
開空 #24  0.30 手 @1.04372  停損 347 點  風險 104.10
開多 #26  0.30 手 @1.04316  停損 352 點  風險 105.60
開空 #28  0.24 手 @1.04074  停損 434 點  風險 104.16

手數從 0.24 到 0.64 手,每筆風險都在 97.92 到 105.60 塊之間。不是剛好 100 有兩個原因:捨去到步進通常會少一點;餘額會變,賺了 1% 跟著變大、賠了跟著縮,最後一筆算出來是 104.16。

碰停損的 2 筆可以對帳:#14 開 0.61 手印風險 102.48,報告成交表那筆盈利欄是 -102.48;#26 開 0.30 手印 105.60,盈利欄 -105.60。一分不差,手續費另外算,那兩筆各是 3.66 和 1.80。

項目固定 0.10 手餘額 1%
交易總計1414
總淨盈利 (USD)237.28656.51
最大權益數虧損103.57 (1.02%)441.66 (4.19%)

賺比較多不是策略變好了,是平均開了 0.37 手、三倍多的部位,回撤也跟著放大到四倍。風險比例不會讓一個策略從賠變賺,它做的是讓每一筆賠的錢可以預期,回撤跟著風險比例走而不是跟著行情。

1% 是常見的起點,連賠 10 筆掉一成;要 2% 還是 0.5%,看自己受得了多深的回撤。

換商品、換資金、換槓桿

黃金同一個月:手數只有 0.07 到 0.11 手,因為停損動輒 930 到 1353 點、一點 1 塊。手數每差一個步進 0.01 手,賠的錢就差 9 到 14 塊,捨去之後風險落在 92.24 到 104.24,比 EURUSD 離目標遠。一步的錢大的商品就是這樣,帳戶小的時候更明顯。

入金 300 塊跑 EURUSD:1% 是 3 塊,0.01 手停損 161 點要賠 1.61,開得成;停損 304 點以上的訊號 0.01 手就超過 3 塊,14 個訊號被擋掉 8 個,日誌印「連最小手數 0.01 手都不夠,這筆不做」。要改成硬開最小手數也行,那就要接受這種帳戶單筆賠到 1.4% 的事實。

杠杆改 1:1、入金 10000:第一筆 0.34 手要押 35034.28,可用只有 10000,整個月 14 個訊號全部印「保證金不夠」,一筆都沒開。沒有這個檢查的話一樣一筆都開不了,只是換成伺服器拒單:固定 0.10 手的 MaCrossTrade 在 1:1 跑同一個月,日誌是 14 行「下單失敗 10019 not enough money」。結果相同,差在自己先算過就知道差多少。

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

發佈留言

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

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