訊號會印了,這篇讓它真的下單。用 MT5 標準程式庫的 CTrade 類別開倉、帶停損停利、平倉,還有 EA 怎麼從一堆持倉裡認出自己的那一筆。範例沿用均線交叉:黃金交叉做多、死亡交叉做空,反向訊號來就先平掉再開。
整份程式在我的 MT5 上編譯過、用策略測試器跑過 2025 年 1 月的 EURUSD H1,文中日誌和數字是實際輸出。可以先下載 MaCrossTrade.mq5 跟著跑。它會真的下單,請掛在模擬帳號上。
先把範例跑起來
- 把 MaCrossTrade.mq5 放到
MQL5\Experts\,在 MetaEditor 打開、按F7,0 errors。 - 策略測試器
Ctrl+R:EA 選 MaCrossTrade,EURUSD,H1,日期 2025.01.02 到 2025.01.31,模式「每個報價」,初始入金 10000,按「開始」。這隻 EA 有停損停利,一定要用「每個報價」:1 分鐘 OHLC 和只用開盤價那兩種模式,一分鐘只有開高低收四個價或更少,停損停利只在這幾個價位上檢查,碰到的時間和價格會不一樣。 - 跑完看報告的「總淨盈利」「交易總計」,「日誌」頁有每一筆開倉、平倉的紀錄。
- 要掛在模擬帳號的圖表上跑,拖上去時跳出的視窗裡「允許演算法交易」要勾,工具列的「演算法交易」(
Ctrl+E)也要開著,EA 才能下單。
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_Lots = 0.10; // 手數
input double i_SlAtr = 2.0; // 停損 = ATR x 倍數
input double i_TpAtr = 3.0; // 停利 = ATR x 倍數
input long i_Magic = 20250401; // 魔術碼
CTrade g_Trade; // 下單、平倉都透過它
int g_FastHandle = INVALID_HANDLE;
int g_SlowHandle = INVALID_HANDLE;
int g_AtrHandle = INVALID_HANDLE;
datetime g_LastBarTime = 0;#include <Trade\Trade.mqh> 把 MT5 附的 CTrade 類別拉進來。下單在 MQL5 底層是填一個 MqlTradeRequest 結構、呼叫 OrderSend()、再看回傳的結構,欄位十幾個;CTrade 把這些包起來,一行 Buy() 就搞定。宣告成全域變數 g_Trade,整隻 EA 共用一個,之後都用 g_Trade.Buy() 這種「物件.函數()」的寫法叫它做事。
停損停利(MT5 介面叫「止損」「止盈」,程式裡是 SL、TP)不用固定點數,用 ATR 的倍數:波動大,距離自動拉開。手數固定 0.10 手,先不管資金管理。
i_Magic 是魔術碼(magic number):EA 送出的每張單都帶這個號碼,之後從一堆持倉裡認出自己的就靠它。同一個帳號跑兩隻 EA、或同一隻 EA 掛在兩個週期,魔術碼要不一樣,不然會把對方的單當成自己的平掉。手動下的單魔術碼是 0。
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); // 成交方式照商品規格
Print("EA 啟動:", DoubleToString(i_Lots, 2), " 手,停損 ",
i_SlAtr, " 倍 ATR,停利 ", i_TpAtr, " 倍 ATR,魔術碼 ",
i_Magic);
return INIT_SUCCEEDED;
}指標的三個 handle 照舊在這裡建。OnDeinit 釋放指標、IsNewBar 判斷新 K 線兩段檔案裡有,這篇不貼。多的是 g_Trade 的三行設定。
SetExpertMagicNumber() 之後每張單自動帶魔術碼。SetDeviationInPoints() 是能接受的滑點(點),只有「即時執行」(Instant Execution)和更老的「執行請求」模式的商品用得到:報價差超過這個值,券商會回「重新報價」(requote)、不成交。Tickmill 的 EURUSD 是「市場執行」(Market Execution),券商直接照當下市價成交,這個值不起作用。SetTypeFillingBySymbol() 依商品規格挑成交方式(FOK 或 IOC,Tickmill 是 IOC)。
3. 找自己的持倉
//=== 3. 找自己的持倉 ================================================
// 回傳 POSITION_TYPE_BUY 或 POSITION_TYPE_SELL,沒有就回 -1
// 找到的持倉會處於「已選取」狀態,ticket 由參數帶回
int FindMyPosition(ulong &ticket)
{
for(int i = PositionsTotal() - 1; i >= 0; i--)
{
ulong t = PositionGetTicket(i); // 選取第 i 個持倉
if(t == 0)
continue;
if(PositionGetString(POSITION_SYMBOL) != _Symbol)
continue; // 別的商品
if(PositionGetInteger(POSITION_MAGIC) != i_Magic)
continue; // 別的 EA 或手動單
ticket = t;
return (int)PositionGetInteger(POSITION_TYPE);
}
return -1;
}
void ShowStatus()
{
ulong ticket = 0;
int have = FindMyPosition(ticket);
if(have < 0)
{
Comment("沒有持倉");
return;
}
double open = PositionGetDouble(POSITION_PRICE_OPEN);
double sl = PositionGetDouble(POSITION_SL);
double tp = PositionGetDouble(POSITION_TP);
double profit = PositionGetDouble(POSITION_PROFIT);
Comment(have == POSITION_TYPE_BUY ? "多單" : "空單", " #", ticket,
" 開倉價 ", DoubleToString(open, _Digits),
"\nSL ", DoubleToString(sl, _Digits),
" TP ", DoubleToString(tp, _Digits),
"\n浮動盈虧 ", DoubleToString(profit, 2));
}從 MT4 過來最容易搞混的地方在這裡。MT4 一張單就是一筆部位,OrderSelect() 掃過去就好。MT5 分三層:你送出的是訂單(order),成交後留下成交紀錄(deal),手上真正的部位叫持倉(position)。要知道現在手上有什麼,看持倉。
PositionsTotal() 是帳號目前的持倉數,所有商品、所有 EA、手動單全算在內。PositionGetTicket(i) 選取第 i 筆並回傳它的編號(ticket),選取之後 PositionGetString()、PositionGetInteger()、PositionGetDouble() 讀的都是這一筆。商品跟魔術碼都對得上,才是自己的。這隻 EA 一次只留一筆,找到就回傳。
ShowStatus() 把那筆的開倉價、SL、TP、浮動盈虧顯示在圖表左上角,每個 tick 更新。策略測試器不是視覺化模式看不到,掛在圖表上才有。
4. 開倉:市價單帶停損停利
//=== 4. 開倉:市價單帶停損停利 ======================================
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 ok;
if(dir == POSITION_TYPE_BUY)
ok = g_Trade.Buy(i_Lots, _Symbol, 0, // 0 = 用現在的 Ask
NormalizeDouble(ask - slDist, _Digits),
NormalizeDouble(ask + tpDist, _Digits));
else
ok = g_Trade.Sell(i_Lots, _Symbol, 0, // 0 = 用現在的 Bid
NormalizeDouble(bid + slDist, _Digits),
NormalizeDouble(bid - tpDist, _Digits));
if(!ok || g_Trade.ResultRetcode() != TRADE_RETCODE_DONE)
{
Print("下單失敗 ", g_Trade.ResultRetcode(), " ",
g_Trade.ResultRetcodeDescription());
return false;
}
Print(dir == POSITION_TYPE_BUY ? "開多" : "開空",
" #", g_Trade.ResultOrder(),
" ", DoubleToString(i_Lots, 2), " 手 @",
DoubleToString(g_Trade.ResultPrice(), _Digits),
" SL ", DoubleToString(g_Trade.RequestSL(), _Digits),
" TP ", DoubleToString(g_Trade.RequestTP(), _Digits));
return true;
}買在 Ask、賣在 Bid。多單的停損放在 Ask 下面 2 倍 ATR、停利放上面 3 倍 ATR;空單用 Bid 算,方向反過來。算出來的價格用 NormalizeDouble() 修到商品的小數位數,多幾位小數券商會拒。
SYMBOL_TRADE_STOPS_LEVEL 是券商規定停損停利離現價的最小距離(點),比它近會被拒,錯誤碼 10016。多單的停損券商是拿 Bid 檢查的,我們從 Ask 算,所以最小距離再加上點差。EURUSD 在我的 Tickmill 帳號是 0,別的商品或券商不一定,所以還是擋一下。
g_Trade.Buy(手數, 商品, 價格, 停損, 停利),價格填 0 就用當下的 Ask。回傳 true 只代表請求送到伺服器,有沒有成交要看 ResultRetcode():10009(TRADE_RETCODE_DONE)才是真的成交。失敗時 ResultRetcodeDescription() 會給一句英文說明,印出來就知道被拒的原因。
成交後 ResultOrder() 是訂單編號、ResultPrice() 是實際成交價,RequestSL()、RequestTP() 是剛才送出去的值。在策略測試器裡 CTrade 自己還會多印一行 CTrade::OrderSend: market buy ...;掛在圖表上預設只有失敗才印。
5. OnTick:訊號來了,先平反向、再開新倉
//=== 5. OnTick:訊號來了,先平反向、再開新倉 ========================
void OnTick()
{
ShowStatus(); // 每個 tick 更新左上角
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;
}
bool goldenCross = fast[2] <= slow[2] && fast[1] > slow[1];
bool deadCross = fast[2] >= slow[2] && fast[1] < slow[1];
if(!goldenCross && !deadCross)
return;
int dir = goldenCross ? POSITION_TYPE_BUY : POSITION_TYPE_SELL;
ulong ticket = 0;
int have = FindMyPosition(ticket);
if(have == dir)
return; // 同方向已經有了,不加碼
if(have >= 0) // 反向的先平掉
{
double profit = PositionGetDouble(POSITION_PROFIT);
if(!g_Trade.PositionClose(ticket) ||
g_Trade.ResultRetcode() != TRADE_RETCODE_DONE)
{
Print("平倉失敗 ", g_Trade.ResultRetcode(), " ",
g_Trade.ResultRetcodeDescription());
return;
}
Print("平倉 #", ticket, " 浮動盈虧 ",
DoubleToString(profit, 2));
}
OpenPosition(dir, atr[1]);
}訊號只看已收盤的 [1] 和 [2]。有訊號才去查持倉:同方向已經有一筆就不加碼;有反向的就先 PositionClose(ticket) 平掉再開新的。CTrade 會自己送一張反向市價單把它平掉,不用算方向,成交與否一樣看 ResultRetcode()。
平倉前先讀 POSITION_PROFIT,那是平倉前一刻的浮動盈虧,單位是帳戶幣別,這裡是 USD。它就是工具箱「歷史」頁盈利欄那個數:測試器裡完全一樣,實盤平倉價可能差一點滑點;手續費、隔夜利息是另外兩欄。
停損停利是券商伺服器在盯,價格碰到就平,EA 關掉、電腦關機也照樣觸發。所以 EA 只印開倉和反向平倉,停損停利觸發的紀錄是測試器自己印的。
執行結果
2025 年 1 月,14 筆交易。只列 EA 自己 Print() 的行;CTrade 的內建紀錄、測試器的時間欄和它印的成交紀錄都省略:
EA 啟動:0.10 手,停損 2.0 倍 ATR,停利 3.0 倍 ATR,魔術碼 20250401
開多 #2 0.10 手 @1.03042 SL 1.02754 TP 1.03473
開空 #4 0.10 手 @1.03545 SL 1.03934 TP 1.02961
開多 #6 0.10 手 @1.02483 SL 1.02103 TP 1.03053
開空 #8 0.10 手 @1.02889 SL 1.03173 TP 1.02463
平倉 #8 浮動盈虧 -7.80
開多 #10 0.10 手 @1.02967 SL 1.02636 TP 1.03464
平倉 #10 浮動盈虧 -12.00
開空 #12 0.10 手 @1.02847 SL 1.03008 TP 1.02606
平倉 #12 浮動盈虧 -12.40
開多 #14 0.10 手 @1.02971 SL 1.02803 TP 1.03222
開空 #16 0.10 手 @1.02864 SL 1.03168 TP 1.02408
平倉 #16 浮動盈虧 -17.90
開多 #18 0.10 手 @1.03043 SL 1.02783 TP 1.03432
開空 #20 0.10 手 @1.04039 SL 1.04251 TP 1.03721
開多 #22 0.10 手 @1.04355 SL 1.04015 TP 1.04865
開空 #24 0.10 手 @1.04372 SL 1.04719 TP 1.03852
開多 #26 0.10 手 @1.04316 SL 1.03964 TP 1.04844
開空 #28 0.10 手 @1.04074 SL 1.04508 TP 1.03422持倉編號通常就是開倉那張訂單的編號,所以開空 #8 之後平倉也叫 #8;日誌裡跳過的奇數是平倉那些訂單的編號。開倉後沒有「平倉」行的,就是被停損停利收掉的,最後一筆 #28 是測試結束時強制平掉。
14 筆的結局:7 筆停利、2 筆停損、4 筆被反向訊號平掉、最後 1 筆測試結束時強制平倉。報告的重點:
| 項目 | 數值 |
|---|---|
| 交易總計 | 14 |
| 盈利交易 | 8 (57.14%) |
| 總淨盈利 | 237.28 USD |
一個月 14 筆什麼都證明不了,有賺運氣成分居多。看得出來的是停利 3 倍、停損 2 倍 ATR 的效果:勝率四成就打平,這個月 57% 所以有賺。
1 月 16 到 17 日盤整那段開的 5 筆全賠:4 筆被反向訊號平掉、賠 7.8 到 17.9 塊,1 筆碰停損。均線交叉在盤整時就是這樣,兩條線黏在一起反覆穿越。
鎖倉帳號與單邊帳號
MT5 帳號有兩種模式。鎖倉(hedging)可以同一個商品同時持有多單和空單,各自是一筆持倉;單邊(netting)一個商品只有一筆持倉,反向下單直接抵銷。我的 Tickmill 帳號是鎖倉,開模擬帳號時的「使用鎖倉交易」選項就是這個。這篇的寫法是先平反向再開新倉,兩種模式跑出來都一樣;如果省掉平倉直接反向下單,鎖倉帳號會變成多空各一筆、單邊帳號會變成沒部位,寫 EA 之前要知道自己在哪一種上面。
常見的錯誤碼
| 代碼 | 名稱 | 意思 |
|---|---|---|
| 10009 | TRADE_RETCODE_DONE | 成交 |
| 10004 | TRADE_RETCODE_REQUOTE | 重新報價(市場執行的商品不會遇到) |
| 10014 | TRADE_RETCODE_INVALID_VOLUME | 手數不合規格(低於最小手數、不是步進的倍數) |
| 10016 | TRADE_RETCODE_INVALID_STOPS | 停損停利價位不合法(太近、方向反了) |
| 10018 | TRADE_RETCODE_MARKET_CLOSED | 市場關閉 |
| 10019 | TRADE_RETCODE_NO_MONEY | 保證金不夠 |
| 10027 | TRADE_RETCODE_CLIENT_DISABLES_AT | 終端的「演算法交易」沒開 |
全部的代碼在 MetaEditor 按 F1 搜 Trade Server Return Codes。
本篇檔案:MaCrossTrade.mq5,放到 MQL5\Experts\ 底下,在 MetaEditor 打開按 F7 就能用。