//+------------------------------------------------------------------+
//|                                                ExitCodeGates.mq5 |
//|                                    EXIT CODE  ·  exitcode.trade  |
//+------------------------------------------------------------------+
//  EXIT CODE Gates (Connected EA, v1.11). Site-distributed, NOT for
//  the MQL5 Market.
//
//  WHAT IT DOES
//  - Sends your account snapshot (equity, balance, open positions,
//    closed trades) to your EXIT CODE workstation over HTTPS.
//  - Sends recent chart candles for this chart's symbol and timeframe
//    so the workstation can draw your chart in the browser.
//  - Shows your day loss against a limit you set, plus the server's
//    day identity, day loss figure, note, and lockout state.
//  - Execute relay (v1.10): while linked, the EA asks the server every
//    5 seconds whether YOU pressed Execute on a gate-cleared ticket at
//    exitcode.trade. If you did, the EA places that one market order,
//    with the stop and target from your ticket, sized from your own
//    risk settings, and reports the broker's result back. One order at
//    a time, never the same order twice. The relay is off server-side
//    until it is enabled.
//  - Optional local guardian: set DailyLossLimitPercent above zero to
//    arm it. The limit is a fixed dollar amount: your account size
//    times DailyLossLimitPercent divided by 100. Account size comes
//    from AccountSizeOverride, or, when that is 0, from the balance
//    recorded on first run (persisted like the rest of the state).
//    When your day loss in dollars reaches that amount and
//    CloseAllAtLimit is on, the EA closes all open positions. This
//    check is computed entirely on your terminal from your own
//    inputs. It works even when the server is unreachable, and it is
//    OFF by default.
//
//  WHAT IT NEVER DOES
//  - No trade is ever opened, closed, or modified because of anything
//    the server sends on the sync channel. The sync response is
//    display data only.
//  - The EA never originates a trade, and neither does the server. A
//    relayed order exists only because you yourself clicked Execute on
//    a gate-cleared ticket at exitcode.trade. The relay carries your
//    own click to your terminal, nothing more, on its own channel,
//    separate from the sync response.
//  - No gate rules run in this file. Rule checks live on the server.
//  - The local guardian stays local and off by default. Nothing the
//    server sends can arm it or trigger it.
//
//  LOCAL AUTHORITY BY DESIGN
//  - The guardian's trading day and day-start anchor are computed
//    locally on purpose. No field the server sends can ever feed a
//    trade action, move the guardian's clock, or move its anchor.
//    The server's day, day loss, note, and lockout values are shown
//    on the chart and nothing more.
//
//  SETUP (also in mt5/README.md)
//  1) Tools -> Options -> Expert Advisors -> tick "Allow WebRequest
//     for listed URL" and add:  https://exitcode.trade
//  2) Get a pairing code from your EXIT CODE workstation, paste it
//     into the PairingCode input, attach the EA to any chart.
//  3) After "linked" appears, clear the PairingCode input. The EA
//     stores its key in MQL5\Files\EXITCODE\link_key.txt.
//
//  NOTES
//  - WebRequest returning -1 with error 4014 means either the URL is
//    not whitelisted (the usual cause here) or the call ran in a
//    context where web requests are not allowed. The EA alerts once
//    with the whitelist steps in both cases.
//  - The trading day used by the local guardian is the FTMO day:
//    midnight Europe/Prague, computed locally from GMT with EU
//    daylight saving rules. The server's day value is shown on the
//    chart but never changes the guardian's own clock or anchor.
//  - The Strategy Tester cannot run web requests. In the tester this
//    EA skips all network calls; the local guardian still runs.
//  - Test on a demo account first.
//+------------------------------------------------------------------+
#property copyright "EXIT CODE"
#property link      "https://exitcode.trade"
#property version   "1.11"
#property description "EXIT CODE Gates v1.11: account sync + display + optional local day-loss guardian + Execute relay + per-item readiness flags."
#property description "The server never commands a trade. Relayed orders carry your own Execute click from exitcode.trade. Close at limit is local, from your own inputs, off by default."

#include <Trade\Trade.mqh>

//--- inputs ---------------------------------------------------------
sinput string PairingCode           = "";     // Pairing code from exitcode.trade (clear after first link)
input  int    SyncSeconds           = 5;      // Seconds between syncs (minimum 2)
input  double DailyLossLimitPercent = 0.0;    // Daily loss limit, percent of account size (0 = off)
input  double AccountSizeOverride   = 0.0;    // Firm account size in account currency (0 = record balance on first run)
input  bool   CloseAllAtLimit       = false;  // Close all positions when the daily limit is hit
input  bool   ShowChartComment      = true;   // Show the status block on the chart

//--- constants ------------------------------------------------------
const string EC_URL_LINK   = "https://exitcode.trade/api/mt5/link";
const string EC_URL_SYNC   = "https://exitcode.trade/api/mt5/sync";
const string EC_URL_POLL   = "https://exitcode.trade/api/mt5/orders/poll";
const string EC_URL_ACK    = "https://exitcode.trade/api/mt5/orders/ack";
const string EC_LINK_FILE  = "EXITCODE\\link_key.txt";
const int    EC_HTTP_TIMEOUT_MS = 5000;
const int    EC_MAX_POSITIONS   = 100;   // server cap per sync
const int    EC_MAX_DEALS       = 50;    // server cap per sync
const int    EC_MAX_PENDING     = 400;   // local buffer cap for unsent deals
const int    EC_MAX_DONE_IDS    = 512;   // dedupe memory for sent deals
const int    EC_POLL_SECONDS    = 5;     // order relay poll cadence while linked
const int    EC_CANDLE_SECONDS  = 30;    // candle stream cadence (or on new bar)
const int    EC_CANDLE_BARS     = 120;   // most recent bars per candle payload
const long   EC_ACK_GIVEUP_SECS = 180;   // order record expires server-side at 120 s

//--- pending closed-deal record ------------------------------------
struct PendingDeal
{
   ulong    deal_id;
   long     pos_id;
   string   symbol;
   string   side;          // direction of the position being closed
   double   volume;        // lots
   double   price;
   double   profit;        // DEAL_PROFIT + DEAL_COMMISSION + DEAL_SWAP + DEAL_FEE
   long     time_utc;      // epoch seconds, converted from server clock to UTC
   datetime time_srv;      // raw DEAL_TIME (server clock), for the watermark
};

//--- runtime state --------------------------------------------------
CTrade      g_trade;
bool        g_bootstrapped     = false;
int         g_syncFloor        = 5;      // clamped SyncSeconds
string      g_linkKey          = "";
long        g_login            = 0;

// guardian state (local authority; persisted)
long        g_dayNum           = 0;      // yyyymmdd of the local FTMO (Prague) day
double      g_anchor           = 0.0;    // balance at the last Prague-midnight roll
double      g_accountSize      = 0.0;    // guardian limit base (override or first-run balance)
datetime    g_watermark        = 0;      // last server-clock deal time CONFIRMED synced

// deal buffers
PendingDeal g_pending[];
ulong       g_doneIds[];
int         g_doneCount        = 0;

// sync throttle + backoff
long        g_lastSendGmt      = 0;      // last attempt (GMT secs)
long        g_lastOkGmt        = 0;      // last accepted sync (GMT secs)
double      g_lastSentEquity   = 0.0;
string      g_lastSentFp       = "";
long        g_backoffUntilGmt  = 0;
long        g_backoffSecs      = 0;

// server display state (display only, never guardian input)
string      g_srvDay           = "";
string      g_srvDayLoss       = "";
string      g_srvNote          = "";
bool        g_srvLockout       = false;

// link/pairing state
bool        g_linkRevoked      = false;
bool        g_pairFatal        = false;  // invalid or expired code; stop retrying
long        g_lastPairTryGmt   = 0;

// login-change defence (W4): the login the link key was minted for
long        g_pairedLogin      = 0;      // persisted in the link file next to the key
bool        g_accountMismatch  = false;  // terminal login differs from the paired login
bool        g_alertedMismatch  = false;

// order relay state (W1)
string      g_lastOrderId      = "";     // last order id handled; never re-place (persisted)
bool        g_orderBusy        = false;  // an order is being handled or its ack is unsent
string      g_pendingAckBody   = "";     // ack body not yet accepted by the server
string      g_pendingAckOrderId= "";
long        g_pendingAckFirstGmt = 0;
long        g_lastPollGmt      = 0;
bool        g_relayReady       = false;  // last poll was accepted by the server

// candle stream state (W3)
long        g_lastCandlesGmt   = 0;      // last accepted sync that carried candles
datetime    g_lastBarTime      = 0;      // open time of the last seen chart bar
bool        g_newBarFlag       = false;  // a new bar opened since the last candle send

// one-time alert flags
bool        g_alertedWhitelist = false;
bool        g_alertedRevoked   = false;
bool        g_alertedLinked    = false;
bool        g_alertedPairFail  = false;
bool        g_alertedBreach    = false;  // reset each day
bool        g_alertedNoTrade   = false;  // reset each day
bool        g_alertedStuck     = false;  // close-all could not finish; reset each day
bool        g_breachLatched    = false;  // reset each day

// timer multiplex
int         g_tickCount        = 0;

//+------------------------------------------------------------------+
//| Small helpers                                                    |
//+------------------------------------------------------------------+
string SanitizeStr(string s, const int maxlen)
{
   StringReplace(s, "\\", "");
   StringReplace(s, "\"", "");
   StringReplace(s, "\r", " ");
   StringReplace(s, "\n", " ");
   if(StringLen(s) > maxlen)
      s = StringSubstr(s, 0, maxlen);
   return s;
}

// Flat-JSON scalar reader. Flat objects only, no nesting, no arrays.
string JsonGetRaw(const string json, const string key)
{
   string pat = "\"" + key + "\":";
   int i = StringFind(json, pat);
   if(i < 0) return "";
   i += StringLen(pat);
   while(i < StringLen(json) && StringGetCharacter(json, i) == ' ') i++;
   bool quoted = (i < StringLen(json) && StringGetCharacter(json, i) == '"');
   if(quoted)
   {
      int j = i + 1;
      while(j < StringLen(json))
      {
         ushort c = StringGetCharacter(json, j);
         if(c == '\\') { j += 2; continue; }
         if(c == '"') break;
         j++;
      }
      return StringSubstr(json, i + 1, j - i - 1);
   }
   int j = i;
   while(j < StringLen(json))
   {
      ushort c = StringGetCharacter(json, j);
      if(c == ',' || c == '}' || c == ' ' || c == '\r' || c == '\n') break;
      j++;
   }
   return StringSubstr(json, i, j - i);
}

bool   JsonGetBool  (const string json, const string key) { return JsonGetRaw(json, key) == "true"; }
string JsonGetString(const string json, const string key) { return JsonGetRaw(json, key); }

long ServerGmtOffsetSecs()
{
   // TimeTradeServer keeps advancing off-hours; TimeCurrent goes stale.
   return (long)TimeTradeServer() - (long)TimeGMT();
}

//+------------------------------------------------------------------+
//| Prague (FTMO) day: computed from GMT with EU DST rules.          |
//| EU DST: last Sunday of March 01:00 UTC to last Sunday of         |
//| October 01:00 UTC. Prague is UTC+1 (CET) or UTC+2 (CEST).        |
//+------------------------------------------------------------------+
datetime LastSundayOfMonthUtc(const int year, const int month)
{
   // March and October both have 31 days.
   datetime d = StringToTime(StringFormat("%04d.%02d.31 00:00:00", year, month));
   MqlDateTime s;
   TimeToStruct(d, s);
   return d - (datetime)(s.day_of_week * 86400);
}

bool IsEuDst(const datetime gmt)
{
   MqlDateTime t;
   TimeToStruct(gmt, t);
   datetime dstStart = LastSundayOfMonthUtc(t.year, 3)  + 3600;  // 01:00 UTC
   datetime dstEnd   = LastSundayOfMonthUtc(t.year, 10) + 3600;  // 01:00 UTC
   return (gmt >= dstStart && gmt < dstEnd);
}

long PragueDayNum(const datetime gmt)
{
   int offsetHours = IsEuDst(gmt) ? 2 : 1;
   datetime praha = gmt + (datetime)(offsetHours * 3600);
   MqlDateTime t;
   TimeToStruct(praha, t);
   return (long)t.year * 10000 + (long)t.mon * 100 + (long)t.day;
}

string DayNumToStr(const long dn)
{
   if(dn <= 0) return "";
   return StringFormat("%04d-%02d-%02d", (int)(dn / 10000), (int)((dn / 100) % 100), (int)(dn % 100));
}

//+------------------------------------------------------------------+
//| Persistence: GlobalVariables (doubles) mirrored to a state file. |
//| GlobalVariables expire after 4 weeks idle; the file backstops.   |
//+------------------------------------------------------------------+
string GvName(const string leaf) { return StringFormat("EC_GATES_%I64d_%s", g_login, leaf); }
string StateFileName()           { return StringFormat("EXITCODE\\gates_state_%I64d.txt", g_login); }

void PersistState()
{
   GlobalVariableSet(GvName("DAY"),    (double)g_dayNum);
   GlobalVariableSet(GvName("ANCHOR"), g_anchor);
   GlobalVariableSet(GvName("SIZE"),   g_accountSize);
   GlobalVariableSet(GvName("WMARK"),  (double)(long)g_watermark);
   GlobalVariablesFlush();

   int h = FileOpen(StateFileName(), FILE_WRITE | FILE_TXT | FILE_ANSI, '\t', CP_UTF8);
   if(h != INVALID_HANDLE)
   {
      FileWriteString(h, StringFormat("%I64d;%s;%I64d;%s;%s",
                                      g_dayNum,
                                      DoubleToString(g_anchor, 2),
                                      (long)g_watermark,
                                      DoubleToString(g_accountSize, 2),
                                      g_lastOrderId));
      FileClose(h);
   }
}

void LoadState()
{
   // The last relayed order id is a string, so it lives ONLY in the
   // state file (GlobalVariables hold doubles). Read the file first
   // for it, whatever the GlobalVariables path decides below.
   int h = FileOpen(StateFileName(), FILE_READ | FILE_TXT | FILE_ANSI, '\t', CP_UTF8);
   string parts[];
   int n = 0;
   if(h != INVALID_HANDLE)
   {
      string line = FileReadString(h);
      FileClose(h);
      n = StringSplit(line, ';', parts);
      if(n >= 5)
         g_lastOrderId = SanitizeStr(parts[4], 64);
   }

   if(GlobalVariableCheck(GvName("DAY")) &&
      GlobalVariableCheck(GvName("ANCHOR")) &&
      GlobalVariableCheck(GvName("WMARK")))
   {
      g_dayNum    = (long)GlobalVariableGet(GvName("DAY"));
      g_anchor    = GlobalVariableGet(GvName("ANCHOR"));
      g_watermark = (datetime)(long)GlobalVariableGet(GvName("WMARK"));
      if(GlobalVariableCheck(GvName("SIZE")))   // absent on pre-SIZE installs
         g_accountSize = GlobalVariableGet(GvName("SIZE"));
      return;
   }
   if(n >= 3)   // 3 fields = legacy file without account size
   {
      g_dayNum    = StringToInteger(parts[0]);
      g_anchor    = StringToDouble(parts[1]);
      g_watermark = (datetime)StringToInteger(parts[2]);
      if(n >= 4)
         g_accountSize = StringToDouble(parts[3]);
   }
}

// Link file format since v1.10: "link_key;login". A v1.00 file holds
// the key alone; the current login is adopted as the paired login and
// the file is rewritten (the key was minted on this terminal, and v1
// had no other login to describe).
void LoadLinkKey()
{
   int h = FileOpen(EC_LINK_FILE, FILE_READ | FILE_TXT | FILE_ANSI, '\t', CP_UTF8);
   if(h != INVALID_HANDLE)
   {
      string line = FileReadString(h);
      FileClose(h);
      StringTrimLeft(line);
      StringTrimRight(line);
      string parts[];
      int n = StringSplit(line, ';', parts);
      if(n >= 1) g_linkKey = parts[0];
      if(n >= 2)
         g_pairedLogin = StringToInteger(parts[1]);
      else if(g_linkKey != "")
      {
         g_pairedLogin = g_login;
         SaveLinkKey(g_linkKey);   // upgrade the file to the v1.10 format
      }
   }
   // INVALID_HANDLE here just means "not yet paired". Not an error.
}

void SaveLinkKey(const string key)
{
   int h = FileOpen(EC_LINK_FILE, FILE_WRITE | FILE_TXT | FILE_ANSI, '\t', CP_UTF8);
   if(h != INVALID_HANDLE)
   {
      FileWriteString(h, StringFormat("%s;%I64d", key, g_pairedLogin));
      FileClose(h);
   }
   else
      Print("EXIT CODE Gates: could not write ", EC_LINK_FILE, ", error ", GetLastError());
}

void DeleteLinkKey()
{
   FileDelete(EC_LINK_FILE);
   g_linkKey = "";
}

//+------------------------------------------------------------------+
//| Login-change defence (W4). The link key describes ONE account.   |
//| If the terminal is logged into a different account, stop all     |
//| syncing and polling, alert once, and treat the link as absent.   |
//| The link file is kept: the user may switch back.                 |
//+------------------------------------------------------------------+
void EnterAccountMismatch()
{
   g_accountMismatch  = true;
   g_linkKey          = "";     // in-memory only; the file stays on disk
   g_relayReady       = false;
   g_orderBusy        = false;
   g_pendingAckBody   = "";
   g_pendingAckOrderId = "";
   if(!g_alertedMismatch)
   {
      g_alertedMismatch = true;
      Alert("EXIT CODE Gates: this terminal is logged into a different account than the one it was paired with. Syncing has stopped. Get a new pairing code from exitcode.trade/members/mt5/ to link this account. If you switch back to the paired account, the old link resumes on its own.");
      Print("EXIT CODE Gates: account login differs from the paired login. Sync and order relay stopped until re-pair or switch back.");
   }
}

void CheckAccountMismatch()
{
   if(g_linkKey == "" || g_pairedLogin == 0) return;
   if(AccountInfoInteger(ACCOUNT_LOGIN) == g_pairedLogin) return;
   EnterAccountMismatch();
}

// 401 from the server: the link was revoked. Same handling on every
// authenticated channel (sync, order poll, order ack). A pending ack
// is dropped too: with no credential it can never be delivered, and
// keeping it would freeze the relay display on "order in flight".
void HandleLinkRejected()
{
   g_linkRevoked = true;
   g_relayReady  = false;
   g_orderBusy   = false;
   g_pendingAckBody    = "";
   g_pendingAckOrderId = "";
   DeleteLinkKey();
   if(!g_alertedRevoked)
   {
      g_alertedRevoked = true;
      Alert("EXIT CODE Gates: this terminal's link was removed. Get a new pairing code from exitcode.trade and put it in the PairingCode input to link again.");
      Print("EXIT CODE Gates: link key rejected (401). Re-pair with a fresh code.");
   }
}

//+------------------------------------------------------------------+
//| HTTP: canonical JSON POST (UTF-8, exact byte length, no null).   |
//| Returns the HTTP status, or -1 (transport error, see err).       |
//+------------------------------------------------------------------+
int HttpPostJson(const string url, const string json, string &responseBody, int &err)
{
   responseBody = "";
   err = 0;
   if(MQLInfoInteger(MQL_TESTER))
      return -1;   // WebRequest cannot execute in the Strategy Tester

   string hdr = "Content-Type: application/json\r\n";
   char data[];
   int n = StringToCharArray(json, data, 0, WHOLE_ARRAY, CP_UTF8);
   if(n <= 0) { err = -1; return -1; }
   ArrayResize(data, n - 1);   // strip the terminal 0: array size == body bytes

   char result[];
   string resultHeaders;
   ResetLastError();
   int status = WebRequest("POST", url, hdr, EC_HTTP_TIMEOUT_MS, data, result, resultHeaders);
   if(status == -1)
   {
      err = GetLastError();
      return -1;
   }
   responseBody = CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8);
   return status;
}

void HandleTransportError(const int err)
{
   if(err == 4014)
   {
      if(!g_alertedWhitelist)
      {
         g_alertedWhitelist = true;
         Alert("EXIT CODE Gates: web requests are blocked. Open Tools, then Options, then Expert Advisors, tick Allow WebRequest for listed URL, and add https://exitcode.trade to the list. Then remove and re-attach the EA. The same alert appears if web requests are not allowed in this context.");
         Print("EXIT CODE Gates: WebRequest blocked (4014). Whitelist https://exitcode.trade in Tools, Options, Expert Advisors.");
      }
      return;
   }
   if(err == 5200)
   {
      Print("EXIT CODE Gates: invalid URL (5200). This is a build problem, not a connection problem.");
      return;
   }
   // 5201 connect failed, 5202 timeout, 5203 request failed: normal offline states, back off.
   ApplyBackoff(30);
}

void ApplyBackoff(const long baseSecs)
{
   if(g_backoffSecs <= 0) g_backoffSecs = baseSecs;
   else                   g_backoffSecs = MathMin(g_backoffSecs * 2, 600);
   g_backoffUntilGmt = (long)TimeGMT() + g_backoffSecs;
}

void ClearBackoff()
{
   g_backoffSecs = 0;
   g_backoffUntilGmt = 0;
}

//+------------------------------------------------------------------+
//| Pairing: POST /api/mt5/link once, store the returned link_key.   |
//+------------------------------------------------------------------+
void TryPair()
{
   if(g_linkKey != "" || g_pairFatal) return;
   if(MQLInfoInteger(MQL_TESTER)) return;

   string code = PairingCode;
   StringTrimLeft(code);
   StringTrimRight(code);
   StringToUpper(code);
   if(StringLen(code) != 8) return;   // nothing usable typed in yet

   // Pairing codes use A-Z without O and I, and digits 2-9. Anything
   // else is a mis-copy; catch it here instead of burning a request.
   for(int i = 0; i < 8; i++)
   {
      ushort c = StringGetCharacter(code, i);
      bool okChar = (c >= '2' && c <= '9') ||
                    (c >= 'A' && c <= 'Z' && c != 'O' && c != 'I');
      if(!okChar)
      {
         g_pairFatal = true;
         if(!g_alertedPairFail)
         {
            g_alertedPairFail = true;
            Alert("EXIT CODE Gates: that pairing code has a character that codes never use. Codes never contain 0, O, 1 or I. Re-copy the code from exitcode.trade and update the PairingCode input.");
         }
         return;
      }
   }

   long now = (long)TimeGMT();
   if(now - g_lastPairTryGmt < 30) return;   // retry at most every 30 seconds
   g_lastPairTryGmt = now;

   string body = StringFormat("{\"code\":\"%s\",\"account\":\"%I64d\",\"broker\":\"%s\",\"server\":\"%s\",\"currency\":\"%s\"}",
                              SanitizeStr(code, 8),
                              g_login,
                              SanitizeStr(AccountInfoString(ACCOUNT_COMPANY), 64),
                              SanitizeStr(AccountInfoString(ACCOUNT_SERVER), 64),
                              SanitizeStr(AccountInfoString(ACCOUNT_CURRENCY), 16));

   string resp;
   int err = 0;
   int status = HttpPostJson(EC_URL_LINK, body, resp, err);
   if(status == -1)
   {
      HandleTransportError(err);
      return;
   }
   // Accept a valid grant on ANY status (2026-08-29, found during a live demo-account run):
   // Wine's networking handed back status 1003 on a request the server had completed,
   // so a status==200 check alone threw away a real credential and the retry burned
   // the code. The body is the authority: if it parses as ok with a link_key, it IS
   // the grant, whatever the transport claims. The server side is idempotent since
   // the same day, so an over-eager retry gets the same key back instead of a 404.
   if(JsonGetBool(resp, "ok"))
   {
      string key = JsonGetString(resp, "link_key");
      if(StringLen(key) > 0)
      {
         g_linkKey = key;
         g_linkRevoked = false;
         g_pairedLogin = g_login;        // W4: this key describes THIS login
         g_accountMismatch = false;
         g_alertedMismatch = false;
         SaveLinkKey(key);
         if(!g_alertedLinked)
         {
            g_alertedLinked = true;
            Alert("EXIT CODE Gates is linked to your account. You can clear the PairingCode input now.");
            Print("EXIT CODE Gates: linked. Clear the PairingCode input.");
         }
      }
      return;
   }
   if(status == 400 || status == 404)
   {
      // 400 = malformed code, 404 = unknown or expired. Both are fatal
      // for this code; retrying the same string can never succeed.
      g_pairFatal = true;
      if(!g_alertedPairFail)
      {
         g_alertedPairFail = true;
         if(status == 400)
            Alert("EXIT CODE Gates: the server rejected that pairing code as malformed. Codes never contain 0, O, 1 or I. Re-copy the code from exitcode.trade and update the PairingCode input.");
         else
            Alert("EXIT CODE Gates: that pairing code is invalid or has expired. Codes last 15 minutes. Get a new code from exitcode.trade and update the PairingCode input.");
      }
      return;
   }
   if(status == 429) { ApplyBackoff(60); return; }
   Print("EXIT CODE Gates: pairing failed with HTTP ", status);
}

//+------------------------------------------------------------------+
//| Closed-deal collection (hybrid: transaction event + history      |
//| reconciler with persisted watermark). Watermark only advances    |
//| over deals CONFIRMED accepted by a successful sync.              |
//+------------------------------------------------------------------+
bool IsKnownDeal(const ulong dealId)
{
   for(int i = 0; i < g_doneCount; i++)
      if(g_doneIds[i] == dealId) return true;
   int p = ArraySize(g_pending);
   for(int i = 0; i < p; i++)
      if(g_pending[i].deal_id == dealId) return true;
   return false;
}

void RememberDone(const ulong dealId)
{
   if(ArraySize(g_doneIds) < EC_MAX_DONE_IDS)
      ArrayResize(g_doneIds, ArraySize(g_doneIds) + 1, EC_MAX_DONE_IDS);
   int slot = g_doneCount % EC_MAX_DONE_IDS;
   if(slot < ArraySize(g_doneIds))
      g_doneIds[slot] = dealId;
   g_doneCount++;
}

// Deal must already be in the selected history cache (HistorySelect
// index walk, or HistoryDealSelect from the transaction handler).
void AddPendingDealByTicket(const ulong d)
{
   long dtype = HistoryDealGetInteger(d, DEAL_TYPE);
   if(dtype != DEAL_TYPE_BUY && dtype != DEAL_TYPE_SELL)
      return;   // balance operations, credits etc. never enter the deals array
   long entry = HistoryDealGetInteger(d, DEAL_ENTRY);
   if(entry != DEAL_ENTRY_OUT && entry != DEAL_ENTRY_INOUT && entry != DEAL_ENTRY_OUT_BY)
      return;   // INOUT counts as a close: a netting reversal closes the old exposure
   if(IsKnownDeal(d))
      return;

   int sz = ArraySize(g_pending);
   if(sz >= EC_MAX_PENDING)
   {
      Print("EXIT CODE Gates: pending deal buffer full, dropping oldest unsent deal.");
      for(int i = 1; i < sz; i++) g_pending[i - 1] = g_pending[i];
      sz--;
      ArrayResize(g_pending, sz);
   }
   ArrayResize(g_pending, sz + 1, 64);

   PendingDeal p;
   p.deal_id  = d;
   p.pos_id   = HistoryDealGetInteger(d, DEAL_POSITION_ID);
   p.symbol   = SanitizeStr(HistoryDealGetString(d, DEAL_SYMBOL), 32);
   // A buy position closes with a sell deal. side = direction of the
   // position being closed, matching the positions array semantics.
   p.side     = (dtype == DEAL_TYPE_SELL) ? "buy" : "sell";
   p.volume   = HistoryDealGetDouble(d, DEAL_VOLUME);
   p.price    = HistoryDealGetDouble(d, DEAL_PRICE);
   p.profit   = HistoryDealGetDouble(d, DEAL_PROFIT)
              + HistoryDealGetDouble(d, DEAL_COMMISSION)
              + HistoryDealGetDouble(d, DEAL_SWAP)
              + HistoryDealGetDouble(d, DEAL_FEE);
   p.time_srv = (datetime)HistoryDealGetInteger(d, DEAL_TIME);
   // DEAL_TIME is server-clock epoch seconds. Convert to real UTC so
   // the server's day bucketing (Prague) lands on the right day.
   p.time_utc = (long)p.time_srv - ServerGmtOffsetSecs();
   g_pending[sz] = p;
}

void PollClosedDeals()
{
   datetime from = (g_watermark > 60) ? g_watermark - 60 : 0;   // overlap window; dedupe handles repeats
   if(!HistorySelect(from, TimeCurrent() + 60))
      return;
   int total = HistoryDealsTotal();
   for(int i = 0; i < total; i++)
   {
      ulong d = HistoryDealGetTicket(i);   // returns AND selects; never single-select inside this loop
      if(d == 0) continue;
      AddPendingDealByTicket(d);
   }
}

void OnTradeTransaction(const MqlTradeTransaction &trans,
                        const MqlTradeRequest    &request,
                        const MqlTradeResult     &result)
{
   if(trans.type != TRADE_TRANSACTION_DEAL_ADD) return;
   if(HistoryDealSelect(trans.deal))
      AddPendingDealByTicket(trans.deal);
}

//+------------------------------------------------------------------+
//| Candle stream (W3): the chart symbol and timeframe this EA is    |
//| attached to, most recent EC_CANDLE_BARS bars, sent inside the    |
//| sync payload every EC_CANDLE_SECONDS or when a new bar opens.    |
//| Bar times are server-clock; converted to UTC with the            |
//| TimeTradeServer minus TimeGMT offset, same as deal times.        |
//+------------------------------------------------------------------+
string TfString()
{
   switch(_Period)
   {
      case PERIOD_M1:  return "M1";
      case PERIOD_M2:  return "M2";
      case PERIOD_M3:  return "M3";
      case PERIOD_M4:  return "M4";
      case PERIOD_M5:  return "M5";
      case PERIOD_M6:  return "M6";
      case PERIOD_M10: return "M10";
      case PERIOD_M12: return "M12";
      case PERIOD_M15: return "M15";
      case PERIOD_M20: return "M20";
      case PERIOD_M30: return "M30";
      case PERIOD_H1:  return "H1";
      case PERIOD_H2:  return "H2";
      case PERIOD_H3:  return "H3";
      case PERIOD_H4:  return "H4";
      case PERIOD_H6:  return "H6";
      case PERIOD_H8:  return "H8";
      case PERIOD_H12: return "H12";
      case PERIOD_D1:  return "D1";
      case PERIOD_W1:  return "W1";
      case PERIOD_MN1: return "MN1";
   }
   return "H1";
}

// Cheap new-bar watch: one bar copied per timer tick. The flag stays
// up until a sync that carries candles is ACCEPTED by the server.
void UpdateNewBarFlag()
{
   MqlRates r[];
   if(CopyRates(_Symbol, _Period, 0, 1, r) == 1)
   {
      if(r[0].time != g_lastBarTime)
      {
         if(g_lastBarTime != 0) g_newBarFlag = true;
         g_lastBarTime = r[0].time;
      }
   }
}

bool CandlesDue(const long nowGmt)
{
   return g_newBarFlag || (nowGmt - g_lastCandlesGmt >= EC_CANDLE_SECONDS);
}

// The ONE nested object the server sync contract allows. Everything
// else in the sync JSON stays flat.
string CandlesJson()
{
   MqlRates rates[];
   int n = CopyRates(_Symbol, _Period, 0, EC_CANDLE_BARS, rates);
   if(n <= 0) return "";
   long off = ServerGmtOffsetSecs();
   string bars = "";
   for(int i = 0; i < n; i++)   // CopyRates fills oldest first: bars ascend in time
   {
      string item = StringFormat("[%I64d,%s,%s,%s,%s]",
                                 (long)rates[i].time - off,
                                 DoubleToString(rates[i].open, 8),
                                 DoubleToString(rates[i].high, 8),
                                 DoubleToString(rates[i].low, 8),
                                 DoubleToString(rates[i].close, 8));
      if(i > 0) bars += ",";
      bars += item;
   }
   return StringFormat("\"candles\":{\"symbol\":\"%s\",\"tf\":\"%s\",\"bars\":[%s]}",
                       SanitizeStr(_Symbol, 32), TfString(), bars);
}

//+------------------------------------------------------------------+
//| Sync: build the payload, POST, apply the accepted-only watermark |
//+------------------------------------------------------------------+
string PositionsJson(int &count)
{
   string s = "";
   count = 0;
   for(int i = PositionsTotal() - 1; i >= 0 && count < EC_MAX_POSITIONS; i--)
   {
      ulong tk = PositionGetTicket(i);   // returns AND selects
      if(tk == 0) continue;
      long   id   = PositionGetInteger(POSITION_IDENTIFIER);
      long   type = PositionGetInteger(POSITION_TYPE);
      double lots = PositionGetDouble(POSITION_VOLUME);
      double open = PositionGetDouble(POSITION_PRICE_OPEN);
      double sl   = PositionGetDouble(POSITION_SL);
      double tp   = PositionGetDouble(POSITION_TP);
      double pl   = PositionGetDouble(POSITION_PROFIT) + PositionGetDouble(POSITION_SWAP);
      string sym  = SanitizeStr(PositionGetString(POSITION_SYMBOL), 32);
      string side = (type == POSITION_TYPE_BUY) ? "buy" : "sell";
      string item = StringFormat("{\"pos_id\":%I64d,\"symbol\":\"%s\",\"side\":\"%s\",\"volume\":%s,\"entry\":%s,\"sl\":%s,\"tp\":%s,\"profit\":%s}",
                                 id, sym, side,
                                 DoubleToString(lots, 8),
                                 DoubleToString(open, 8),
                                 DoubleToString(sl, 8),
                                 DoubleToString(tp, 8),
                                 DoubleToString(pl, 2));
      if(count > 0) s += ",";
      s += item;
      count++;
   }
   return s;
}

string PositionsFingerprint()
{
   long   n = 0;
   long   tsum = 0;
   double vsum = 0.0, lsum = 0.0;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      ulong tk = PositionGetTicket(i);
      if(tk == 0) continue;
      tsum += (long)tk;
      vsum += PositionGetDouble(POSITION_VOLUME);
      lsum += PositionGetDouble(POSITION_SL) + PositionGetDouble(POSITION_TP);
      n++;
   }
   return StringFormat("%I64d|%I64d|%s|%s", n, tsum, DoubleToString(vsum, 4), DoubleToString(lsum, 5));
}

void RemoveSentPending(const ulong &sentIds[], const int sentCount)
{
   int sz = ArraySize(g_pending);
   int w = 0;
   for(int i = 0; i < sz; i++)
   {
      bool sent = false;
      for(int j = 0; j < sentCount; j++)
         if(g_pending[i].deal_id == sentIds[j]) { sent = true; break; }
      if(sent)
         RememberDone(g_pending[i].deal_id);
      else
      {
         if(w != i) g_pending[w] = g_pending[i];
         w++;
      }
   }
   ArrayResize(g_pending, w);
}

void TrySync()
{
   if(g_linkKey == "" || g_linkRevoked || g_accountMismatch) return;
   if(MQLInfoInteger(MQL_TESTER)) return;

   long now = (long)TimeGMT();
   if(now < g_backoffUntilGmt) return;
   if(now - g_lastSendGmt < g_syncFloor) return;   // hard floor between posts

   double equity  = AccountInfoDouble(ACCOUNT_EQUITY);
   double balance = AccountInfoDouble(ACCOUNT_BALANCE);
   string fp = PositionsFingerprint();

   bool equityMoved = true;
   if(g_lastSentEquity > 0.0)
      equityMoved = (MathAbs(equity - g_lastSentEquity) >= g_lastSentEquity * 0.0005);   // 0.05%
   bool positionsChanged = (fp != g_lastSentFp);
   bool heartbeatDue     = (now - g_lastSendGmt >= 60);
   bool dealsWaiting     = (ArraySize(g_pending) > 0);
   bool candlesDue       = CandlesDue(now);        // W3: 30 s cadence or new bar
   if(!positionsChanged && !equityMoved && !heartbeatDue && !dealsWaiting && !candlesDue) return;

   int posCount = 0;
   string positions = PositionsJson(posCount);

   // First up-to-50 pending deals; the rest stay buffered for later.
   ulong sentIds[];
   int   sentCount = 0;
   datetime maxSentSrvTime = 0;
   string deals = "";
   int pendCount = ArraySize(g_pending);
   int takeCount = (pendCount < EC_MAX_DEALS) ? pendCount : EC_MAX_DEALS;
   ArrayResize(sentIds, takeCount);
   for(int i = 0; i < takeCount; i++)
   {
      PendingDeal p = g_pending[i];
      // Wire contract (the /api/mt5/sync endpoint header, dist/functions/api/mt5/sync.js): "side" here is the
      // direction of the POSITION being closed, not the closing deal.
      // A buy position closes with a sell deal and is sent as "buy",
      // matching the semantics of the positions array above.
      string item = StringFormat("{\"deal_id\":%I64d,\"pos_id\":%I64d,\"symbol\":\"%s\",\"side\":\"%s\",\"volume\":%s,\"price\":%s,\"profit\":%s,\"time\":%I64d}",
                                 (long)p.deal_id, p.pos_id, p.symbol, p.side,
                                 DoubleToString(p.volume, 8),
                                 DoubleToString(p.price, 8),
                                 DoubleToString(p.profit, 2),
                                 p.time_utc);
      if(i > 0) deals += ",";
      deals += item;
      sentIds[i] = p.deal_id;
      if(p.time_srv > maxSentSrvTime) maxSentSrvTime = p.time_srv;
      sentCount++;
   }

   // day_start_equity carries the local guardian anchor (the balance
   // snapshot at the Prague-midnight roll) so the server can compute
   // its display day_loss from the same number. It is sent only when
   // the anchor is a real positive number; the server also refuses
   // anything that is not finite and positive.
   string dse = "";
   if(g_anchor > 0.0 && MathIsValidNumber(g_anchor))
      dse = StringFormat("\"day_start_equity\":%s,", DoubleToString(g_anchor, 2));
   // W3: candles is the ONE nested object the server contract allows;
   // everything else stays flat.
   string candlesPart = "";
   if(candlesDue)
   {
      string cj = CandlesJson();
      if(cj != "") candlesPart = "," + cj;
   }
   // Readiness flags (v1.11): each precondition CanTradeNow() folds together, reported
   // SEPARATELY so exitcode.trade can name the exact blocker in plain language. The common
   // silent failure is ACCOUNT_TRADE_ALLOWED false, the investor (read only) password:
   // equity and positions sync perfectly while nothing can ever be placed or closed.
   string flags = StringFormat("\"trade_allowed\":%s,\"algo_terminal\":%s,\"algo_expert\":%s,\"expert_allowed\":%s,\"connected\":%s,\"guardian_on\":%s,\"ea_version\":\"1.11\",",
                               AccountInfoInteger(ACCOUNT_TRADE_ALLOWED)  != 0 ? "true" : "false",
                               TerminalInfoInteger(TERMINAL_TRADE_ALLOWED) != 0 ? "true" : "false",
                               MQLInfoInteger(MQL_TRADE_ALLOWED)           != 0 ? "true" : "false",
                               AccountInfoInteger(ACCOUNT_TRADE_EXPERT)   != 0 ? "true" : "false",
                               TerminalInfoInteger(TERMINAL_CONNECTED)    != 0 ? "true" : "false",
                               (DailyLossLimitPercent > 0.0 && CloseAllAtLimit) ? "true" : "false");

   // W4: every sync carries the login it describes.
   string body = StringFormat("{\"link_key\":\"%s\",\"account\":\"%I64d\",%s\"equity\":%s,\"balance\":%s,%s\"positions\":[%s],\"deals\":[%s]%s}",
                              SanitizeStr(g_linkKey, 64),
                              g_login,
                              flags,
                              DoubleToString(equity, 2),
                              DoubleToString(balance, 2),
                              dse,
                              positions,
                              deals,
                              candlesPart);

   g_lastSendGmt = now;
   string resp;
   int err = 0;
   int status = HttpPostJson(EC_URL_SYNC, body, resp, err);

   if(status == -1)
   {
      HandleTransportError(err);
      return;
   }
   // W4: the server says this link belongs to a different account.
   if(status == 409 || StringFind(resp, "account_changed") >= 0)
   {
      EnterAccountMismatch();
      return;
   }
   if(status == 401)
   {
      // Link key unknown or revoked. Forget it and ask for a re-pair
      // instead of hammering the endpoint forever.
      HandleLinkRejected();
      return;
   }
   if(status == 429) { ApplyBackoff(60); return; }
   if(status != 200) { ApplyBackoff(30); return; }
   if(!JsonGetBool(resp, "ok")) { ApplyBackoff(30); return; }

   // Accepted: advance the watermark over the deals that were actually
   // sent in this accepted sync, and only over those.
   ClearBackoff();
   g_lastOkGmt      = (long)TimeGMT();
   g_lastSentEquity = equity;
   g_lastSentFp     = fp;
   if(candlesDue && candlesPart != "")
   {
      // Candles reached the server; the next batch is due in 30 s or
      // on the next bar open, whichever comes first.
      g_lastCandlesGmt = g_lastOkGmt;
      g_newBarFlag     = false;
   }
   if(sentCount > 0)
   {
      RemoveSentPending(sentIds, sentCount);
      if(maxSentSrvTime > g_watermark) g_watermark = maxSentSrvTime;
      PersistState();
   }

   // Display-only fields. Nothing below feeds the guardian.
   g_srvDay     = SanitizeStr(JsonGetString(resp, "day"), 16);
   g_srvDayLoss = SanitizeStr(JsonGetRaw(resp, "day_loss"), 24);
   g_srvLockout = JsonGetBool(resp, "lockout");
   g_srvNote    = SanitizeStr(JsonGetString(resp, "note"), 200);
}

//+------------------------------------------------------------------+
//| Order relay (W1). The EA polls its own dedicated channel every   |
//| EC_POLL_SECONDS while linked. An order exists ONLY because the   |
//| member clicked Execute on a gate-cleared ticket at exitcode.trade|
//| The server relays that click; it never originates a trade and    |
//| neither does this EA. One order at a time; an order id that was  |
//| already handled is never placed again (persisted in the state    |
//| file). Sizing is commodity arithmetic only; no gate logic here.  |
//+------------------------------------------------------------------+
void QueueAck(const string body, const string orderId)
{
   g_pendingAckBody    = body;
   g_pendingAckOrderId = orderId;
   g_pendingAckFirstGmt = (long)TimeGMT();
   g_orderBusy = true;
   TrySendPendingAck();
}

void QueueAckFailure(const string orderId, const string errorCode, const string note)
{
   string body = StringFormat("{\"link_key\":\"%s\",\"order_id\":\"%s\",\"ok\":false,\"error\":\"%s\",\"note\":\"%s\"}",
                              SanitizeStr(g_linkKey, 64),
                              SanitizeStr(orderId, 64),
                              errorCode,
                              SanitizeStr(note, 140));
   Print("EXIT CODE Gates: relay order ", orderId, " was not placed. ", errorCode, ". ", note);
   QueueAck(body, orderId);
}

void QueueAckSuccess(const string orderId, const long positionId, const double fillPrice, const double lots)
{
   string body = StringFormat("{\"link_key\":\"%s\",\"order_id\":\"%s\",\"ok\":true,\"position_id\":%I64d,\"fill_price\":%s,\"lots\":%s}",
                              SanitizeStr(g_linkKey, 64),
                              SanitizeStr(orderId, 64),
                              positionId,
                              DoubleToString(fillPrice, 8),
                              DoubleToString(lots, 8));
   Print("EXIT CODE Gates: relay order ", orderId, " placed. Position ", positionId,
         ", fill ", DoubleToString(fillPrice, 8), ", lots ", DoubleToString(lots, 8), ".");
   QueueAck(body, orderId);
}

void TrySendPendingAck()
{
   if(g_pendingAckBody == "") { g_orderBusy = false; return; }
   if(g_linkKey == "" || g_accountMismatch)
   {
      // No credential to speak with. Drop the ack; the order record
      // expires server-side and the ticket stays cleared for a manual
      // placement.
      g_pendingAckBody = ""; g_pendingAckOrderId = ""; g_orderBusy = false;
      return;
   }
   long now = (long)TimeGMT();
   if(now - g_pendingAckFirstGmt > EC_ACK_GIVEUP_SECS)
   {
      // The order record expires server-side at 120 seconds; past this
      // point the ack would land on nothing. The member sees the
      // ticket still cleared on the site and places by hand.
      Print("EXIT CODE Gates: gave up reporting the result of relay order ", g_pendingAckOrderId,
            ". Check the terminal and the site; the ticket stays cleared there.");
      g_pendingAckBody = ""; g_pendingAckOrderId = ""; g_orderBusy = false;
      return;
   }

   string resp;
   int err = 0;
   int status = HttpPostJson(EC_URL_ACK, g_pendingAckBody, resp, err);
   if(status == -1) { HandleTransportError(err); return; }               // retried on a later tick
   if(status == 409 || StringFind(resp, "account_changed") >= 0) { EnterAccountMismatch(); return; }
   if(status == 401) { HandleLinkRejected(); return; }                   // pending ack dropped in there
   if(status == 404)
   {
      // Unknown or expired order id: the server already let it go.
      g_pendingAckBody = ""; g_pendingAckOrderId = ""; g_orderBusy = false;
      return;
   }
   if(status == 429) { ApplyBackoff(60); return; }
   // Body over transport status (the Wine lesson from pairing): an ok
   // body IS the delivery, whatever status the transport reports.
   if(!JsonGetBool(resp, "ok")) { ApplyBackoff(30); return; }            // retried until the giveup window closes
   // Delivered. The ack is idempotent by order_id server-side.
   g_pendingAckBody = ""; g_pendingAckOrderId = ""; g_orderBusy = false;
}

// Place ONE market order for a relayed ticket. Retcode decides
// success, never the bool return. Price-rejection retcodes are safe
// to retry (nothing filled); TIMEOUT, CONNECTION, and LOCKED are NOT
// retried on an open: the request may have reached the server and a
// re-send could open the trade twice.
void PlaceRelayOrder(const string orderId, const string symbol, const string side,
                     const double lots, const double stop, const double target)
{
   int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   double sl = (stop   > 0.0) ? NormalizeDouble(stop, digits)   : 0.0;
   double tp = (target > 0.0) ? NormalizeDouble(target, digits) : 0.0;

   bool forceFillFallback = false;
   int  fillStep = 0;
   uint rc = 0;
   for(int attempt = 0; attempt < 3; attempt++)
   {
      if(forceFillFallback)
      {
         // 10030 on the previous attempt: walk IOC -> FOK -> RETURN. RETURN is
         // last on purpose: a partial market fill under RETURN leaves the
         // remainder resting as a live order, size beyond the single entry the
         // member asked for. IOC and FOK never leave a remainder.
         if(fillStep == 0)      g_trade.SetTypeFilling(ORDER_FILLING_IOC);
         else if(fillStep == 1) g_trade.SetTypeFilling(ORDER_FILLING_FOK);
         else                   g_trade.SetTypeFilling(ORDER_FILLING_RETURN);
      }
      else if(!g_trade.SetTypeFillingBySymbol(symbol))
      {
         long fm = SymbolInfoInteger(symbol, SYMBOL_FILLING_MODE);
         if((fm & SYMBOL_FILLING_IOC) != 0)      g_trade.SetTypeFilling(ORDER_FILLING_IOC);
         else if((fm & SYMBOL_FILLING_FOK) != 0) g_trade.SetTypeFilling(ORDER_FILLING_FOK);
         else                                    g_trade.SetTypeFilling(ORDER_FILLING_RETURN);
      }

      if(side == "buy") g_trade.Buy(lots, symbol, 0.0, sl, tp);
      else              g_trade.Sell(lots, symbol, 0.0, sl, tp);
      rc = g_trade.ResultRetcode();

      if(rc == TRADE_RETCODE_DONE || rc == TRADE_RETCODE_DONE_PARTIAL)
      {
         double fill = g_trade.ResultPrice();
         // Report what actually FILLED, not what was requested: on a partial
         // fill (DONE_PARTIAL) the requested lots would overstate the ticket.
         double filledLots = g_trade.ResultVolume();
         if(filledLots <= 0.0 || !MathIsValidNumber(filledLots))
            filledLots = lots;
         ulong  deal = g_trade.ResultDeal();
         long   posId = 0;
         if(deal > 0 && HistoryDealSelect(deal))
            posId = HistoryDealGetInteger(deal, DEAL_POSITION_ID);
         if(posId == 0)
            posId = (long)g_trade.ResultOrder();
         QueueAckSuccess(orderId, posId, fill, filledLots);
         return;
      }
      if(rc == TRADE_RETCODE_INVALID_FILL)
      {
         if(forceFillFallback) fillStep++;
         forceFillFallback = true;
         if(fillStep > 2) break;
         continue;
      }
      if(rc == TRADE_RETCODE_REQUOTE || rc == TRADE_RETCODE_PRICE_CHANGED || rc == TRADE_RETCODE_PRICE_OFF)
      {
         Sleep(300);
         continue;
      }
      break;   // anything else: one attempt only, see the note above
   }
   QueueAckFailure(orderId, StringFormat("retcode_%u", rc),
                   "the broker did not confirm the order. Check the terminal before placing it by hand");
}

// One order arrived on the poll channel. Size it with commodity
// arithmetic and place it, or report exactly why not.
void ProcessRelayOrder(const string resp, const string orderId)
{
   g_orderBusy = true;

   // Recorded and persisted BEFORE placing: a restart between this
   // write and the broker result must never place the order twice.
   g_lastOrderId = SanitizeStr(orderId, 64);
   PersistState();

   string symbol = SanitizeStr(JsonGetString(resp, "symbol"), 32);
   string side   = JsonGetString(resp, "side");
   double entry       = StringToDouble(JsonGetRaw(resp, "entry"));
   double stop        = StringToDouble(JsonGetRaw(resp, "stop"));
   double target      = StringToDouble(JsonGetRaw(resp, "target"));
   double riskPct     = StringToDouble(JsonGetRaw(resp, "risk_pct"));
   double accountSize = StringToDouble(JsonGetRaw(resp, "account_size"));

   if(symbol == "" || (side != "buy" && side != "sell"))
   {
      QueueAckFailure(orderId, "bad_order_fields", "the order data could not be read");
      return;
   }
   if(!SymbolSelect(symbol, true))
   {
      QueueAckFailure(orderId, "symbol_not_found", "this broker does not offer " + symbol);
      return;
   }

   double tickSize  = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   double tickValue = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
   double step      = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
   double volMin    = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
   double volMax    = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);
   double stopDist  = MathAbs(entry - stop);
   double riskMoney = riskPct / 100.0 * accountSize;

   if(stopDist <= 0.0 || tickSize <= 0.0 || tickValue <= 0.0 || step <= 0.0 ||
      riskMoney <= 0.0 ||
      !MathIsValidNumber(stopDist) || !MathIsValidNumber(riskMoney) ||
      !MathIsValidNumber(tickSize) || !MathIsValidNumber(tickValue))
   {
      QueueAckFailure(orderId, "needs_manual_sizing", "the position size could not be computed safely on this symbol");
      return;
   }

   // Loss per whole lot if the stop is hit.
   double riskPerLot = stopDist / tickSize * tickValue;
   if(riskPerLot <= 0.0 || !MathIsValidNumber(riskPerLot))
   {
      QueueAckFailure(orderId, "needs_manual_sizing", "the position size could not be computed safely on this symbol");
      return;
   }

   // Floor to the volume step (never round up), then clamp to the
   // broker's min and max.
   double lots = MathFloor(riskMoney / riskPerLot / step + 1e-9) * step;
   lots = NormalizeDouble(lots, 8);
   if(lots < volMin) lots = volMin;
   if(lots > volMax) lots = volMax;

   // If the smallest tradable size risks more than 105 percent of the
   // intended amount, do not place. Mirrors the cTrader governor.
   double realRisk = lots * riskPerLot;
   if(lots <= 0.0 || !MathIsValidNumber(realRisk) || realRisk > riskMoney * 1.05)
   {
      QueueAckFailure(orderId, "needs_manual_sizing", "the smallest allowed size on this symbol risks more than you intended");
      return;
   }

   if(!CanTradeNow())
   {
      QueueAckFailure(orderId, "trading_not_allowed", "AutoTrading is off or the terminal is not connected. Check the terminal");
      return;
   }

   PlaceRelayOrder(orderId, symbol, side, lots, stop, target);
}

void TryOrderPoll()
{
   if(g_linkKey == "" || g_linkRevoked || g_accountMismatch) return;
   if(MQLInfoInteger(MQL_TESTER)) return;

   long now = (long)TimeGMT();
   if(now < g_backoffUntilGmt) return;
   if(now - g_lastPollGmt < EC_POLL_SECONDS) return;
   g_lastPollGmt = now;

   // An unsent ack goes first. The server holds one order in flight
   // per account and cannot move on until it hears the result.
   if(g_pendingAckBody != "")
   {
      TrySendPendingAck();
      return;
   }

   string body = StringFormat("{\"link_key\":\"%s\",\"account\":\"%I64d\"}",
                              SanitizeStr(g_linkKey, 64), g_login);
   string resp;
   int err = 0;
   int status = HttpPostJson(EC_URL_POLL, body, resp, err);
   if(status == -1) { HandleTransportError(err); return; }
   if(status == 409 || StringFind(resp, "account_changed") >= 0) { EnterAccountMismatch(); return; }
   if(status == 401) { HandleLinkRejected(); return; }
   if(status == 429) { ApplyBackoff(60); return; }
   // Body over transport status (the Wine lesson from pairing): an ok
   // body is the server's answer whatever status the transport claims.
   if(!JsonGetBool(resp, "ok"))
   {
      // The relay endpoint is absent, erroring, or disabled server
      // side. Display shows "off (server)"; polling continues.
      g_relayReady = false;
      return;
   }
   g_relayReady = true;

   // The poll response nests the order object; its field names are
   // unique in the response, so the flat scanner reads them directly.
   string orderId = JsonGetString(resp, "order_id");
   if(orderId == "") return;   // no pending order
   if(orderId == g_lastOrderId)
   {
      // Already handled. If the ack was lost to a restart there is
      // nothing truthful left to send: the order expires server-side
      // and the ticket stays cleared for a manual placement.
      return;
   }
   ProcessRelayOrder(resp, orderId);
}

//+------------------------------------------------------------------+
//| Local guardian: day roll, day loss, optional close-all.          |
//| Local authority only. Server data never opens, closes, or        |
//| modifies anything, and never moves this clock or anchor.         |
//+------------------------------------------------------------------+
void RollDayIfNeeded()
{
   long today = PragueDayNum(TimeGMT());
   if(today == g_dayNum && g_anchor > 0.0) return;
   g_dayNum = today;
   // FTMO anchors the daily loss on the balance at Prague midnight;
   // floating profit or loss after that counts against the limit.
   g_anchor = AccountInfoDouble(ACCOUNT_BALANCE);
   g_alertedBreach  = false;
   g_alertedNoTrade = false;
   g_alertedStuck   = false;
   g_breachLatched  = false;
   PersistState();
}

double DayLossDollars()
{
   return g_anchor - AccountInfoDouble(ACCOUNT_EQUITY);   // positive when losing
}

double DayLossPercent()
{
   if(g_anchor <= 0.0 || !MathIsValidNumber(g_anchor)) return 0.0;
   double pct = DayLossDollars() / g_anchor * 100.0;
   if(!MathIsValidNumber(pct)) return 0.0;
   return pct;
}

// The guardian limit in account currency: a fixed dollar amount off
// the account size, matching how prop firms state the daily limit.
double GuardianLimitDollars()
{
   if(DailyLossLimitPercent <= 0.0) return 0.0;
   if(g_accountSize <= 0.0 || !MathIsValidNumber(g_accountSize)) return 0.0;
   return g_accountSize * DailyLossLimitPercent / 100.0;
}

bool CanTradeNow()
{
   return TerminalInfoInteger(TERMINAL_TRADE_ALLOWED) != 0
       && MQLInfoInteger(MQL_TRADE_ALLOWED) != 0
       && AccountInfoInteger(ACCOUNT_TRADE_ALLOWED) != 0
       && AccountInfoInteger(ACCOUNT_TRADE_EXPERT) != 0
       && TerminalInfoInteger(TERMINAL_CONNECTED) != 0;
}

// Close one position with a bounded retry. Success means flat.
bool ClosePositionWithRetry(const ulong ticket)
{
   int delays[3] = {300, 600, 1200};
   bool forceFillFallback = false;
   int  fillStep = 0;
   for(int attempt = 0; attempt < 4; attempt++)
   {
      if(!PositionSelectByTicket(ticket))
         return true;   // already flat: goal achieved
      string sym = PositionGetString(POSITION_SYMBOL);

      if(forceFillFallback)
      {
         // 10030 on the previous attempt: walk RETURN -> IOC -> FOK.
         if(fillStep == 0)      g_trade.SetTypeFilling(ORDER_FILLING_RETURN);
         else if(fillStep == 1) g_trade.SetTypeFilling(ORDER_FILLING_IOC);
         else                   g_trade.SetTypeFilling(ORDER_FILLING_FOK);
      }
      else if(!g_trade.SetTypeFillingBySymbol(sym))
      {
         long fm = SymbolInfoInteger(sym, SYMBOL_FILLING_MODE);
         if((fm & SYMBOL_FILLING_IOC) != 0)      g_trade.SetTypeFilling(ORDER_FILLING_IOC);
         else if((fm & SYMBOL_FILLING_FOK) != 0) g_trade.SetTypeFilling(ORDER_FILLING_FOK);
         else                                    g_trade.SetTypeFilling(ORDER_FILLING_RETURN);
      }

      g_trade.PositionClose(ticket);
      uint rc = g_trade.ResultRetcode();

      if(rc == TRADE_RETCODE_DONE || rc == TRADE_RETCODE_POSITION_CLOSED)
         return true;
      if(rc == TRADE_RETCODE_DONE_PARTIAL)
         continue;   // close the remainder on the next attempt, no sleep
      if(rc == TRADE_RETCODE_INVALID_FILL)
      {
         if(forceFillFallback) fillStep++;
         forceFillFallback = true;
         continue;   // retry immediately with the next filling mode
      }
      if(rc == TRADE_RETCODE_MARKET_CLOSED)
         return false;   // deterministic now; the timer will re-attempt
      if(rc == TRADE_RETCODE_TRADE_DISABLED || rc == TRADE_RETCODE_NO_MONEY ||
         rc == TRADE_RETCODE_FROZEN ||
         rc == TRADE_RETCODE_SERVER_DISABLES_AT || rc == TRADE_RETCODE_CLIENT_DISABLES_AT)
         return false;   // unrecoverable from code; caller alerts
      // Transient: REQUOTE, PRICE_CHANGED, PRICE_OFF, TIMEOUT,
      // TOO_MANY_REQUESTS, CONNECTION, LOCKED (position re-checked at
      // the top of the loop, covering the in-flight double-close race).
      if(attempt < 3)
         Sleep(delays[attempt]);
   }
   return !PositionSelectByTicket(ticket);
}

// One full pass over all positions plus a bounded re-scan.
bool CloseAllPositions()
{
   for(int pass = 0; pass < 3; pass++)
   {
      for(int i = PositionsTotal() - 1; i >= 0; i--)   // downward: indices shift as positions close
      {
         ulong tk = PositionGetTicket(i);
         if(tk == 0) continue;
         ClosePositionWithRetry(tk);
      }
      if(PositionsTotal() == 0)
         return true;
   }
   return (PositionsTotal() == 0);
}

void RunGuardian()
{
   double limitD = GuardianLimitDollars();
   if(limitD <= 0.0) return;
   if(DayLossDollars() < limitD && !g_breachLatched) return;
   g_breachLatched = true;   // stays tripped for the rest of the day

   if(!CloseAllAtLimit)
   {
      if(!g_alertedBreach)
      {
         g_alertedBreach = true;
         Alert(StringFormat("EXIT CODE Gates: your daily loss limit is hit. Day loss %s has reached your limit of %s (%s%% of your account size %s). CloseAllAtLimit is off, so no positions were touched.",
                            DoubleToString(DayLossDollars(), 2),
                            DoubleToString(limitD, 2),
                            DoubleToString(DailyLossLimitPercent, 2),
                            DoubleToString(g_accountSize, 2)));
      }
      return;
   }

   if(PositionsTotal() == 0) return;   // nothing left to protect

   if(!CanTradeNow())
   {
      if(!g_alertedNoTrade)
      {
         g_alertedNoTrade = true;
         Alert("EXIT CODE Gates: your daily loss limit is hit and CloseAllAtLimit is on, but trading is not allowed right now (AutoTrading off, EA algo trading off, or no connection). The EA cannot close positions until that changes. Check the terminal now.");
      }
      return;
   }

   if(!g_alertedBreach)
   {
      g_alertedBreach = true;
      Alert(StringFormat("EXIT CODE Gates: your daily loss limit is hit. Day loss %s reached your limit of %s (%s%% of your account size %s). Closing all open positions now, as you set in CloseAllAtLimit.",
                         DoubleToString(DayLossDollars(), 2),
                         DoubleToString(limitD, 2),
                         DoubleToString(DailyLossLimitPercent, 2),
                         DoubleToString(g_accountSize, 2)));
   }

   bool flat = CloseAllPositions();
   if(!flat && !g_alertedStuck)
   {
      g_alertedStuck = true;
      Alert("EXIT CODE Gates: some positions could not be closed after retries. Check the terminal and close them by hand. The EA keeps retrying while the limit is breached.");
   }
}

//+------------------------------------------------------------------+
//| Chart display                                                    |
//+------------------------------------------------------------------+
void UpdateComment()
{
   if(!ShowChartComment) { Comment(""); return; }

   string linkLine;
   if(g_accountMismatch)                      linkLine = "paused. This terminal is on a different account than the one paired. Get a new code from exitcode.trade/members/mt5/";
   else if(g_linkRevoked)                     linkLine = "link removed. Get a new pairing code from exitcode.trade";
   else if(g_linkKey != "")                   linkLine = "linked";
   else if(g_pairFatal)                       linkLine = "pairing code invalid or expired. Get a new code";
   else if(g_alertedWhitelist)                linkLine = "blocked. Add https://exitcode.trade to the WebRequest list";
   else                                       linkLine = "not paired. Put your pairing code in the EA inputs";

   string relayLine;
   if(g_orderBusy || g_pendingAckBody != "")  relayLine = "Execute relay: order in flight";
   else if(g_linkKey != "" && g_relayReady)   relayLine = "Execute relay: ready";
   else                                       relayLine = "Execute relay: off (server)";

   double equity  = AccountInfoDouble(ACCOUNT_EQUITY);
   double balance = AccountInfoDouble(ACCOUNT_BALANCE);
   double lossD   = DayLossDollars();
   double lossP   = DayLossPercent();

   double limitD = GuardianLimitDollars();
   string limitLine;
   if(limitD <= 0.0)
      limitLine = "Day loss " + DoubleToString(lossD, 2) + " (" + DoubleToString(lossP, 2) + "%) · limit off";
   else
      limitLine = "Day loss " + DoubleToString(lossD, 2) + " · limit " + DoubleToString(limitD, 2) +
                  " (" + DoubleToString(DailyLossLimitPercent, 2) + "% of account size " +
                  DoubleToString(g_accountSize, 2) + ")" + (g_breachLatched ? " · LIMIT HIT" : "");

   string guardLine;
   if(limitD <= 0.0)                     guardLine = "Guardian: off";
   else if(CloseAllAtLimit)              guardLine = "Guardian: closes all positions at your limit (local, works offline)";
   else                                  guardLine = "Guardian: alert only at your limit";

   string srvLine;
   if(g_lastOkGmt > 0)
   {
      long ago = (long)TimeGMT() - g_lastOkGmt;
      srvLine = "Server: synced " + IntegerToString(ago) + "s ago";
      if(g_srvDay != "")     srvLine += " · day " + g_srvDay;
      if(g_srvDayLoss != "") srvLine += " · day loss " + g_srvDayLoss;
      if(g_srvNote != "")    srvLine += "\n" + g_srvNote;
   }
   else
      srvLine = "Server: no sync yet";

   string lockLine = g_srvLockout
      ? "EXIT CODE lockout active: the workstation will not clear new tickets"
      : "Lockout: none";

   string txt =
      "EXIT CODE Gates v1.11 · exitcode.trade\n" +
      "Link: " + linkLine + "\n" +
      "Account " + IntegerToString(g_login) + " · Equity " + DoubleToString(equity, 2) +
      " · Balance " + DoubleToString(balance, 2) + " " + AccountInfoString(ACCOUNT_CURRENCY) + "\n" +
      "Day " + DayNumToStr(g_dayNum) + " (resets at midnight Prague time, the FTMO day) · day start " +
      DoubleToString(g_anchor, 2) + "\n" +
      limitLine + "\n" +
      guardLine + "\n" +
      relayLine + "\n" +
      srvLine + "\n" +
      lockLine;

   Comment(txt);
}

//+------------------------------------------------------------------+
//| Lifecycle                                                        |
//+------------------------------------------------------------------+
int OnInit()
{
   g_syncFloor = (SyncSeconds < 2) ? 2 : SyncSeconds;

   if(!g_bootstrapped)
   {
      g_login = AccountInfoInteger(ACCOUNT_LOGIN);
      g_trade.SetDeviationInPoints(20);
      g_trade.SetAsyncMode(false);
      LoadLinkKey();
      LoadState();
      if(g_dayNum == 0 || g_anchor <= 0.0)
      {
         // Fresh install (or state lost): anchor at the current
         // balance. Mid-day installs anchor mid-day; the next Prague
         // midnight puts it on the real firm schedule.
         g_dayNum = PragueDayNum(TimeGMT());
         g_anchor = AccountInfoDouble(ACCOUNT_BALANCE);
      }
      if(g_watermark == 0)
         g_watermark = TimeCurrent();   // fresh start: only future closes are journaled
      // Account size: the input wins when set; otherwise keep the
      // persisted value; otherwise record the balance on first run.
      if(AccountSizeOverride > 0.0)
         g_accountSize = AccountSizeOverride;
      else if(g_accountSize <= 0.0)
         g_accountSize = AccountInfoDouble(ACCOUNT_BALANCE);
      PersistState();

      // W4: the link file may have been written while a different
      // account was logged in. Never sync or poll for the wrong one.
      CheckAccountMismatch();

      if(g_linkKey == "" && !MQLInfoInteger(MQL_TESTER))
         Print("EXIT CODE Gates: not paired yet. Put your pairing code from exitcode.trade in the PairingCode input. This line is also how you can tell on the MQL5 VPS journal.");

      g_bootstrapped = true;
   }
   else
   {
      // Inputs changed (REASON_PARAMETERS): runtime state is kept.
      // A new PairingCode may retry after an earlier invalid one.
      g_pairFatal = false;
      g_alertedPairFail = false;
      if(AccountSizeOverride > 0.0 && AccountSizeOverride != g_accountSize)
      {
         g_accountSize = AccountSizeOverride;
         PersistState();
      }
   }

   EventSetTimer(1);   // one system timer; all throttling is internal
   return INIT_SUCCEEDED;
}

void OnDeinit(const int reason)
{
   EventKillTimer();
   Comment("");
   PersistState();   // quick, no network calls here
}

void OnTimer()
{
   g_tickCount++;

   CheckAccountMismatch();      // W4: stop everything for a wrong login, first
   RollDayIfNeeded();
   RunGuardian();               // local first, every tick, server not needed
   UpdateNewBarFlag();          // W3: cheap one-bar watch for the candle stream

   if(g_tickCount % 5 == 0)
      PollClosedDeals();        // durable close detection with overlap + dedupe

   if(g_linkKey == "")
      TryPair();
   else
   {
      TrySync();
      TryOrderPoll();           // W1: own 5 second cadence, own throttle var
   }

   UpdateComment();
}
//+------------------------------------------------------------------+
