In MT5, using an Expert Advisor (OnTimer) rather than a single-symbol OnCalculate() indicator is the institutional standard for multi-symbol scanning: OnCalculate only wakes up when the host chart receives a tick, whereas OnTimer() polls US500, US100, DE40, and XAUUSD deterministically every second even if your host chart is quiet.
Create a new Expert Advisor in MetaEditor 5 named MultiSymbolQuantScannerPro.mq5:
Code: Select all
//+------------------------------------------------------------------+
//| MultiSymbolQuantScannerPro.mq5 |
//| Single-Chart Multi-Asset M15 Liquidity Sweep Scanner & HUD |
//| Simultaneously Monitors: US500, US100, DE40, XAUUSD |
//+------------------------------------------------------------------+
#property copyright "Quant Multi-Asset Architecture"
#property version "3.00"
#property strict
//--- Input Parameters
input group "1. Multi-Symbol Watchlist"
input string InpSymbolsCsv = "US500,US100,DE40,XAUUSD"; // Comma-Separated Tickers
input group "2. Hybrid Money Management (Mobile Alert Lot Calc)"
input double InpSeriesCapital = 5000.0; // Series Starting Capital ($)
input double InpBaseRiskPct = 1.0; // Base Risk Per Trade (%)
input double InpExpProfitMult = 1.5; // Accrued Profit Scaling Factor
input group "3. Adaptive ATR(14) & Microstructure Filters"
input double InpMinSweepAtr = 0.10; // Min Sweep Depth (x M15 ATR)
input double InpMaxBarAtr = 2.80; // Max Candle Size Filter (x M15 ATR)
input double InpSlBufferAtr = 0.20; // Stop-Loss Buffer (x M15 ATR)
input double InpMinWickPct = 32.0; // Min Rejection Wick (% of Bar)
input bool InpRequireVolSpike = true; // Require Tick Volume > 1.15x SMA(20)
input group "4. Notifications"
input bool InpDesktopAlert = true; // Desktop Alert Popup
input bool InpMobilePush = true; // Send MT5 Mobile Push Notification
//--- Asset Profile Enum
enum ENUM_ASSET_PROFILE
{
PROFILE_US_INDEX,
PROFILE_EU_DAX,
PROFILE_GOLD_COMEX
};
//--- Per-Symbol State Structure
struct SymbolSlot
{
string name;
bool active;
ENUM_ASSET_PROFILE profile;
int atrHandle;
int h4EmaHandle;
datetime lastBarTime;
datetime sessionAnchor;
double rangeHigh;
double rangeLow;
double pdHigh;
double pdLow;
bool shortFired;
bool longFired;
string statusText;
string lastSignal;
};
SymbolSlot g_slots[];
int g_slotCount = 0;
//+------------------------------------------------------------------+
//| Resolve Broker Symbol Name Automatically |
//+------------------------------------------------------------------+
string ResolveBrokerTicker(string baseTicker)
{
StringTrimLeft(baseTicker);
StringTrimRight(baseTicker);
string suffixes[] = {"", ".cash", ".r", "_i", ".pro"};
for(int i = 0; i < ArraySize(suffixes); i++)
{
string cand = baseTicker + suffixes[i];
if(SymbolSelect(cand, true)) return cand;
}
// Fallback for GER40 vs DE40
if(StringFind(baseTicker, "DE40") >= 0)
{
if(SymbolSelect("GER40", true)) return "GER40";
if(SymbolSelect("GER40.cash", true)) return "GER40.cash";
}
return "";
}
//+------------------------------------------------------------------+
//| Classify Symbol into Session Profile |
//+------------------------------------------------------------------+
ENUM_ASSET_PROFILE ClassifyProfile(string sym)
{
string u = sym;
StringToUpper(u);
if(StringFind(u, "DE40") >= 0 || StringFind(u, "GER40") >= 0 || StringFind(u, "DAX") >= 0)
return PROFILE_EU_DAX;
if(StringFind(u, "XAU") >= 0 || StringFind(u, "GOLD") >= 0)
return PROFILE_GOLD_COMEX;
return PROFILE_US_INDEX;
}
//+------------------------------------------------------------------+
//| Expert initialization function |
//+------------------------------------------------------------------+
int OnInit()
{
string rawList[];
int count = StringSplit(InpSymbolsCsv, ',', rawList);
ArrayResize(g_slots, count);
g_slotCount = 0;
for(int i = 0; i < count; i++)
{
string resolved = ResolveBrokerTicker(rawList[i]);
if(resolved == "")
{
PrintFormat("[Scanner Warning] Ticker '%s' not found in MarketWatch.", rawList[i]);
continue;
}
int idx = g_slotCount++;
g_slots[idx].name = resolved;
g_slots[idx].active = true;
g_slots[idx].profile = ClassifyProfile(resolved);
g_slots[idx].atrHandle = iATR(resolved, PERIOD_M15, 14);
g_slots[idx].h4EmaHandle = iMA(resolved, PERIOD_H4, 50, 0, MODE_EMA, PRICE_CLOSE);
g_slots[idx].lastBarTime = 0;
g_slots[idx].sessionAnchor = 0;
g_slots[idx].rangeHigh = -DBL_MAX;
g_slots[idx].rangeLow = DBL_MAX;
g_slots[idx].pdHigh = -DBL_MAX;
g_slots[idx].pdLow = DBL_MAX;
g_slots[idx].shortFired = false;
g_slots[idx].longFired = false;
g_slots[idx].statusText = "INITIALIZING";
g_slots[idx].lastSignal = "None";
}
ArrayResize(g_slots, g_slotCount);
EventSetTimer(1); // Poll all 4 symbols every 1 second
return(INIT_SUCCEEDED);
}
//+------------------------------------------------------------------+
//| Expert deinitialization function |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
EventKillTimer();
for(int i = 0; i < g_slotCount; i++)
{
if(g_slots[i].atrHandle != INVALID_HANDLE) IndicatorRelease(g_slots[i].atrHandle);
if(g_slots[i].h4EmaHandle != INVALID_HANDLE) IndicatorRelease(g_slots[i].h4EmaHandle);
}
Comment("");
}
//+------------------------------------------------------------------+
//| Automatic US & EU DST Offset Calculators |
//+------------------------------------------------------------------+
int GetUsOffsetSec(datetime utc)
{
MqlDateTime dt; TimeToStruct(utc, dt);
MqlDateTime m1 = {dt.year, 3, 1, 7, 0, 0, 0, 0};
datetime tm1 = StructToTime(m1); TimeToStruct(tm1, m1);
datetime dstStart = tm1 + (((7 - m1.day_of_week) % 7 + 7) * 86400);
MqlDateTime n1 = {dt.year, 11, 1, 6, 0, 0, 0, 0};
datetime tn1 = StructToTime(n1); TimeToStruct(tn1, n1);
datetime dstEnd = tn1 + (((7 - n1.day_of_week) % 7) * 86400);
return (utc >= dstStart && utc < dstEnd) ? (-4 * 3600) : (-5 * 3600);
}
int GetEuOffsetSec(datetime utc)
{
MqlDateTime dt; TimeToStruct(utc, dt);
MqlDateTime m31 = {dt.year, 3, 31, 1, 0, 0, 0, 0};
datetime tm31 = StructToTime(m31); TimeToStruct(tm31, m31);
datetime dstStart = tm31 - (m31.day_of_week * 86400);
MqlDateTime o31 = {dt.year, 10, 31, 1, 0, 0, 0, 0};
datetime to31 = StructToTime(o31); TimeToStruct(to31, o31);
datetime dstEnd = to31 - (o31.day_of_week * 86400);
return (utc >= dstStart && utc < dstEnd) ? (2 * 3600) : (1 * 3600);
}
int GetServerOffsetSec()
{
return (int)MathRound((double)(TimeTradeServer() - TimeGMT()) / 1800.0) * 1800;
}
//+------------------------------------------------------------------+
//| Compute Asset-Specific Session Boundaries in Broker Server Time |
//+------------------------------------------------------------------+
void GetAssetSessionServerTime(ENUM_ASSET_PROFILE prof, datetime barSrv, int srvOffSec,
datetime &outRangeStartSrv, datetime &outKillOpenSrv,
datetime &outKillEndSrv, datetime &outAnchor)
{
datetime barUtc = barSrv - srvOffSec;
int nyOff = GetUsOffsetSec(barUtc);
int euOff = GetEuOffsetSec(barUtc);
if(prof == PROFILE_EU_DAX)
{
datetime cetTime = barUtc + euOff;
MqlDateTime dtCet; TimeToStruct(cetTime, dtCet);
int curHour = dtCet.hour;
dtCet.hour = 0; dtCet.min = 0; dtCet.sec = 0;
datetime cetMid = StructToTime(dtCet);
if(curHour < 13)
{
outAnchor = cetMid;
outRangeStartSrv = (cetMid + 1 * 3600) - euOff + srvOffSec; // 01:00 - 08:00 CET
outKillOpenSrv = (cetMid + 8 * 3600) - euOff + srvOffSec; // 08:00 CET Xetra Open
outKillEndSrv = (cetMid + 10 * 3600 + 1800) - euOff + srvOffSec;// 10:30 CET
}
else
{
outAnchor = cetMid + 12 * 3600;
outRangeStartSrv = (cetMid + 8 * 3600) - euOff + srvOffSec; // 08:00 CET EU Range
datetime nyTime = barUtc + nyOff;
MqlDateTime dtNy; TimeToStruct(nyTime, dtNy);
dtNy.hour = 0; dtNy.min = 0; dtNy.sec = 0;
datetime nyMid = StructToTime(dtNy);
outKillOpenSrv = (nyMid + 9 * 3600 + 1800) - nyOff + srvOffSec; // 09:30 NY Open
outKillEndSrv = (nyMid + 11 * 3600 + 1800) - nyOff + srvOffSec; // 11:30 NY
}
return;
}
datetime nyTime = barUtc + nyOff;
MqlDateTime dtNy; TimeToStruct(nyTime, dtNy);
dtNy.hour = 0; dtNy.min = 0; dtNy.sec = 0;
datetime nyMid = StructToTime(dtNy);
outAnchor = nyMid;
if(prof == PROFILE_GOLD_COMEX)
{
outRangeStartSrv = (nyMid + 3 * 3600) - nyOff + srvOffSec; // 03:00 NY
outKillOpenSrv = (nyMid + 8 * 3600 + 900) - nyOff + srvOffSec; // 08:15 NY (COMEX Open)
outKillEndSrv = (nyMid + 11 * 3600 + 1800) - nyOff + srvOffSec; // 11:30 NY
}
else
{
outRangeStartSrv = (nyMid + 3 * 3600) - nyOff + srvOffSec; // 03:00 NY
outKillOpenSrv = (nyMid + 9 * 3600 + 1800) - nyOff + srvOffSec; // 09:30 NY Cash Open
outKillEndSrv = (nyMid + 12 * 3600) - nyOff + srvOffSec; // 12:00 NY
}
}
//+------------------------------------------------------------------+
//| Hybrid Position Size Calculator Per Symbol |
//+------------------------------------------------------------------+
double CalcSymbolLots(string sym, double stopPts, double &outRiskUsd)
{
double eq = AccountInfoDouble(ACCOUNT_EQUITY);
if(eq <= 0) eq = InpSeriesCapital;
double profit = MathMax(0.0, eq - InpSeriesCapital);
double baseRisk = InpSeriesCapital * (InpBaseRiskPct / 100.0);
double expRisk = profit * ((InpBaseRiskPct * InpExpProfitMult) / 100.0);
outRiskUsd = (eq < InpSeriesCapital) ? (eq * (InpBaseRiskPct / 100.0)) : (baseRisk + expRisk);
double tickSize = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE);
double tickVal = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE);
double step = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);
double minLot = SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN);
double maxLot = SymbolInfoDouble(sym, SYMBOL_VOLUME_MAX);
if(tickSize <= 0 || tickVal <= 0 || stopPts <= 0) return minLot;
double lossPerLot = (stopPts / tickSize) * tickVal;
double rawLots = (lossPerLot > 0) ? (outRiskUsd / lossPerLot) : minLot;
double stepped = MathFloor(rawLots / step) * step;
return MathMax(minLot, MathMin(maxLot, stepped));
}
//+------------------------------------------------------------------+
//| Timer Loop: Poll All Symbols & Render Unified Dashboard |
//+------------------------------------------------------------------+
void OnTimer()
{
int srvOffSec = GetServerOffsetSec();
datetime nowSrv = TimeTradeServer();
string clusterSymbols = "";
int clusterCount = 0;
for(int s = 0; s < g_slotCount; s++)
{
MqlRates rates[];
ArraySetAsSeries(rates, true);
if(CopyRates(g_slots[s].name, PERIOD_M15, 0, 50, rates) < 35) continue;
datetime closedBarSrv = rates[1].time;
datetime rStart, kOpen, kEnd, anchor;
GetAssetSessionServerTime(g_slots[s].profile, closedBarSrv, srvOffSec, rStart, kOpen, kEnd, anchor);
// Reset Session State on new session anchor
if(anchor != g_slots[s].sessionAnchor)
{
g_slots[s].sessionAnchor = anchor;
g_slots[s].shortFired = false;
g_slots[s].longFired = false;
double pdH[1], pdL[1];
g_slots[s].pdHigh = (CopyHigh(g_slots[s].name, PERIOD_D1, 1, 1, pdH) > 0) ? pdH[0] : -DBL_MAX;
g_slots[s].pdLow = (CopyLow(g_slots[s].name, PERIOD_D1, 1, 1, pdL) > 0) ? pdL[0] : DBL_MAX;
}
// Accumulate Pre-Killzone Range High & Low
double rH = -DBL_MAX, rL = DBL_MAX;
for(int j = 1; j < 50; j++)
{
if(rates[j].time < rStart) break;
if(rates[j].time >= rStart && rates[j].time < kOpen)
{
if(rates[j].high > rH) rH = rates[j].high;
if(rates[j].low < rL) rL = rates[j].low;
}
}
g_slots[s].rangeHigh = rH;
g_slots[s].rangeLow = rL;
// Update Live Status
if(nowSrv >= rStart && nowSrv < kOpen) g_slots[s].statusText = "BUILDING RANGE";
else if(nowSrv >= kOpen && nowSrv < kEnd) g_slots[s].statusText = "KILLZONE ACTIVE";
else g_slots[s].statusText = "STANDBY";
// Only evaluate closed bar once per M15 interval
if(rates[0].time == g_slots[s].lastBarTime) continue;
g_slots[s].lastBarTime = rates[0].time;
if(closedBarSrv < kOpen || closedBarSrv >= kEnd) continue;
if(rH <= rL) continue;
// Fetch ATR(14) and H4 EMA(50)
double atrBuf[1], h4Ema[1], h4Close[1];
if(CopyBuffer(g_slots[s].atrHandle, 0, 1, 1, atrBuf) <= 0) continue;
double atr = atrBuf[0];
bool h4Bull = false, h4Bear = false;
if(CopyBuffer(g_slots[s].h4EmaHandle, 0, 0, 1, h4Ema) > 0 &&
CopyClose(g_slots[s].name, PERIOD_H4, 0, 1, h4Close) > 0)
{
h4Bull = (h4Close[0] > h4Ema[0]);
h4Bear = (h4Close[0] < h4Ema[0]);
}
double high = rates[1].high;
double low = rates[1].low;
double open = rates[1].open;
double close = rates[1].close;
double rng = high - low;
if(rng <= 0 || rng > atr * InpMaxBarAtr) continue;
double volSum = 0;
for(int v = 2; v <= 21; v++) volSum += (double)rates[v].tick_volume;
if(InpRequireVolSpike && (double)rates[1].tick_volume < (volSum / 20.0) * 1.15) continue;
double minSwp = atr * InpMinSweepAtr;
double slBuf = atr * InpSlBufferAtr;
// --- BEARISH SWEEP ---
if(!g_slots[s].shortFired)
{
bool swpRH = (high >= rH + minSwp && close < rH);
bool swpPH = (g_slots[s].pdHigh > 0 && high >= g_slots[s].pdHigh + minSwp && close < g_slots[s].pdHigh);
if(swpRH || swpPH)
{
double uWickPct = ((high - MathMax(open, close)) / rng) * 100.0;
if(uWickPct >= InpMinWickPct)
{
g_slots[s].shortFired = true;
string pool = (swpRH && swpPH) ? "DUAL(PMH+PDH)" : (swpPH ? "PDH-SWEEP" : "PMH-SWEEP");
string grade = h4Bear ? "A+ [H4 Trend]" : "B [Counter-H4]";
double sl = high + slBuf;
double stop = sl - close;
double tp1 = close - (stop * 1.5);
double tp2 = MathMax(rL, close - (stop * 3.0));
SendScannerAlert(s, "SHORT", pool, grade, close, sl, tp1, tp2, stop);
}
}
}
// --- BULLISH SWEEP ---
if(!g_slots[s].longFired)
{
bool swpRL = (low <= rL - minSwp && close > rL);
bool swpPL = (g_slots[s].pdLow < DBL_MAX && low <= g_slots[s].pdLow - minSwp && close > g_slots[s].pdLow);
if(swpRL || swpPL)
{
double lWickPct = ((MathMin(open, close) - low) / rng) * 100.0;
if(lWickPct >= InpMinWickPct)
{
g_slots[s].longFired = true;
string pool = (swpRL && swpPL) ? "DUAL(PML+PDL)" : (swpPL ? "PDL-SWEEP" : "PML-SWEEP");
string grade = h4Bull ? "A+ [H4 Trend]" : "B [Counter-H4]";
double sl = low - slBuf;
double stop = close - sl;
double tp1 = close + (stop * 1.5);
double tp2 = MathMin(rH, close + (stop * 3.0));
SendScannerAlert(s, "LONG", pool, grade, close, sl, tp1, tp2, stop);
}
}
}
}
RenderMultiAssetHud();
}
//+------------------------------------------------------------------+
//| Format & Dispatch Mobile Alert |
//+------------------------------------------------------------------+
void SendScannerAlert(int idx, string dir, string pool, string grade, double ent, double sl, double tp1, double tp2, double stopPts)
{
string sym = g_slots[idx].name;
int digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
double riskUsd = 0.0;
double lots = CalcSymbolLots(sym, stopPts, riskUsd);
g_slots[idx].lastSignal = StringFormat("%s @ %s (%s)", dir, DoubleToString(ent, digits), pool);
string msg = StringFormat("[%s M15 %s] %s | %s | Lot: %.2f ($%.0f) | Ent: %s | SL: %s | TP1: %s | TP2: %s",
sym, dir, pool, grade, lots, riskUsd,
DoubleToString(ent, digits), DoubleToString(sl, digits),
DoubleToString(tp1, digits), DoubleToString(tp2, digits));
if(InpDesktopAlert) Alert(msg);
if(InpMobilePush) SendNotification(msg);
}
//+------------------------------------------------------------------+
//| Render Unified Multi-Symbol Matrix on Host Chart |
//+------------------------------------------------------------------+
void RenderMultiAssetHud()
{
string hud = "====================================================================================\n";
hud += StringFormat(" MULTI-ASSET M15 QUANT SCANNER | UTC: %s | Equity: $%.0f\n",
TimeToString(TimeGMT(), TIME_SECONDS), AccountInfoDouble(ACCOUNT_EQUITY));
hud += "====================================================================================\n";
hud += "SYMBOL | SESSION STATE | M15 ATR | RANGE HIGH / LOW | LAST SIGNAL\n";
hud += "------------------------------------------------------------------------------------\n";
for(int i = 0; i < g_slotCount; i++)
{
double atrBuf[1];
double atr = (CopyBuffer(g_slots[i].atrHandle, 0, 0, 1, atrBuf) > 0) ? atrBuf[0] : 0.0;
int d = (int)SymbolInfoInteger(g_slots[i].name, SYMBOL_DIGITS);
string rngStr = (g_slots[i].rangeHigh > g_slots[i].rangeLow)
? (DoubleToString(g_slots[i].rangeHigh, d) + " / " + DoubleToString(g_slots[i].rangeLow, d))
: "Waiting Window";
hud += StringFormat("%-10s | %-16s | %-8.2f | %-21s | %s\n",
g_slots[i].name, g_slots[i].statusText, atr, rngStr, g_slots[i].lastSignal);
}
hud += "====================================================================================\n";
Comment(hud);
}
//+------------------------------------------------------------------+