//+------------------------------------------------------------------+ //| UltimateFleetReporter.mq5 | //| Ultimate Dashboard - Service program (monitor only) | //| | //| Install in an MT5 "Services" slot, one instance per terminal | //| (i.e. per account). Pushes the account plus every open position | //| to the backend every few seconds, and every closed deal as it | //| appears. It NEVER opens, modifies or closes anything. | //| | //| Derived from FleetReporter 2.06 (Steve Report) but independent: | //| its own name, files (ufr_*) and terminal global variables | //| (UFR_*), so both can run side by side in the same terminal. | //| | //| Contract: POST /api/v1/ingest with the user's personal API key | //| (Authorization: Bearer ud_...). The key identifies the user; | //| the terminal reports its own broker and login. | //+------------------------------------------------------------------+ #property copyright "Ultimate Dashboard" #property version "1.00" #property service #define EA_VERSION "1.00" #define LOG_PREFIX "UltimateFleetReporter: " #define DEALS_PER_POST 200 // first connection may send years of history: in chunks //--- inputs ----------------------------------------------------------------- input string InpApiUrl = "https://ultimatedashboard.cloud"; // Backend base URL, no trailing slash input string InpApiKey = ""; // Your personal API key (ud_...), from "Connect MetaTrader" input bool InpEnabled = true; // Master switch: false = report nothing for this account input int InpSnapshotSec = 10; // Seconds between snapshot pushes (5-60) input string InpMagicNames = ""; // "magic:Name" comma-separated, e.g. "915001:Gold One Shot,637488:Gold AI" input int InpDealLookbackDays = 7; // How far back to scan for closed deals on first run / after an outage input int InpHttpTimeoutMs = 5000; // WebRequest timeout input int InpMaeSampleMs = 1000; // How often open positions are sampled for MAE (ms) //--- state ---------------------------------------------------------------- long g_login = 0; string g_gvLastOk = ""; // terminal global variable: time of the last accepted report // Set when the backend answers in a way retrying cannot fix (key revoked or // wrong, user suspended): the service stops and says why in the Journal. bool g_fatal = false; // Set on 402 (subscription expired) and 429 (too many requests): nothing is // sent before this moment, then reporting resumes on its own. datetime g_pauseUntil = 0; string g_cursorFile = ""; string g_outboxFile = ""; ulong g_cursorTicket = 0; // Server time -> true UTC. The terminal's own clock is NOT used: TimeGMT() // only reflects the Windows clock and timezone, and a VPS whose clock is set // to broker time (or whose timezone is wrong) makes it silently wrong by // hours. The backend knows real UTC and returns it in every reply, so the // offset is measured against a trustworthy reference instead of guessed. int g_srvToUtcSec = 0; bool g_haveOffset = false; // Import start, set per account on the dashboard and read back from every // reply. It decides how far into history to scan: "from the beginning" (an // empty string) means all of it, a date means from there. Kept alongside the // cursor so that moving the date earlier forces a rescan instead of needing // the cursor file to be deleted by hand. string g_importFrom = ""; string g_importFromSeen = ""; bool g_haveImport = false; // "" is a real answer, so it needs its own flag long g_mapMagic[]; string g_mapName[]; //--- maximum adverse excursion -------------------------------------------- // MT5 records where a trade ended, never how far against you it travelled on // the way. The only way to know is to watch: these arrays hold, per open // position, the worst POSITION_PROFIT seen so far, sampled far more often // than reports are sent. When a ticket closes, its low moves to the settled // list and is attached to the deal. ulong g_maeTicket[]; // open positions being watched double g_maeLow[]; bool g_maeFull[]; // seen from the moment it opened? ulong g_maeDoneTicket[]; // closed, waiting for their deal to be sent double g_maeDoneLow[]; bool g_maeDoneFull[]; datetime g_startTime = 0; //+------------------------------------------------------------------+ //| Service entry point. Services have no OnInit/OnTimer - the work | //| is driven from this loop. | //+------------------------------------------------------------------+ void OnStart() { g_login = AccountInfoInteger(ACCOUNT_LOGIN); g_cursorFile = StringFormat("ufr_cursor_%I64d.txt", g_login); g_outboxFile = StringFormat("ufr_outbox_%I64d.jsonl", g_login); g_gvLastOk = StringFormat("UFR_LASTOK_%I64d", g_login); if(!InpEnabled) { Print(LOG_PREFIX, "InpEnabled=false -> idle for login ", g_login); return; } if(StringLen(InpApiKey) < 20 || StringFind(InpApiKey, "ud_") != 0) { Print(LOG_PREFIX, "InpApiKey is missing or not an Ultimate key (it starts with ud_). ", "Create one in the dashboard under Connect MetaTrader and load its .set file."); return; } ParseMagicNames(InpMagicNames); g_cursorTicket = LoadCursor(); g_startTime = TimeTradeServer(); int period = MathMin(60, MathMax(5, InpSnapshotSec)); int sampleMs = MathMax(200, InpMaeSampleMs); Print(LOG_PREFIX, EA_VERSION, " up for login ", g_login, " url=", InpApiUrl, " cursor=", (long)g_cursorTicket, " magics mapped=", ArraySize(g_mapMagic), " period=", period, "s"); while(!IsStopped() && !g_fatal) { if(TimeLocal() >= g_pauseUntil) { FlushOutbox(); // resend buffered deals first (idempotent on deal_id) SendSnapshot(); // account + every open position; also learns the UTC offset // Deals carry timestamps that are written once and read for months, so // they wait until the clock offset is known rather than going out wrong. // The snapshot above establishes it on the first successful reply. if(g_haveOffset && !g_fatal && TimeLocal() >= g_pauseUntil) ScanAndSendDeals(); else if(!g_haveOffset && !g_fatal) Print(LOG_PREFIX, "waiting for the backend's clock before sending deals"); } // MAE is sampled while waiting, not once per report: a dip that recovers // between two reports would otherwise never be seen at all. for(int slept = 0; slept < period * 1000 && !IsStopped(); slept += sampleMs) { SampleMae(); Sleep(sampleMs); } } Print(LOG_PREFIX, g_fatal ? "stopped: fix the problem above, then restart the service" : "stopped"); } //================================================================== // SNAPSHOT - account + one object per open position //================================================================== void SendSnapshot() { string acc = "{" + "\"broker\":" + JStr(AccountInfoString(ACCOUNT_COMPANY)) + ",\"login\":" + IntegerToString(g_login) + ",\"currency\":" + JStr(AccountInfoString(ACCOUNT_CURRENCY)) + ",\"balance\":" + DBL(AccountInfoDouble(ACCOUNT_BALANCE)) + ",\"equity\":" + DBL(AccountInfoDouble(ACCOUNT_EQUITY)) + ",\"account_type\":" + AccountTypeJson() + "}"; string arr = ""; int total = PositionsTotal(); for(int i = 0; i < total; i++) { ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; string symbol = PositionGetString(POSITION_SYMBOL); long magic = PositionGetInteger(POSITION_MAGIC); long ptype = PositionGetInteger(POSITION_TYPE); double sl = PositionGetDouble(POSITION_SL); double tp = PositionGetDouble(POSITION_TP); int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS); double floating = PositionGetDouble(POSITION_PROFIT) + PositionGetDouble(POSITION_SWAP); if(StringLen(arr) > 0) arr += ","; arr += "{" + "\"ticket\":" + IntegerToString((long)ticket) + ",\"magic\":" + MagicJson(magic) + ",\"ea_name\":" + NameJson(magic) + ",\"symbol\":" + JStr(symbol) + ",\"side\":" + JStr(ptype == POSITION_TYPE_BUY ? "buy" : "sell") + ",\"lots\":" + DBL(PositionGetDouble(POSITION_VOLUME)) + ",\"open_price\":" + DoubleToString(PositionGetDouble(POSITION_PRICE_OPEN), digits) + ",\"current_price\":" + DoubleToString(PositionGetDouble(POSITION_PRICE_CURRENT), digits) + ",\"sl\":" + PriceOrNull(sl, digits) + ",\"tp\":" + PriceOrNull(tp, digits) + ",\"opened_at\":" + JStr(ToIsoUtc((datetime)PositionGetInteger(POSITION_TIME))) + ",\"swap\":" + DBL(PositionGetDouble(POSITION_SWAP)) + ",\"floating\":" + DBL(floating) + ",\"digits\":" + IntegerToString(digits) + "}"; } string body = "{\"kind\":\"snapshot\",\"sent_at\":" + JStr(NowIsoUtc()) + ",\"account\":" + acc + ",\"positions\":[" + arr + "]}"; string resp; int code = HttpPost(body, resp); if((code < 200 || code >= 300) && !IsHandled(code)) Print(LOG_PREFIX, "snapshot POST failed code=", code, " ", StringSubstr(resp, 0, 200)); } //================================================================== // MAXIMUM ADVERSE EXCURSION //================================================================== //+------------------------------------------------------------------+ //| One pass over the open positions: record the worst unrealised | //| loss each is carrying, and settle any ticket that has gone. | //+------------------------------------------------------------------+ void SampleMae() { int total = PositionsTotal(); bool stillOpen[]; ArrayResize(stillOpen, ArraySize(g_maeTicket)); ArrayInitialize(stillOpen, false); for(int i = 0; i < total; i++) { ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; double profit = PositionGetDouble(POSITION_PROFIT); int at = MaeFind(ticket); if(at < 0) { // first sight of this position; it is only fully observed if it // opened after this service did int n = ArraySize(g_maeTicket); ArrayResize(g_maeTicket, n + 1); g_maeTicket[n] = ticket; ArrayResize(g_maeLow, n + 1); g_maeLow[n] = MathMin(0.0, profit); ArrayResize(g_maeFull, n + 1); g_maeFull[n] = ((datetime)PositionGetInteger(POSITION_TIME) >= g_startTime); ArrayResize(stillOpen, n + 1); stillOpen[n] = true; } else { if(profit < g_maeLow[at]) g_maeLow[at] = profit; stillOpen[at] = true; } } // a ticket that is no longer open has closed: move its low to the settled // list, where DealRowJson will pick it up for(int k = ArraySize(g_maeTicket) - 1; k >= 0; k--) { if(stillOpen[k]) continue; int n = ArraySize(g_maeDoneTicket); ArrayResize(g_maeDoneTicket, n + 1); g_maeDoneTicket[n] = g_maeTicket[k]; ArrayResize(g_maeDoneLow, n + 1); g_maeDoneLow[n] = MathMin(0.0, g_maeLow[k]); ArrayResize(g_maeDoneFull, n + 1); g_maeDoneFull[n] = g_maeFull[k]; MaeRemoveAt(k); } } //+------------------------------------------------------------------+ int MaeFind(const ulong ticket) { for(int i = 0; i < ArraySize(g_maeTicket); i++) if(g_maeTicket[i] == ticket) return i; return -1; } //+------------------------------------------------------------------+ void MaeRemoveAt(const int idx) { int last = ArraySize(g_maeTicket) - 1; if(idx < 0 || last < 0) return; g_maeTicket[idx] = g_maeTicket[last]; g_maeLow[idx] = g_maeLow[last]; g_maeFull[idx] = g_maeFull[last]; ArrayResize(g_maeTicket, last); ArrayResize(g_maeLow, last); ArrayResize(g_maeFull, last); } //+------------------------------------------------------------------+ //| The recorded excursion for a closed position, as JSON. Consumes | //| the entry: a deal is only sent once. Never observed, or only | //| observed part-way, reports null rather than claiming zero. | //+------------------------------------------------------------------+ string MaeJsonFor(const long positionId) { for(int i = 0; i < ArraySize(g_maeDoneTicket); i++) { if((long)g_maeDoneTicket[i] != positionId) continue; string out = g_maeDoneFull[i] ? DBL(MathMin(0.0, g_maeDoneLow[i])) : "null"; int last = ArraySize(g_maeDoneTicket) - 1; g_maeDoneTicket[i] = g_maeDoneTicket[last]; g_maeDoneLow[i] = g_maeDoneLow[last]; g_maeDoneFull[i] = g_maeDoneFull[last]; ArrayResize(g_maeDoneTicket, last); ArrayResize(g_maeDoneLow, last); ArrayResize(g_maeDoneFull, last); return out; } return "null"; // closed before this service started watching } //================================================================== // DEALS - one object per newly closed deal (its own ticket) //================================================================== void ScanAndSendDeals() { datetime now = TimeTradeServer(); datetime from = HistoryScanFrom(); if(!HistorySelect(from, now + 60)) return; // pass 1: collect the closing deals we have not sent yet ulong outTickets[]; long posIds[]; int hcount = HistoryDealsTotal(); for(int i = 0; i < hcount; i++) { ulong t = HistoryDealGetTicket(i); if(t == 0 || t <= g_cursorTicket) continue; long entry = HistoryDealGetInteger(t, DEAL_ENTRY); if(entry != DEAL_ENTRY_OUT && entry != DEAL_ENTRY_INOUT && entry != DEAL_ENTRY_OUT_BY) continue; long dtype = HistoryDealGetInteger(t, DEAL_TYPE); if(dtype != DEAL_TYPE_BUY && dtype != DEAL_TYPE_SELL) continue; int n = ArraySize(outTickets); ArrayResize(outTickets, n + 1); outTickets[n] = t; ArrayResize(posIds, n + 1); posIds[n] = HistoryDealGetInteger(t, DEAL_POSITION_ID); } if(ArraySize(outTickets) == 0) return; // pass 2: for each, resolve the position entry (open price / time / side) string rows[]; ulong maxTicket = g_cursorTicket; for(int k = 0; k < ArraySize(outTickets); k++) { ulong outT = outTickets[k]; string row = DealRowJson(outT, posIds[k]); if(StringLen(row) == 0) continue; int n = ArraySize(rows); ArrayResize(rows, n + 1); rows[n] = row; if(outT > maxTicket) maxTicket = outT; } if(ArraySize(rows) == 0) return; // In chunks, so a first connection with years of history never makes one // huge request. A chunk that fails goes to the outbox with the rest. bool failed = false; for(int from = 0; from < ArraySize(rows); from += DEALS_PER_POST) { string chunk[]; int cnt = MathMin(DEALS_PER_POST, ArraySize(rows) - from); ArrayResize(chunk, cnt); for(int j = 0; j < cnt; j++) chunk[j] = rows[from + j]; string body = DealsBody(chunk); if(failed) { OutboxAppend(body); continue; } string resp; int code = HttpPost(body, resp); if(code < 200 || code >= 300) { // a revoked key is not a network outage: nothing is kept for it if(g_fatal) return; Print(LOG_PREFIX, "deals POST failed code=", code, " -> buffering ", cnt, " deal(s)"); OutboxAppend(body); failed = true; } } // advance the cursor either way: a buffered payload is retried from the // outbox and deal_id makes a duplicate a no-op on the backend. g_cursorTicket = maxTicket; SaveCursor(g_cursorTicket); } //+------------------------------------------------------------------+ //| Build one deal JSON object. Selects the whole position history | //| (this replaces the current HistorySelect - callers must re-do | //| their own selection afterwards; ScanAndSendDeals already has its | //| two passes separated for this reason). | //+------------------------------------------------------------------+ string DealRowJson(const ulong outTicket, const long positionId) { if(!HistorySelectByPosition(positionId)) return ""; double inVol = 0.0, inPriceVol = 0.0; double inComm = 0.0, inSwap = 0.0; // costs booked on the way IN datetime openedAt = 0; long openSideType = -1; long inMagic = 0; // the EA's magic lives on the deal it placed string inSymbol = ""; double outPrice = 0.0, outVol = 0.0, outProfit = 0.0, outComm = 0.0, outSwap = 0.0; long outMagic = 0; string outSymbol = ""; datetime closedAt = 0; int n = HistoryDealsTotal(); for(int i = 0; i < n; i++) { ulong t = HistoryDealGetTicket(i); if(t == 0) continue; long entry = HistoryDealGetInteger(t, DEAL_ENTRY); long dtype = HistoryDealGetInteger(t, DEAL_TYPE); if(entry == DEAL_ENTRY_IN) { double v = HistoryDealGetDouble(t, DEAL_VOLUME); inVol += v; inPriceVol += v * HistoryDealGetDouble(t, DEAL_PRICE); datetime dt = (datetime)HistoryDealGetInteger(t, DEAL_TIME); if(openedAt == 0 || dt < openedAt) openedAt = dt; if(openSideType < 0) openSideType = dtype; // side as opened if(inMagic == 0) inMagic = HistoryDealGetInteger(t, DEAL_MAGIC); if(inSymbol == "") inSymbol = HistoryDealGetString(t, DEAL_SYMBOL); inComm += HistoryDealGetDouble(t, DEAL_COMMISSION); inSwap += HistoryDealGetDouble(t, DEAL_SWAP); } if(t == outTicket) { outPrice = HistoryDealGetDouble(t, DEAL_PRICE); outVol = HistoryDealGetDouble(t, DEAL_VOLUME); outProfit = HistoryDealGetDouble(t, DEAL_PROFIT); outComm = HistoryDealGetDouble(t, DEAL_COMMISSION); outSwap = HistoryDealGetDouble(t, DEAL_SWAP); outMagic = HistoryDealGetInteger(t, DEAL_MAGIC); outSymbol = HistoryDealGetString(t, DEAL_SYMBOL); closedAt = (datetime)HistoryDealGetInteger(t, DEAL_TIME); } } double openPrice = (inVol > 0.0) ? inPriceVol / inVol : outPrice; // side: prefer the opening deal's type; fall back to inverting the close string side; if(openSideType == DEAL_TYPE_BUY) side = "buy"; else if(openSideType == DEAL_TYPE_SELL) side = "sell"; else side = (HistoryDealGetInteger(outTicket, DEAL_TYPE) == DEAL_TYPE_SELL) ? "buy" : "sell"; // The magic identifies the strategy that OPENED the trade, so it is read // from the opening deal. The closing deal carries magic 0 whenever the // position was closed by something other than an EA order of its own -- // an SL/TP trigger, a close-by, the dealer -- and trusting it there would // file a perfectly ordinary EA trade under "Manual". long magic = (inMagic != 0) ? inMagic : outMagic; string symbol = (inSymbol != "") ? inSymbol : outSymbol; int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS); if(digits <= 0) digits = 2; return "{" + "\"deal_id\":" + IntegerToString((long)outTicket) + ",\"magic\":" + MagicJson(magic) + ",\"ea_name\":" + NameJson(magic) + ",\"symbol\":" + JStr(symbol) + ",\"side\":" + JStr(side) + ",\"lots\":" + DBL(outVol) + ",\"open_price\":" + DoubleToString(openPrice, digits) + ",\"close_price\":" + DoubleToString(outPrice, digits) + ",\"profit\":" + DBL(NetProfit(outProfit, outComm, outSwap, outVol, inComm, inSwap, inVol)) + ",\"mae\":" + MaeJsonFor(positionId) + ",\"opened_at\":" + JStr(ToIsoUtc(openedAt)) + ",\"closed_at\":" + JStr(ToIsoUtc(closedAt)) + "}"; } //+------------------------------------------------------------------+ //| What the trade actually put in the account. | //| | //| On a raw-spread account the commission is charged when the | //| position OPENS, so a figure built from the closing deal alone | //| silently omits it -- on these accounts, all of it. The entry | //| costs are therefore folded in, shared across the closing deals by | //| volume so that a position closed in parts is not charged the | //| entry commission once per part. | //+------------------------------------------------------------------+ double NetProfit(const double outProfit, const double outComm, const double outSwap, const double outVol, const double inComm, const double inSwap, const double inVol) { double share = (inVol > 0.0) ? (outVol / inVol) : 1.0; if(share > 1.0) share = 1.0; return outProfit + outComm + outSwap + (inComm + inSwap) * share; } //+------------------------------------------------------------------+ string DealsBody(const string &rows[]) { string acc = "{\"broker\":" + JStr(AccountInfoString(ACCOUNT_COMPANY)) + ",\"login\":" + IntegerToString(g_login) + ",\"account_type\":" + AccountTypeJson() + "}"; string arr = ""; for(int i = 0; i < ArraySize(rows); i++) { if(i > 0) arr += ","; arr += rows[i]; } return "{\"kind\":\"deals\",\"sent_at\":" + JStr(NowIsoUtc()) + ",\"account\":" + acc + ",\"deals\":[" + arr + "]}"; } //================================================================== // OUTBOX (failed "deals" payloads, one JSON per line) //================================================================== void OutboxAppend(const string line) { int h = FileOpen(g_outboxFile, FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI); if(h == INVALID_HANDLE) { Print(LOG_PREFIX, "cannot open outbox ", g_outboxFile); return; } FileSeek(h, 0, SEEK_END); FileWriteString(h, line + "\n"); FileClose(h); } //+------------------------------------------------------------------+ void FlushOutbox() { if(!FileIsExist(g_outboxFile)) return; string pending[]; int h = FileOpen(g_outboxFile, FILE_READ | FILE_TXT | FILE_ANSI); if(h == INVALID_HANDLE) return; while(!FileIsEnding(h)) { string s = FileReadString(h); if(StringLen(s) > 2) { int n = ArraySize(pending); ArrayResize(pending, n + 1); pending[n] = s; } } FileClose(h); int sent = 0; for(; sent < ArraySize(pending); sent++) { string resp; int code = HttpPost(pending[sent], resp); if(code < 200 || code >= 300) break; // still offline / rejected - keep this line and the rest } if(sent >= ArraySize(pending)) { FileDelete(g_outboxFile); if(sent > 0) Print(LOG_PREFIX, "outbox flushed (", sent, " payload(s))"); return; } if(sent == 0) return; int w = FileOpen(g_outboxFile, FILE_WRITE | FILE_TXT | FILE_ANSI); if(w == INVALID_HANDLE) return; for(int i = sent; i < ArraySize(pending); i++) FileWriteString(w, pending[i] + "\n"); FileClose(w); Print(LOG_PREFIX, "outbox partially flushed (", sent, " sent, ", ArraySize(pending) - sent, " left)"); } //================================================================== // HTTP //================================================================== int HttpPost(const string body, string &response) { string url = InpApiUrl + "/api/v1/ingest"; string headers = "Content-Type: application/json\r\n" "Authorization: Bearer " + InpApiKey + "\r\n"; char post[], result[]; string result_headers; int len = StringToCharArray(body, post, 0, WHOLE_ARRAY, CP_UTF8) - 1; // drop trailing '\0' if(len < 0) len = 0; ArrayResize(post, len); ResetLastError(); int code = WebRequest("POST", url, headers, InpHttpTimeoutMs, post, result, result_headers); if(code == -1) { int err = GetLastError(); if(err == 4014) Print(LOG_PREFIX, "URL not allowed. Add ", InpApiUrl, " under Tools > Options > Expert Advisors > Allow WebRequest for listed URL."); else Print(LOG_PREFIX, "WebRequest error ", err, " (backend unreachable?)"); response = ""; return 0; // caller treats as failure } response = CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8); if(code >= 200 && code < 300) { LearnUtcOffset(response); // every accepted reply re-measures the clock LearnImportFrom(response); // ... and picks up the import start GlobalVariableSet(g_gvLastOk, (double)TimeLocal()); } else HandleRefusal(code, response, result_headers); return code; } //+------------------------------------------------------------------+ //| What the backend's refusals mean for the service: | //| 401 key missing, wrong or revoked -> stop | //| 403 user suspended -> stop | //| 402 trial/subscription expired -> retry every hour, so a | //| renewal restarts reporting with nothing to do on the MT5 | //| 429 too many requests -> wait what Retry-After says| //+------------------------------------------------------------------+ void HandleRefusal(const int code, const string response, const string headers) { if(code == 401 || code == 403) { g_fatal = true; Print(LOG_PREFIX, code == 401 ? "the backend refused the API key (missing, wrong or revoked). Create a new key in the dashboard and load its .set file." : "access refused for this user (suspended). Contact the dashboard administrator.", " reply: ", StringSubstr(response, 0, 200)); return; } if(code == 402) { g_pauseUntil = TimeLocal() + 3600; Print(LOG_PREFIX, "trial or subscription expired: retrying in one hour. Reply: ", StringSubstr(response, 0, 200)); return; } if(code == 429) { int wait = RetryAfter(headers); g_pauseUntil = TimeLocal() + wait; Print(LOG_PREFIX, "too many requests: pausing ", wait, "s"); } } //+------------------------------------------------------------------+ bool IsHandled(const int code) { return code == 401 || code == 402 || code == 403 || code == 429; } //+------------------------------------------------------------------+ //| Seconds from a "Retry-After: N" response header (default 10). | //+------------------------------------------------------------------+ int RetryAfter(const string headers) { string lower = headers; StringToLower(lower); int at = StringFind(lower, "retry-after:"); if(at < 0) return 10; int end = StringFind(lower, "\n", at); string v = StringSubstr(lower, at + 12, end < 0 ? -1 : end - at - 12); StringTrimLeft(v); StringTrimRight(v); int secs = (int)StringToInteger(v); return (secs <= 0) ? 10 : MathMin(secs, 3600); } //================================================================== // HELPERS //================================================================== string NowIsoUtc() { return ToIsoUtc(TimeTradeServer()); } //+------------------------------------------------------------------+ //| Broker-server timestamp -> ISO8601 UTC. | //| | //| Every timestamp MT5 hands us (DEAL_TIME, POSITION_TIME, | //| TimeTradeServer) is broker-server time. The offset to real UTC | //| comes from the backend, never from TimeGMT(): that only reflects | //| the Windows clock and timezone, and a VPS whose clock is set to | //| broker time - or whose timezone is simply wrong - makes it | //| silently hours off, with nothing on screen to show it. | //+------------------------------------------------------------------+ string ToIsoUtc(datetime serverTime) { if(serverTime == 0) return ""; datetime utc = serverTime + g_srvToUtcSec; MqlDateTime s; TimeToStruct(utc, s); return StringFormat("%04d-%02d-%02dT%02d:%02d:%02dZ", s.year, s.mon, s.day, s.hour, s.min, s.sec); } //+------------------------------------------------------------------+ //| Read "server_time":"YYYY-MM-DDTHH:MM:SSZ" out of a reply and | //| re-measure the server->UTC offset. Runs after every accepted | //| POST, so a broker DST change or a clock correction is picked up | //| within one cycle instead of needing a restart. | //+------------------------------------------------------------------+ void LearnUtcOffset(const string response) { string key = "\"server_time\":\""; int at = StringFind(response, key); if(at < 0) return; at += StringLen(key); int end = StringFind(response, "\"", at); if(end <= at) return; string iso = StringSubstr(response, at, end - at); // 2026-09-03T16:19:36Z if(StringLen(iso) < 19) return; // StringToTime expects "YYYY.MM.DD HH:MM:SS" string norm = StringSubstr(iso, 0, 19); StringReplace(norm, "-", "."); StringReplace(norm, "T", " "); datetime utcNow = StringToTime(norm); if(utcNow <= 0) return; int offset = (int)(utcNow - TimeTradeServer()); if(!g_haveOffset || MathAbs(offset - g_srvToUtcSec) >= 60) Print(LOG_PREFIX, "server->UTC offset ", offset, "s (", DoubleToString(offset / 3600.0, 2), "h), backend time ", iso); g_srvToUtcSec = offset; g_haveOffset = true; } //+------------------------------------------------------------------+ //| Read "import_from":"YYYY-MM-DD" (possibly empty) from a reply. | //| Moving the start EARLIER means history the EA already walked past | //| has to be walked again, so the cursor is reset: without that the | //| older tickets would be skipped forever as "already sent". | //+------------------------------------------------------------------+ void LearnImportFrom(const string response) { string key = "\"import_from\":\""; int at = StringFind(response, key); if(at < 0) return; at += StringLen(key); int end = StringFind(response, "\"", at); if(end < at) return; string value = StringSubstr(response, at, end - at); if(g_haveImport && value == g_importFromSeen) return; bool first = !g_haveImport; g_haveImport = true; if(first) { // first answer of the session: adopt it, and only rescan if it reaches // further back than what the cursor was built from bool wider = (StringLen(value) == 0 && StringLen(g_importFromSeen) > 0) || (StringLen(value) > 0 && StringLen(g_importFromSeen) > 0 && value < g_importFromSeen); Print(LOG_PREFIX, "import start is ", StringLen(value) == 0 ? "the beginning of history" : value); g_importFrom = value; if(wider) { g_cursorTicket = 0; } g_importFromSeen = value; SaveCursor(g_cursorTicket); return; } // "" (from the beginning) reaches further back than any date bool widened = (StringLen(value) == 0 && StringLen(g_importFromSeen) > 0) || (StringLen(value) > 0 && StringLen(g_importFromSeen) > 0 && value < g_importFromSeen); Print(LOG_PREFIX, "import start is now ", StringLen(value) == 0 ? "the beginning of history" : value, widened ? " -- rescanning from there" : ""); g_importFrom = value; g_importFromSeen = value; if(widened) { g_cursorTicket = 0; SaveCursor(0); } else SaveCursor(g_cursorTicket); // persist the new start alongside the cursor } //+------------------------------------------------------------------+ //| Where the history scan starts, in server time. | //+------------------------------------------------------------------+ datetime HistoryScanFrom() { datetime now = TimeTradeServer(); if(StringLen(g_importFrom) >= 10) { string norm = g_importFrom + " 00:00:00"; StringReplace(norm, "-", "."); datetime utc = StringToTime(norm); // the date is UTC; shift it into server time, then a day of slack so a // trade closed just after midnight is never missed at the boundary if(utc > 0) return utc - g_srvToUtcSec - 86400; } if(g_haveImport) return 0; // "from the beginning": 0 is the epoch to MT5 // no answer yet: fall back to the input rather than sweeping all history return now - (datetime)InpDealLookbackDays * 86400; } //+------------------------------------------------------------------+ string JStr(const string v) { string r = v; StringReplace(r, "\\", "\\\\"); StringReplace(r, "\"", "\\\""); StringReplace(r, "\r", " "); StringReplace(r, "\n", " "); StringReplace(r, "\t", " "); return "\"" + r + "\""; } //+------------------------------------------------------------------+ string DBL(const double v) { return DoubleToString(v, 2); } //+------------------------------------------------------------------+ //| Demo or real, straight from the terminal. The dashboard keeps the | //| two apart, so this is never guessed from the login or the broker: | //| MT5 states it, and it is reported on every push. | //+------------------------------------------------------------------+ string AccountTypeJson() { ENUM_ACCOUNT_TRADE_MODE mode = (ENUM_ACCOUNT_TRADE_MODE)AccountInfoInteger(ACCOUNT_TRADE_MODE); if(mode == ACCOUNT_TRADE_MODE_DEMO) return JStr("demo"); if(mode == ACCOUNT_TRADE_MODE_CONTEST) return JStr("contest"); return JStr("real"); } //+------------------------------------------------------------------+ //| null for a magic of 0 (manual), the number otherwise. | //+------------------------------------------------------------------+ string MagicJson(const long magic) { return (magic == 0) ? "null" : IntegerToString(magic); } //+------------------------------------------------------------------+ //| "name" from the magic map, or null (manual / unmapped). | //+------------------------------------------------------------------+ string NameJson(const long magic) { if(magic == 0) return "null"; for(int i = 0; i < ArraySize(g_mapMagic); i++) if(g_mapMagic[i] == magic) return JStr(g_mapName[i]); return "null"; } //+------------------------------------------------------------------+ //| null when the position has no SL/TP (price 0), the number else. | //+------------------------------------------------------------------+ string PriceOrNull(const double price, const int digits) { return (price == 0.0) ? "null" : DoubleToString(price, digits); } //+------------------------------------------------------------------+ void ParseMagicNames(const string spec) { ArrayResize(g_mapMagic, 0); ArrayResize(g_mapName, 0); string items[]; int c = StringSplit(spec, ',', items); for(int i = 0; i < c; i++) { string it = items[i]; StringTrimLeft(it); StringTrimRight(it); if(StringLen(it) == 0) continue; int colon = StringFind(it, ":"); if(colon <= 0) continue; long m = (long)StringToInteger(StringSubstr(it, 0, colon)); string nm = StringSubstr(it, colon + 1); StringTrimLeft(nm); StringTrimRight(nm); int n = ArraySize(g_mapMagic); ArrayResize(g_mapMagic, n + 1); g_mapMagic[n] = m; ArrayResize(g_mapName, n + 1); g_mapName[n] = nm; } } //+------------------------------------------------------------------+ ulong LoadCursor() { if(!FileIsExist(g_cursorFile)) return 0; int h = FileOpen(g_cursorFile, FILE_READ | FILE_TXT | FILE_ANSI); if(h == INVALID_HANDLE) return 0; string s = FileReadString(h); FileClose(h); // "|" since v2.04; a bare ticket is the older format int bar = StringFind(s, "|"); if(bar >= 0) { g_importFromSeen = StringSubstr(s, bar + 1); s = StringSubstr(s, 0, bar); } return (ulong)StringToInteger(s); } //+------------------------------------------------------------------+ void SaveCursor(const ulong v) { int h = FileOpen(g_cursorFile, FILE_WRITE | FILE_TXT | FILE_ANSI); if(h == INVALID_HANDLE) return; FileWriteString(h, IntegerToString((long)v) + "|" + g_importFromSeen); FileClose(h); } //+------------------------------------------------------------------+