はじめに
MT5でEA(自動売買プログラム)を運用していて、ふと気づいたことがあります。
MT5の標準プッシュ通知は、MT5自身が生きていることが前提なんですよね。
約定通知やアラートは標準機能で飛ばせます。ただ、VPSが落ちた、MT5がクラッシュした、EAがチャートから外れていた——つまり一番通知が欲しい瞬間には、通知の送り手自身が死んでいるので、何も届きません。気づいたら数日EAが止まっていた、という事故はEA運用者なら一度は聞いたことがあるはずです。
そこで、MT5の外側にハートビート(生存信号)の受け口を置いて、信号が途絶えたらDiscordに通知する仕組みを作りました。構成はすべて無料です。
- MQL5(EAにincludeするだけのモジュール)
- Google Apps Script(受信+途絶判定)
- Googleスプレッドシート(状態保存)
- Discord Webhook(通知)
なお、2025年3月にLINE Notifyが終了した影響で、この手の通知はLINEからDiscordへの移行が進んでいます。Discord Webhookは登録も審査も不要でURLを1つ発行するだけなので、今から作るならDiscord一択だと思います。
この記事では、システムの全体像と実装、そして実際に踏んだ3つの罠(ここが本編です)を紹介します。
筆者はSIer歴約20年のシステムエンジニアで、普段は業務システムの開発リーダーをしています。FX自動売買はデモ口座で検証中の身です。
全体アーキテクチャ
[MT5/EA] --(WebRequest POST/60秒毎)--> [GAS WebApp(doPost)]
|
[スプレッドシートに最終受信時刻をupsert]
|
[GAS 時間主導トリガー(5分毎)checkAlive]
|
最終受信からN分超過 → [Discord Webhookへ⚠️]
受信再開 → [Discord Webhookへ✅]
ポイントは3つ。
- 監視の主体がMT5の外にあること。GASのトリガーはMT5が死んでいても動くので、「死んだこと」を検知できます
- プッシュ型・ハートビートであること。外形監視(ポーリング)はMT5に対しては使えません。EAはHTTPサーバーではないので外から生存確認しに行けないためです
- すべて自分のGoogleアカウント内で完結すること。口座情報が第三者のサーバーに送信されない構成です(後述)
設計方針
取引ロジックに一切影響を与えない
監視のために本体が死んだら本末転倒なので、以下を徹底しました。
- 既存EAへの変更は「includeの追加+関数呼び出し2行」のみ
- WebRequestの送信失敗時はリトライしない(次の周期で自然に再送される)
- 送信失敗はログ出力のみで、OnTickの後続処理を止めない
個人情報・資産情報を設計レベルで持たない
死活監視に必要なのは「EAの識別子」と「受信時刻」だけです。なので送信ペイロードは、
- 必須:
ea_id(任意のニックネーム文字列。例v3-usdjpy)、サーバー時刻 - オプション(デフォルトOFF): symbol、timeframe、equity、ポジション数
とし、口座番号・残高・ブローカー名は送信項目自体に存在しません。持っていないものは漏れようがない、というデータ最小化の考え方です。
実装
MQL5側: HeartbeatSender.mqh
EAに組み込むincludeモジュールです。ポイントだけ抜粋します。
//+------------------------------------------------------------------+
//| HeartbeatSender.mqh (抜粋) |
//+------------------------------------------------------------------+
string hb_eaId;
string hb_url;
int hb_intervalSec = 60;
long hb_magic;
datetime hb_lastSent = 0;
void HeartbeatInit(string eaId, string url, int intervalSec, long magic)
{
hb_eaId = eaId;
hb_url = url;
hb_intervalSec = intervalSec;
hb_magic = magic;
Print("[Heartbeat] initialized: ea_id=", hb_eaId, " interval=", hb_intervalSec, "s");
}
void HeartbeatTick()
{
// ストラテジーテスター(バックテスト)中はWebRequestが使えないためスキップ
if(MQLInfoInteger(MQL_TESTER)) return;
// OnTickは高頻度で呼ばれるため、必ず時間ガードを入れる
if(TimeCurrent() - hb_lastSent < hb_intervalSec) return;
hb_lastSent = TimeCurrent();
string payload = "{\"ea_id\":\"" + hb_eaId + "\"," +
"\"server_time\":\"" + TimeToString(TimeCurrent(), TIME_DATE|TIME_SECONDS) + "\"}";
char data[], result[];
string headers = "Content-Type: application/json\r\n";
string resultHeaders;
StringToCharArray(payload, data, 0, StringLen(payload));
ResetLastError();
int status = WebRequest("POST", hb_url, headers, 5000, data, result, resultHeaders);
if(status == -1)
Print("[Heartbeat] send failed: error=", GetLastError());
else
Print("[Heartbeat] sent: ea_id=", hb_eaId, " http_code=", status);
// 失敗してもリトライしない。次の周期で再送されるため、取引ロジックへの影響を最小化する
}
既存EAへの組み込みは3行だけです。
#property strict
#include <HeartbeatSender.mqh> // (1) 先頭に追加
int OnInit()
{
// ...既存の初期化処理...
HeartbeatInit("v3-usdjpy", "GASのWebアプリURL", 60, MagicNumber); // (2) return直前に追加
return(INIT_SUCCEEDED);
}
void OnTick()
{
HeartbeatTick(); // (3) 必ず一番最初に追加
// ...既存の取引ロジック...
}
(3)をOnTickの先頭に置くのは地味に重要です。多くのEAは時間帯フィルターや新バー判定で早期returnする構造になっており、その後ろに置くと「EAは生きているのに送信が止まる」時間帯が生まれて誤報の原因になります。
GAS側: monitor.gs
受信(doPost)、途絶判定(checkAlive)、通知(sendDiscordMessage)の3役です。要点のみ抜粋します。
// 受信: ea_idをキーにシートへupsert
function doPost(e) {
try {
var payload = JSON.parse(e.postData.contents);
if (!payload.ea_id) return jsonResponse({ status: 'error', message: 'ea_id is required' });
upsertHeartbeat(payload.ea_id, { receivedAt: new Date() });
return jsonResponse({ status: 'ok' });
} catch (err) {
console.error('doPost error: ' + String(err)); // ★後述の罠2の教訓
return jsonResponse({ status: 'error', message: String(err) });
}
}
// 生死判定(純粋関数として分離。将来Cloudflare Workers等へ移植しやすくするため)
function evaluateHeartbeat(row, now, thresholdMin) {
var elapsedMin = (now.getTime() - row.lastReceived.getTime()) / 60000;
var isStale = elapsedMin > thresholdMin;
if (isStale && !row.alerted) return 'ALERT';
if (!isStale && row.alerted) return 'RECOVERED';
return 'NONE';
}
// 5分毎トリガー: 途絶/復帰を検知してDiscordへ
function checkAlive() {
// (週末はFX市場が閉まりティックが来ない=送信停止が正常なので判定をスキップ)
// ...省略...
rows.forEach(function (row) {
var decision = evaluateHeartbeat(row, now, thresholdMin);
if (decision === 'ALERT') {
var sent = sendDiscordMessage(webhookUrl, '⚠️ ' + row.eaId + ' からのハートビートが途絶しています');
if (sent) setAlertedFlag(sheet, row.rowNum, true); // ★送信成功時のみフラグ更新(罠3の教訓)
Utilities.sleep(1000);
} else if (decision === 'RECOVERED') {
var sent = sendDiscordMessage(webhookUrl, '✅ ' + row.eaId + ' が復帰しました');
if (sent) setAlertedFlag(sheet, row.rowNum, false);
Utilities.sleep(1000);
}
});
}
// Discord送信: 成否をbooleanで返す
function sendDiscordMessage(webhookUrl, content) {
var res = UrlFetchApp.fetch(webhookUrl, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ content: content }),
muteHttpExceptions: true
});
var code = res.getResponseCode();
if (code < 200 || code >= 300) {
console.error('Discord送信失敗: HTTP ' + code + ' ' + res.getContentText());
return false;
}
return true;
}
設計上のこだわりは、「アラートを送れたかどうか」まで含めて状態管理していることです。ここが後述の罠3につながります。
セットアップの要点
詳細な手順は省きますが、詰まりやすい箇所だけ。
-
GAS: スクリプトプロパティに
SPREADSHEET_ID(記録先シートのID)、DISCORD_WEBHOOK_URLを設定 → ウェブアプリとしてデプロイ(アクセス: 全員) →setupTriggers()を1回手動実行してトリガー作成 - Discord: チャンネルの編集 → 連携サービス → ウェブフックを作成 → URLをコピー(このURLは実質パスワードなので公開厳禁)
- MT5: ツール → オプション → エキスパートアドバイザ → 「WebRequestを許可するURLリスト」に以下の2つを追加
https://script.google.com
https://script.googleusercontent.com
2つ必要な理由: GASのWebアプリは script.google.com へのPOSTに対して302リダイレクトを返し、実体は script.googleusercontent.com から応答されるためです。前者だけだとリダイレクト先で弾かれます。
踏んだ罠3選(本編)
ここからが本題です。設計通りに作ったつもりでも、実際に動かすと事件は起きます。
罠1: スタンドアロンGASで getActiveSpreadsheet() は使えない
最初の実装では、シート取得をこう書いていました。
var ss = SpreadsheetApp.getActiveSpreadsheet(); // ダメなやつ
getActiveSpreadsheet() が返すのは「GASプロジェクトが紐付いているスプレッドシート」です。スプレッドシートの拡張機能メニューから作ったコンテナバインド型なら動きますが、script.google.com から単独で作ったスタンドアロン型プロジェクトには紐付き先が存在しないため、シートを取得できません。
対策はID明示指定です。
var ssId = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');
var ss = SpreadsheetApp.openById(ssId);
シートのIDはURLの /d/ と /edit の間の文字列です。
罠2: 実行ログは「完了」、EAにはHTTP 200、なのにシートは空
罠1が厄介なのは、失敗が完了に見えることです。
- GASの実行数ログ: doPost「完了」
- MT5側のログ: http_code=200
- スプレッドシート: 空
なぜこうなるかというと、doPost全体をtry-catchで包んでいたため、getActiveSpreadsheet()起因のエラーがcatchに落ち、エラー内容がJSONレスポンスとして正常に(!)返却されていたからです。GAS視点では「JSONを返すところまで完走した」ので完了、EA視点では「200が返ってきた」ので成功。誰も嘘をついていないのに、誰も真実を知らないという状況でした。
教訓は2つ。
-
catchで握り潰す前に
console.errorでログを残す。これだけで実行数ログから原因が一発で追えるようになります - HTTP 200は「処理成功」を意味しない。MT5のWebRequestはGASのリダイレクト応答を追跡した結果の200を返すため、アプリケーションレベルの成否は別途確認が必要です
罠3: Discordに通知が1通しか届かない(HTTP 429 / error code 1015)
2つのEAを監視している状態でMT5を停止する途絶テストをしたところ、⚠️通知が1通しか届きませんでした。GASの実行ログを見ると:
Discord送信失敗: HTTP 429 error code: 1015
429はレート制限ですが、注目は error code: 1015。これはDiscord本体ではなく、その前段にいるCloudflareによるレート制限を示すコードです。
GASの UrlFetchApp は、Googleの共有IPプールから外部にアクセスします。つまり自分は2通しか送っていなくても、同じIPを共有する世界中のGASユーザーの送信量と合算されて制限を食らうことがあります。自分のペースと無関係に発生するため、「送信間隔を空ける」だけでは根本対策になりません。
対策は自己回復型の設計にすることです。
-
sendDiscordMessage()が成否を返す - 送信に成功した時だけアラート済みフラグを更新する
- 失敗時はフラグを変えないので、次の5分毎トリガーで自動的に再送される
「失敗したら諦める」ではなく「失敗したら状態を進めない」。通知は多少遅れることがあっても、消滅はしなくなりました。あわせて連続送信の間に Utilities.sleep(1000) を入れて、自分起因の429も予防しています。
動作確認
以下のテストをすべてパスしました。
| # | テスト | 結果 |
|---|---|---|
| 1 | 正常系: 60秒毎にシートの最終受信時刻が更新される | ✅ |
| 2 | 途絶検知: MT5終了→閾値超過でDiscordに⚠️ | ✅ |
| 3 | 多重発報防止: ⚠️が繰り返し送られない | ✅ |
| 4 | 復帰検知: MT5再起動→✅通知(2EA分が1秒間隔で2通) | ✅ |
| 5 | バックテスト中に送信が走らない | ✅ |
まとめ
- MT5標準の通知は「MT5が死んだこと」を通知できない。外部にハートビートの受け口を置くことで解決できる
- GAS+スプレッドシート+Discord Webhookなら完全無料で、自分のGoogleアカウント内に閉じた構成にできる
- スタンドアロンGASでは
openById()を使う。try-catchはconsole.errorとセットで - GASからのDiscord送信は共有IP起因のレート制限(1015)があり得る。「送信成功時のみ状態を進める」自己回復型設計が有効
コードはEA側・GAS側とも、識別子と受信時刻だけで動く最小構成にしてあるので、EAが何個に増えても HeartbeatInit() のea_idを変えるだけで相乗りできます。
同じようにEAの突然死に怯えている方の参考になれば幸いです。
