EA 掛上去之後,你不會一直盯著它。要知道三件事:它做了什麼、發生事情的時候有沒有人知道、隔一陣子還能不能確認它活著。這篇用四個機制回答:自己留一份事件檔、用交易事件抓到伺服器平掉的倉、一個通知入口接 Alert/推播/郵件/Webhook、一個定時器每天結一次帳。
兩個範例檔。MaCrossMonitor.mq5 是在均線交叉 EA MaCrossLive.mq5(EMA 10/30 交叉進場、每筆賠餘額 1%、ATR 倍數停損停利、保本與移動停損、上線自檢與接手持倉)上面加這四件事;策略邏輯一行沒動。NotifyDemo.mq5 是腳本,在連著伺服器的終端上把四個通知函式都叫一遍,印出回傳值和錯誤碼。EA 在策略測試器跑、腳本在真的終端跑,文中的日誌都是實際輸出。
先把範例跑起來
- MaCrossMonitor.mq5 放到
MQL5\Experts\,在 MetaEditor 按F7;策略測試器Ctrl+R:EA 選 MaCrossMonitor,EURUSD,H1,2025.01.02 到 2025.01.31,模式「每次報價」,入金 10000,杠杆 1:100,按「開始」。 - 「日誌」頁會多出以
[通知]開頭的行。另外在C:\Users\<使用者>\AppData\Roaming\MetaQuotes\Terminal\Common\Files\會多一個MaCrossMonitor_EURUSD_tester.log,用記事本打得開。 - NotifyDemo.mq5 放到
MQL5\Scripts\,按F7;回終端從「導航」拖到任何一張圖表上,輸出在「工具箱」的「專家」頁。它會真的送通知,第 3 節的設定開了哪幾個,就會收到哪幾個。
1. 日誌去了哪裡,自己再留一份
//=== 1. 日誌去了哪裡,自己再留一份 ==================================
void Log(string msg)
{
Print(msg); // 專家頁 + MQL5\Logs
int h = FileOpen(g_LogName, FILE_READ|FILE_WRITE|FILE_CSV|
FILE_ANSI|FILE_COMMON, '\t', CP_UTF8);
if(h == INVALID_HANDLE)
{
Print("事件檔打不開,錯誤碼 ", GetLastError());
return;
}
FileSeek(h, 0, SEEK_END); // 接在檔尾
string when = TimeToString(TimeTradeServer(),
TIME_DATE|TIME_SECONDS);
FileWrite(h, when, _Symbol, msg); // 三欄,Tab 隔開
FileClose(h);
}Print() 的字出現在「工具箱」的「專家」頁,同時寫進 MQL5\Logs\ 底下以日期命名的檔,一天一個。終端自己的訊息——連線、登入、被伺服器拒的單——在「日誌」頁和 logs\ 資料夾,兩邊要分開看。策略測試器裡 EA 印的東西在測試器的「日誌」頁,檔案在 Tester\logs\。
這些檔有一個問題:同一個終端所有 EA 的字混在一起。所以重要的事自己再留一份:一個商品一個檔,只寫事件——出場、日結、警告——不寫每個 tick、不寫每根 K 線。
FILE_COMMON 寫到所有終端共用的資料夾,路徑是 TerminalInfoString(TERMINAL_COMMONDATA_PATH),我這台是 C:\Users\<使用者>\AppData\Roaming\MetaQuotes\Terminal\Common;策略測試器也寫到同一個地方,不加的話會寫進測試器代理(真正跑回測的那個程式)自己的資料夾,要去 Tester 底下挖。
其他旗標決定檔案長什麼樣。FILE_READ|FILE_WRITE 一起給才會保留原內容,再 FileSeek 到檔尾接著寫;FILE_CSV 配 '\t',FileWrite 的三個參數就用 Tab 隔開、自動換行;FILE_ANSI 加 CP_UTF8 存成 UTF-8,記事本打得開。
每寫一行就開檔關檔,EA 被強制關掉也不會掉最後幾行。測試器每跑一趟都會往同一個檔接著寫,所以 OnInit 在測試器裡另起檔名、先 FileDelete;定時器也在這裡開、在 OnDeinit 關,第 4 節用:
// 事件檔:一個商品一個檔;測試器另起檔名,每趟從頭寫
g_LogName = "MaCrossMonitor_" + _Symbol +
(MQLInfoInteger(MQL_TESTER) ? "_tester" : "") + ".log";
if(MQLInfoInteger(MQL_TESTER))
FileDelete(g_LogName, FILE_COMMON);
Print("Common=", TerminalInfoString(TERMINAL_COMMONDATA_PATH));
Log("事件檔 Common\\Files\\" + g_LogName);
EventSetTimer(60); // 每分鐘進一次 OnTimer
...
}
void OnDeinit(const int reason)
{
EventKillTimer();
Comment(""); // 指標物件不用釋放
}跑完打開事件檔,每行三欄:時間、商品、訊息。節錄出場那幾行(Tab 這裡換成空格):
2025.01.17 15:26:28 EURUSD [通知] 出場 #14 停損 0.61 手 損益 -70.15
2025.01.20 06:03:04 EURUSD [通知] 出場 #16 停損 0.33 手 損益 -67.90
2025.01.20 11:38:35 EURUSD [通知] 出場 #18 停損 0.39 手 損益 1.562. 出場是伺服器做的,靠交易事件才知道
//=== 2. 出場是伺服器做的,靠交易事件才知道 ==========================
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)) // 先把這筆成交載進來
{
Print("成交 #", deal, " 載不進來,錯誤碼 ", GetLastError());
return;
}
if(HistoryDealGetInteger(deal, DEAL_MAGIC) != i_Magic ||
HistoryDealGetString(deal, DEAL_SYMBOL) != _Symbol)
return; // 不是這隻 EA 的
if(HistoryDealGetInteger(deal, DEAL_ENTRY) == DEAL_ENTRY_IN)
return; // 進場那筆開倉時已經印過
long reason = HistoryDealGetInteger(deal, DEAL_REASON);
string why = "其他";
if(reason <= DEAL_REASON_WEB) why = "手動"; // 桌面/手機/網頁
if(reason == DEAL_REASON_SL) why = "停損";
if(reason == DEAL_REASON_TP) why = "停利";
if(reason == DEAL_REASON_EXPERT) why = "EA 平倉";
if(reason == DEAL_REASON_SO) why = "強制平倉";
long pos = HistoryDealGetInteger(deal, DEAL_POSITION_ID);
Notify("出場 #" + IntegerToString(pos) + " " + why + " " +
DoubleToString(HistoryDealGetDouble(deal, DEAL_VOLUME), 2) +
" 手 損益 " + DoubleToString(PositionNet(pos), 2));
}
double PositionNet(long pos) // 這筆部位所有成交的淨損益
{
double net = 0;
if(!HistorySelectByPosition(pos))
return 0;
for(int i = HistoryDealsTotal() - 1; i >= 0; i--)
{
ulong d = HistoryDealGetTicket(i);
net += HistoryDealGetDouble(d, DEAL_PROFIT) +
HistoryDealGetDouble(d, DEAL_SWAP) +
HistoryDealGetDouble(d, DEAL_COMMISSION);
}
return net;
}停損停利是伺服器在盯,價格碰到就平,EA 只知道自己送出去的單。OnTradeTransaction 每有一筆交易變動就被叫一次,這裡只看 TRADE_TRANSACTION_DEAL_ADD——多了一筆成交。先 HistoryDealSelect 把那筆成交載進來,才讀得到它的屬性。
接著三個屬性:魔術碼和商品要對,別把別隻 EA 的倉算進來;DEAL_ENTRY 分進場和出場,進場那筆開倉時已經印過;DEAL_REASON 說是誰平的:伺服器的停損、停利、EA 自己送的、保證金不夠被強制平倉,桌面、手機、網頁手動平的是另外三個值,其餘一律印「其他」。
損益不要只看那筆出場成交。手續費進場、出場各扣一次,隔夜利息掛在出場那筆。HistorySelectByPosition 把同一個部位的所有成交撈出來加總,才會跟報告的每筆淨利一樣。
2025 年 1 月這趟 13 個出場:5 筆停利、6 筆停損、2 筆 EA 平倉(反向訊號)。「停損」不一定是賠錢——#8 保本之後才碰到停損,損益 +1.44。報告寫 14 筆,多的那筆是測試結束時強制平倉的,那一筆沒有事件。
2025.01.06 10:56:27 [通知] 出場 #2 停利 0.34 手 損益 144.84
2025.01.08 13:30:40 [通知] 出場 #4 停利 0.26 手 損益 151.12
2025.01.14 21:37:05 [通知] 出場 #6 停利 0.27 手 損益 152.28
2025.01.16 17:05:37 [通知] 出場 #8 停損 0.36 手 損益 1.44
2025.01.17 10:00:00 [通知] 出場 #10 EA 平倉 0.31 手 損益 -40.863. 通知:一個入口,四條路
//=== 3. 通知:一個入口,四條路 ======================================
void Notify(string msg)
{
Log("[通知] " + msg); // 先留紀錄,再往外送
if(MQLInfoInteger(MQL_TESTER))
return; // 測試器裡四個函式都不會動
string text = _Symbol + " " + msg;
if(i_NotifyAlert)
Alert(text);
ResetLastError(); // 每條路各看自己的錯誤碼
if(i_NotifyPush && !SendNotification(text))
Print("推播失敗,錯誤碼 ", GetLastError());
ResetLastError();
if(i_NotifyMail && !SendMail("EA " + _Symbol, text))
Print("寄信失敗,錯誤碼 ", GetLastError());
if(i_WebhookUrl == "")
return;
char body[], reply[];
string replyHdr;
int n = StringToCharArray(text, body, 0, WHOLE_ARRAY, CP_UTF8);
ArrayResize(body, n - 1); // 去掉結尾的 0
ResetLastError();
int code = WebRequest("POST", i_WebhookUrl,
"Content-Type: text/plain; charset=utf-8",
5000, body, reply, replyHdr);
if(code != 200)
Print("Webhook 回 ", code, ",錯誤碼 ", GetLastError());
}除了 Alert(),另外三個都要先在「工具 › 選項」設定;四個在策略測試器裡全部不執行,所以 Notify() 先寫進事件檔、再看 MQL_TESTER,測試器裡看得到「這時候會通知」,又不會真的送。四條路各一個 input 開關,預設只開推播;i_WebhookUrl 空白就不送。
Alert():不用設定。本機跳視窗加聲音,「專家」頁也留一行;只有坐在電腦前才看得到。SendNotification():「通知」頁勾「啟用推送通知」、填手機 App 的 MetaQuotes ID。一則 255 字,每秒 2 則、每分鐘 10 則。SendMail():「郵件」頁勾「啟用郵件通知」,填 SMTP 服務器、SMTP登錄名、SMTP 密碼、從、到。回 true 只是排進佇列。WebRequest():「EA交易」頁勾「允許WebRequest 用於所列的URL」,把網址加進那個清單(下面叫它白名單)。送出去會卡住 EA 直到回應或逾時,這裡設 5 秒;在交易事件裡卡太久,排隊中的事件會被新來的擠掉。
腳本在我這台跑的結果。推播和郵件沒設;白名單只加了 http://127.0.0.1,本機 port 80 掛一支只會印出收到什麼、回 ok 的小伺服器;第二個網址 192.0.2.1 故意不列進白名單:
SendNotification false 錯誤碼 4517
SendMail false 錯誤碼 4510
WebRequest http://127.0.0.1/hook -> 200 錯誤碼 0 回應 ok
WebRequest http://192.0.2.1/hook -> -1 錯誤碼 4006 回應
Alert: EA 測試通知 2026.09.22 06:534517 是推播沒設定,4510 是郵件送不出去(終端的「日誌」頁同時印一行 invalid settings)。WebRequest 的網址沒列進白名單時回 -1、錯誤碼 4006——文件沒寫這種情況會回哪個號碼,這個號碼的官方名稱是 ERR_INVALID_ARRAY,跟情境對不上,別拿錯誤碼的名稱猜原因。列了的那個網址回 HTTP 200,小伺服器收到的是:
POST /hook text/plain; charset=utf-8 EA 測試通知 2026.09.22 06:53送 POST 那幾行:文字用 StringToCharArray 轉成 UTF-8 的位元組,回傳的長度含結尾的 0,要去掉一個。換成 Telegram 或 LINE 的 bot,就是網址、header 和 body 格式照它們的文件改,白名單也要加它們的網域。
4. 定時器:每天結一次帳、報價停了要說
//=== 4. 定時器:每天結一次帳、報價停了要說 ==========================
void OnTimer()
{
datetime now = TimeTradeServer(); // 沒報價時 TimeCurrent 停住
datetime today = now - now % 86400; // 今天 00:00(伺服器時間)
if(g_DayStart == 0)
g_DayStart = today;
if(today > g_DayStart) // 過了午夜:結上一天的帳
{
DailySummary(g_DayStart, today);
g_DayStart = today;
}
CheckStale(now);
}
void DailySummary(datetime from, datetime to)
{
int n = 0;
double net = 0;
if(HistorySelect(from, to - 1)) // to 那一秒算下一天
for(int i = HistoryDealsTotal() - 1; i >= 0; i--)
{
ulong d = HistoryDealGetTicket(i);
if(HistoryDealGetInteger(d, DEAL_MAGIC) != i_Magic ||
HistoryDealGetString(d, DEAL_SYMBOL) != _Symbol)
continue;
net += HistoryDealGetDouble(d, DEAL_PROFIT) +
HistoryDealGetDouble(d, DEAL_SWAP) +
HistoryDealGetDouble(d, DEAL_COMMISSION);
if(HistoryDealGetInteger(d, DEAL_ENTRY) != DEAL_ENTRY_IN)
n++; // 只數出場
}
Notify("日結 " + TimeToString(from, TIME_DATE) + " " +
IntegerToString(n) + " 筆 損益 " + DoubleToString(net, 2) +
" 餘額 " + DoubleToString(
AccountInfoDouble(ACCOUNT_BALANCE), 2) + " 權益 " +
DoubleToString(AccountInfoDouble(ACCOUNT_EQUITY), 2));
}
void CheckStale(datetime now)
{
datetime open;
if(i_StaleMin <= 0 || !InSession(now, open))
return; // 休市時間不算
datetime last = (datetime)SymbolInfoInteger(_Symbol, SYMBOL_TIME);
if(last < open)
last = open; // 開盤之後才起算
int idle = (int)(now - last) / 60;
if(idle >= i_StaleMin && !g_StaleSaid)
{
g_StaleSaid = true;
Notify("報價停了 " + IntegerToString(idle) + " 分鐘");
}
if(idle < i_StaleMin && g_StaleSaid)
{
g_StaleSaid = false;
Log("報價恢復");
}
}
bool InSession(datetime now, datetime &open) // 現在在交易時段內?
{
MqlDateTime dt;
TimeToStruct(now, dt);
datetime day = now - now % 86400;
datetime from, to;
for(int i = 0; SymbolInfoSessionTrade(_Symbol,
(ENUM_DAY_OF_WEEK)dt.day_of_week, i, from, to); i++)
{
from += day; // 時段是「幾點」,加上今天
to += day;
if(now >= from && now < to)
{
open = from;
return true;
}
}
return false;
}EventSetTimer(60) 之後 OnTimer 每分鐘進來一次,週末沒報價也照跑,測試器裡也一樣。所以現在幾點要拿 TimeTradeServer(),它是終端自己算的、沒報價也會走;TimeCurrent() 是最後一個報價的時間,斷線就停在那裡。文件說測試器裡兩者相等,實測(build 6182)週末只有前者在走。
日結:過了午夜就用 HistorySelect 把前一天的成交撈出來,算出場筆數和淨損益,連餘額、權益一起送。損益是那天所有成交的合計,跟出場通知的整個部位損益是兩個口徑,所以 1 月 7 日開倉那天會看到「0 筆 損益 -0.78」,那是進場那筆的手續費。這一則每天都會來,包括週末:它不只是報帳,是「EA 還活著」的心跳,哪天沒收到,就該去看終端了。EA 重啟跨過午夜的那天不會補結,狀態只記在記憶體裡。
[通知] 日結 2025.01.15 0 筆 損益 0.00 餘額 10448.24 權益 10448.24
[通知] 日結 2025.01.16 1 筆 損益 0.51 餘額 10448.75 權益 10463.94
[通知] 日結 2025.01.17 3 筆 損益 -194.27 餘額 10254.48 權益 10310.58
[通知] 日結 2025.01.18 0 筆 損益 0.00 餘額 10254.48 權益 10310.58
[通知] 日結 2025.01.19 0 筆 損益 0.00 餘額 10254.48 權益 10310.58報價停了:SymbolInfoSessionTrade 查這個商品今天的交易時段,在時段內、離上一個報價滿 i_StaleMin 分鐘才警告,恢復再印一行。看的是 SYMBOL_TIME——這個商品自己的最後報價,不用 TimeCurrent(),別的商品有報價它也會走。週末不算,週一從開盤時間起算,不然會把整個週末算進去;查的是交易時段,商品沒有時段資料就永遠不會叫。
把門檻改成 1 分鐘、跑跨週末的 1 月 3 日到 7 日:週六、週日一次都沒叫;週一 00:02 開盤,00:05 才有第一個報價,00:03 警告、00:05 恢復。預設 30 分鐘。
2025.01.06 00:03:00 [通知] 報價停了 1 分鐘
2025.01.06 00:05:00 報價恢復執行結果
MaCrossMonitor 跑 2025 年 1 月,EA 自己印的 78 行開倉、保本、移動、平倉跟 MaCrossLive 逐字相同:14 筆交易、總淨盈利 416.21。多出來的是 41 行 [通知]:13 個出場、28 個日結。日誌開頭:
EA 啟動:保本 1.0 ATR +10 點,移動停損 2.0 ATR,停利 3.0 ATR
Common=C:\Users\<使用者>\AppData\Roaming\MetaQuotes\Terminal\Common
事件檔 Common\Files\MaCrossMonitor_EURUSD_tester.log
自檢通過:EURUSD 鎖倉帳戶 餘額 10000.00
沒有要接手的持倉上線之後看什麼
- 每天等那一則日結。沒來,先看終端還開著沒、連線有沒有掉。
- 出場通知看原因:一路「停損」是行情比停損距離大,一路「EA 平倉」是訊號來回翻。
- 要回頭看一個月做了什麼,開事件檔,不用在專家頁裡翻。
- 「專家」頁還是要看:下單失敗、自檢沒過、改停損被拒,這些只在那裡。
本篇檔案:MaCrossMonitor.mq5 是 EA,放 MQL5\Experts\;NotifyDemo.mq5 是腳本,放 MQL5\Scripts\。在 MetaEditor 打開按 F7 就能用。