返回《給 CFD 交易者的 MetaTrader 5 與 MQL5》
第三部分

CFD 的部位規模(多數 EA 跳過的那堂課)

手數不是風險。把 tick value、合約規模與停損距離換成真正的部位大小。

進階閱讀時間約 13 分鐘初學 → 進階第 13 / 17 堂
在 MetaEditor 實際跑一次
正確做出風險導向的 sizing

存成 MQL5/Scripts/Lesson13Sizing.mq5。它把 tick size 與 tick value 換成 1% 風險對應的手數、拿券商的限制驗證它,並檢查保證金。

//+------------------------------------------------------------------+
//| Lesson 13 - Risk-based position sizing for CFD                   |
//+------------------------------------------------------------------+
#property copyright "Strategist Academy"
#property version   "1.00"

//+------------------------------------------------------------------+
//| Round volume down onto the broker's step                         |
//+------------------------------------------------------------------+
double NormalizeVolumeDown(const string sym, double vol)
{
   double vMin  = SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN);
   double vMax  = SymbolInfoDouble(sym, SYMBOL_VOLUME_MAX);
   double vStep = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);

   vol = MathMax(vMin, MathMin(vMax, vol));
   if(vStep > 0)
      vol = vMin + MathFloor((vol - vMin) / vStep + 0.0000001) * vStep;

   return NormalizeDouble(vol, 2);
}

//+------------------------------------------------------------------+
//| Lots such that a stop of stopPoints costs at most riskMoney      |
//+------------------------------------------------------------------+
double CalcLotsForRisk(const string sym, const double riskMoney, const double stopPoints)
{
   double tickValue = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE);
   double tickSize  = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE);

   if(tickValue <= 0 || tickSize <= 0 || stopPoints <= 0)
      return 0.0;

   // stopPoints * _Point is a PRICE distance. tickValue is per TICK, so
   // convert price distance into ticks before multiplying.
   double stopTicks  = (stopPoints * _Point) / tickSize;
   double riskPerLot = stopTicks * tickValue;

   if(riskPerLot <= 0)
      return 0.0;

   return NormalizeVolumeDown(sym, riskMoney / riskPerLot);
}

void OnStart()
{
   string sym = _Symbol;

   double equity  = AccountInfoDouble(ACCOUNT_EQUITY);
   double riskPct = 1.0;                       // risk 1% of equity
   double riskMoney = equity * riskPct / 100.0;

   double tickValue = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE);
   double tickSize  = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE);
   double contract  = SymbolInfoDouble(sym, SYMBOL_TRADE_CONTRACT_SIZE);
   long   stopsLevel= SymbolInfoInteger(sym, SYMBOL_TRADE_STOPS_LEVEL);

   Print("=== Sizing on ", sym, " ===");
   Print("Equity:        ", DoubleToString(equity, 2));
   Print("Risk budget:   ", DoubleToString(riskMoney, 2), " (", riskPct, "%)");
   Print("Tick size:     ", DoubleToString(tickSize, _Digits));
   Print("Tick value:    ", DoubleToString(tickValue, 2), " per lot per tick");
   Print("Contract size: ", DoubleToString(contract, 2));
   Print("Point:         ", DoubleToString(_Point, _Digits),
         "   (point vs tick differ? ", (MathAbs(_Point - tickSize) > 0.0000000001 ? "YES" : "no"), ")");
   Print("Stops level:   ", stopsLevel, " points");
   Print("Swap long/short: ", DoubleToString(SymbolInfoDouble(sym, SYMBOL_SWAP_LONG), 2), " / ",
                              DoubleToString(SymbolInfoDouble(sym, SYMBOL_SWAP_SHORT), 2));

   // Try a few stop distances and see what 1% risk actually buys.
   double testStops[4] = {50, 100, 200, 400};
   for(int i = 0; i < 4; i++)
   {
      double sp   = testStops[i];
      double lots = CalcLotsForRisk(sym, riskMoney, sp);

      string note = "";
      if(sp < (double)stopsLevel)
         note = "  <-- inside broker stops level, would be REJECTED";
      else if(lots <= 0)
         note = "  <-- too small for this symbol";

      Print("Stop ", DoubleToString(sp, 0), " pts -> lots ", DoubleToString(lots, 2), note);
   }

   // Margin check on a candidate size
   double lots = CalcLotsForRisk(sym, riskMoney, 200);
   if(lots > 0)
   {
      double price  = SymbolInfoDouble(sym, SYMBOL_ASK);
      double margin = 0.0;
      if(OrderCalcMargin(ORDER_TYPE_BUY, sym, lots, price, margin))
      {
         double free = AccountInfoDouble(ACCOUNT_MARGIN_FREE);
         Print("Required margin for ", DoubleToString(lots, 2), " lots: ",
               DoubleToString(margin, 2), "   free: ", DoubleToString(free, 2),
               "   -> ", (margin <= free ? "affordable" : "NOT affordable"));
      }
      else
         Print("OrderCalcMargin failed: ", GetLastError());
   }
}
如何執行
  1. 1. 從 MT5 開啟 MetaEditor(按 F4), 選擇這支程式該放的資料夾——Experts、Indicators 或 Scripts。
  2. 2. 開新檔案、把這段程式貼上去,然後按 F7 編譯。Errors 分頁顯示的錯誤 都修掉。
  3. 3. 回到 MT5,把它拖到圖表上——如果是 EA,就打開 Strategy Tester。

為什麼固定手數在不同商品上會失敗

0.10 手在每一種商品上代表的東西都不一樣。一個 CFD 指數的 0.10 手部位,與黃金或外匯貨幣對的 0.10 手有完全不同的虧損輪廓,因為合約規模與 tick value 不同。一個硬編成 0.10 手的 EA 在某些商品上過度承擔風險、在其他商品上風險不足,而交易者通常不知道是哪一種。

三個數字

  • SYMBOL_TRADE_TICK_SIZE——券商報價的最小價格增量。
  • SYMBOL_TRADE_TICK_VALUE——每一個 tick、每一手,以入金幣別計的盈虧金額。
  • SYMBOL_TRADE_CONTRACT_SIZE——每一手對應的標的物單位數。

注意 _Point 與 tick size 不總是相同。有些券商多報一位數,所以一個 point 可能是好幾個 tick。在乘上 tick value 之前,先把停損距離從 point 換算成 tick——這正是最多 sizing bug 跳過的那一步,而它會產生差了十倍的部位。

公式

每一手的風險等於「以 tick 表達的停損距離」乘以 tick value:

riskPerLot = (stopDistance / tickSize) * tickValue
lots       = riskMoney / riskPerLot

然後夾到商品的成交量下限、上限與級距——並且向下取整,永遠不要向上。

送出之前先檢查保證金

OrderCalcMargin 會告訴你這個部位需要多少保證金。拿它跟可用保證金比,不夠就退場。在槓桿之下,一個對風險來說規模正確的部位,仍然可能是你負擔不起的。

尊重 stops level 與 freeze level

SYMBOL_TRADE_STOPS_LEVEL 是 SL/TP 的最小距離;SYMBOL_TRADE_FREEZE_LEVEL 是你可以多靠近市價去修改一張單。如果你的風險導出停損落在 stops level 之內,你有兩個選擇:放大停損並縮小規模,或者跳過這筆交易。正確答案幾乎永遠是縮小規模——為了配合一個固定的手數而放大停損,會默默地把你的風險變成兩倍。

隔夜利息是成本,不是註腳

在抱過夜的 CFD 部位上,swap 會複利。一個忽略它的回測會高估多日持有的報酬。檢查 SYMBOL_SWAP_LONG 與 SYMBOL_SWAP_SHORT,並在測試器裡放入真實的資金成本。

你剛完成了什麼

《給 CFD 交易者的 MetaTrader 5 與 MQL5》的第 13 / 17 堂課。跑完範例或讀完這段之後, 把它勾起來,然後進下一堂課。