//+------------------------------------------------------------------+ //| PropGuardBridge.mq5 | //| Read-only bridge: reports account equity and deals to the | //| Prop Trader Guard backend. It never opens, changes or closes | //| a trade. | //+------------------------------------------------------------------+ #property copyright "Prop Trader Guard" #property version "0.23" #property description "Read-only bridge. Pairs once with a code from the app, then sends equity, open positions and deals." input string InpBaseUrl = "https://proptraderguard.fly.dev"; // Server address (also add it under Tools > Options > Expert Advisors) input string InpPairingCode = ""; // Pairing code from the app (first run only) input int InpSnapshotMs = 1000; // Snapshot interval while positions are open (ms) input int InpIdleSnapshotSec = 10; // Snapshot interval while flat (seconds) input int InpBackfillHours = 48; // Hours of deal history replayed on start input int InpFirstRunDays = 90; // Days of history sent once, on first pairing input int InpTimeoutMs = 3000; // Request timeout (ms) datetime g_lastSnapshot = 0; datetime g_lastErrorPrint = 0; //+------------------------------------------------------------------+ int OnInit() { if(InpSnapshotMs < 250) { Print("PropGuardBridge: snapshot interval must be 250 ms or more."); return(INIT_PARAMETERS_INCORRECT); } // A token saved by an earlier run is reused, so the pairing code is only // ever needed once per terminal. bool justPaired = false; LoadToken(); if(g_token == "") { if(InpPairingCode == "") { Print("PropGuardBridge: not paired. Open the app, add this account, ", "and put its pairing code in the EA inputs."); return(INIT_PARAMETERS_INCORRECT); } if(!Pair()) return(INIT_FAILED); justPaired = true; } // A terminal that has just been paired has a past the backend knows nothing // about, so the whole of it goes up once. Every later restart replays only // the recent window, which is all that is needed to rebuild today. if(justPaired) SendBackfill(InpFirstRunDays * 24, "first pairing"); else SendBackfill(InpBackfillHours, "restart"); SendSnapshot(); EventSetMillisecondTimer(InpSnapshotMs); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ void OnDeinit(const int reason) { EventKillTimer(); } //+------------------------------------------------------------------+ void OnTick() { // Nothing to do on ticks: the timer drives snapshots so they keep // flowing even when the chart symbol is quiet. } //+------------------------------------------------------------------+ void OnTimer() { bool hasPositions = (PositionsTotal() > 0); bool idleDue = (TimeLocal() - g_lastSnapshot >= InpIdleSnapshotSec); if(hasPositions || idleDue) SendSnapshot(); } //+------------------------------------------------------------------+ //| A new deal was added to history: a position opened, closed or | //| was partly closed, or a balance operation happened. | //+------------------------------------------------------------------+ void OnTradeTransaction(const MqlTradeTransaction &trans, const MqlTradeRequest &request, const MqlTradeResult &result) { if(trans.type != TRADE_TRANSACTION_DEAL_ADD) return; ulong ticket = trans.deal; if(ticket == 0 || g_token == "" || !HistoryDealSelect(ticket)) return; Post("/v1/bridge/deals", "[" + DealJson(ticket, false) + "]"); SendSnapshot(); } //+------------------------------------------------------------------+ //| The bridge token this terminal was given when it paired. Kept in | //| MQL5/Files so the pairing code is only needed once. | //+------------------------------------------------------------------+ string g_token = ""; string TokenFileName() { // One file per account and per server. A token only means something to // the server that issued it, so a terminal moved from a local backend to // the live one must start with no token and pair with the code in its // inputs, instead of being refused once and asking to be reattached. string host = InpBaseUrl; StringReplace(host, "https://", ""); StringReplace(host, "http://", ""); StringReplace(host, ":", "_"); StringReplace(host, "/", "_"); return(StringFormat("PropGuardBridge_%I64d_%s.token", AccountInfoInteger(ACCOUNT_LOGIN), host)); } void LoadToken() { g_token = ""; int handle = FileOpen(TokenFileName(), FILE_READ | FILE_TXT | FILE_ANSI); if(handle == INVALID_HANDLE) return; if(!FileIsEnding(handle)) g_token = FileReadString(handle); FileClose(handle); StringTrimLeft(g_token); StringTrimRight(g_token); if(g_token != "") Print("PropGuardBridge: using the saved bridge token."); } void SaveToken(const string token) { int handle = FileOpen(TokenFileName(), FILE_WRITE | FILE_TXT | FILE_ANSI); if(handle == INVALID_HANDLE) { Print("PropGuardBridge: could not save the bridge token, error ", GetLastError(), ". It will have to pair again next time."); return; } FileWriteString(handle, token); FileClose(handle); } void ForgetToken() { g_token = ""; FileDelete(TokenFileName()); } //+------------------------------------------------------------------+ //| Reads one string value out of a flat JSON reply. Enough for the | //| two fields the pairing reply has; no general parser is needed. | //+------------------------------------------------------------------+ string JsonString(const string json, const string key) { string needle = "\"" + key + "\":\""; int start = StringFind(json, needle); if(start < 0) return(""); start += StringLen(needle); int end = StringFind(json, "\"", start); if(end < 0) return(""); return(StringSubstr(json, start, end - start)); } //+------------------------------------------------------------------+ //| Swaps the pairing code for a bridge token bound to this account. | //+------------------------------------------------------------------+ bool Pair() { string json = "{"; json += "\"code\":\"" + JsonEscape(InpPairingCode) + "\","; json += "\"login\":" + IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN)) + ","; json += "\"server\":\"" + JsonEscape(AccountInfoString(ACCOUNT_SERVER)) + "\""; json += "}"; string reply = ""; if(!Post("/v1/bridge/pair", json, reply)) { Print("PropGuardBridge: pairing failed. Check the code has not expired ", "and that the account number matches the one in the app."); return(false); } string token = JsonString(reply, "bridgeToken"); if(token == "") { Print("PropGuardBridge: the backend did not return a bridge token."); return(false); } g_token = token; SaveToken(token); Print("PropGuardBridge: paired. The pairing code can now be cleared from the inputs."); return(true); } //+------------------------------------------------------------------+ //| Seconds the trade server clock is ahead of GMT, rounded to the | //| nearest 15 minutes to absorb clock jitter. | //+------------------------------------------------------------------+ long ServerGmtOffsetSeconds() { double diff = (double)(TimeTradeServer() - TimeGMT()); return((long)MathRound(diff / 900.0) * 900); } //+------------------------------------------------------------------+ string JsonEscape(string value) { StringReplace(value, "\\", "\\\\"); StringReplace(value, "\"", "\\\""); return(value); } //+------------------------------------------------------------------+ string Num(const double value, const int digits) { return(DoubleToString(value, digits)); } //+------------------------------------------------------------------+ string DealTypeName(const long type) { if(type == DEAL_TYPE_BUY) return("buy"); if(type == DEAL_TYPE_SELL) return("sell"); if(type == DEAL_TYPE_BALANCE) return("balance"); return("other"); } //+------------------------------------------------------------------+ string DealEntryName(const long entry) { if(entry == DEAL_ENTRY_IN) return("in"); if(entry == DEAL_ENTRY_OUT) return("out"); if(entry == DEAL_ENTRY_INOUT) return("inout"); if(entry == DEAL_ENTRY_OUT_BY) return("outby"); return("none"); } //+------------------------------------------------------------------+ //| JSON for one deal. The deal must already be selected in history. | //+------------------------------------------------------------------+ string DealJson(const ulong ticket, const bool backfill) { double net = HistoryDealGetDouble(ticket, DEAL_PROFIT) + HistoryDealGetDouble(ticket, DEAL_SWAP) + HistoryDealGetDouble(ticket, DEAL_COMMISSION) + HistoryDealGetDouble(ticket, DEAL_FEE); // Deal times are in server time. Convert to GMT seconds. long timeMsc = HistoryDealGetInteger(ticket, DEAL_TIME_MSC); double gmtSec = (double)(timeMsc - ServerGmtOffsetSeconds() * 1000) / 1000.0; string json = "{"; json += "\"login\":" + IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN)) + ","; json += "\"ticket\":" + IntegerToString((long)ticket) + ","; json += "\"positionId\":" + IntegerToString(HistoryDealGetInteger(ticket, DEAL_POSITION_ID)) + ","; json += "\"symbol\":\"" + JsonEscape(HistoryDealGetString(ticket, DEAL_SYMBOL)) + "\","; json += "\"type\":\"" + DealTypeName(HistoryDealGetInteger(ticket, DEAL_TYPE)) + "\","; json += "\"entry\":\"" + DealEntryName(HistoryDealGetInteger(ticket, DEAL_ENTRY)) + "\","; json += "\"volume\":" + Num(HistoryDealGetDouble(ticket, DEAL_VOLUME), 2) + ","; json += "\"price\":" + Num(HistoryDealGetDouble(ticket, DEAL_PRICE), 5) + ","; json += "\"net\":" + Num(net, 2) + ","; json += "\"ts\":" + Num(gmtSec, 3) + ","; json += "\"backfill\":" + (backfill ? "true" : "false"); json += "}"; return(json); } //+------------------------------------------------------------------+ //| JSON array of the open positions, for the worst-case maths. | //| | //| Read-only: PositionGetTicket selects a position so the | //| PositionGet* readers below return its values. Nothing here | //| opens, changes or closes anything. | //| | //| TICK_VALUE_LOSS is the tick value for a losing position, which | //| is the right one for risk maths. A stop or target that is not | //| set reads as 0, which is what the backend expects. | //+------------------------------------------------------------------+ string PositionsJson() { string items = ""; int total = PositionsTotal(); for(int i = 0; i < total; i++) { ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; string symbol = PositionGetString(POSITION_SYMBOL); long type = PositionGetInteger(POSITION_TYPE); int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS); if(digits <= 0 || digits > 8) digits = 5; if(StringLen(items) > 0) items += ","; items += "{"; items += "\"ticket\":" + IntegerToString((long)ticket) + ","; items += "\"symbol\":\"" + JsonEscape(symbol) + "\","; items += "\"side\":\"" + (type == POSITION_TYPE_SELL ? "sell" : "buy") + "\","; items += "\"volume\":" + Num(PositionGetDouble(POSITION_VOLUME), 2) + ","; items += "\"openPrice\":" + Num(PositionGetDouble(POSITION_PRICE_OPEN), digits) + ","; items += "\"currentPrice\":" + Num(PositionGetDouble(POSITION_PRICE_CURRENT), digits) + ","; items += "\"sl\":" + Num(PositionGetDouble(POSITION_SL), digits) + ","; items += "\"tp\":" + Num(PositionGetDouble(POSITION_TP), digits) + ","; items += "\"profit\":" + Num(PositionGetDouble(POSITION_PROFIT), 2) + ","; items += "\"tickValue\":" + Num(SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE_LOSS), 8) + ","; items += "\"tickSize\":" + Num(SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE), 8); items += "}"; } return("[" + items + "]"); } //+------------------------------------------------------------------+ //| Replays deals so the backend can work out the balance at the | //| start of the firm's trading day, even after a restart. | //| | //| `hours` is the window: the whole of InpFirstRunDays when this | //| terminal has just been paired, and InpBackfillHours after that. | //| Deals already stored are ignored by the backend, so replaying a | //| wider window than needed costs only the upload. | //+------------------------------------------------------------------+ void SendBackfill(const int hours, const string reason) { if(g_token == "") return; datetime to = TimeCurrent() + 86400; datetime from = TimeCurrent() - (datetime)((long)hours * 3600); if(!HistorySelect(from, to)) { Print("PropGuardBridge: could not load deal history for backfill."); return; } int total = HistoryDealsTotal(); string batch = ""; int count = 0; for(int i = 0; i < total; i++) { ulong ticket = HistoryDealGetTicket(i); if(ticket == 0) continue; if(count > 0) batch += ","; batch += DealJson(ticket, true); count++; if(count == 50) { Post("/v1/bridge/deals", "[" + batch + "]"); batch = ""; count = 0; } } if(count > 0) Post("/v1/bridge/deals", "[" + batch + "]"); PrintFormat("PropGuardBridge: replayed %d deals from the last %d hours (%s).", total, hours, reason); } //+------------------------------------------------------------------+ void SendSnapshot() { if(g_token == "") return; string json = "{"; json += "\"login\":" + IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN)) + ","; json += "\"server\":\"" + JsonEscape(AccountInfoString(ACCOUNT_SERVER)) + "\","; json += "\"currency\":\"" + JsonEscape(AccountInfoString(ACCOUNT_CURRENCY)) + "\","; json += "\"balance\":" + Num(AccountInfoDouble(ACCOUNT_BALANCE), 2) + ","; json += "\"equity\":" + Num(AccountInfoDouble(ACCOUNT_EQUITY), 2) + ","; json += "\"positions\":" + IntegerToString(PositionsTotal()) + ","; json += "\"serverGmtOffset\":" + IntegerToString(ServerGmtOffsetSeconds()) + ","; json += "\"ts\":" + IntegerToString((long)TimeGMT()) + ","; // The open positions themselves, under a new key so the existing // "positions" count keeps working with an older backend. json += "\"openPositions\":" + PositionsJson() + ","; // Contract details for this chart's symbol, used for lot sizing. // TICK_VALUE_LOSS is the tick value for a losing position, which is // the right one for risk maths. json += "\"symbol\":{"; json += "\"name\":\"" + JsonEscape(_Symbol) + "\","; json += "\"tickValue\":" + Num(SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE_LOSS), 8) + ","; json += "\"tickSize\":" + Num(SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE), 8) + ","; json += "\"volumeStep\":" + Num(SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP), 8) + ","; json += "\"volumeMin\":" + Num(SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN), 8) + ","; json += "\"volumeMax\":" + Num(SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX), 8); json += "}}"; Post("/v1/bridge/snapshot", json); g_lastSnapshot = TimeLocal(); } //+------------------------------------------------------------------+ //| POST a JSON body to the backend. Returns true on a 2xx reply. | //+------------------------------------------------------------------+ bool Post(const string path, const string body) { string ignored = ""; return(Post(path, body, ignored)); } bool Post(const string path, const string body, string &replyText) { char data[]; char reply[]; string replyHeaders; // StringToCharArray adds a terminating zero, which must not be sent. int length = StringToCharArray(body, data, 0, WHOLE_ARRAY, CP_UTF8); if(length > 0) ArrayResize(data, length - 1); string headers = "Content-Type: application/json\r\n"; if(g_token != "") headers += "Authorization: Bearer " + g_token + "\r\n"; ResetLastError(); int status = WebRequest("POST", InpBaseUrl + path, headers, InpTimeoutMs, data, reply, replyHeaders); replyText = CharArrayToString(reply, 0, WHOLE_ARRAY, CP_UTF8); if(status >= 200 && status < 300) return(true); // The backend says this terminal is no longer paired: drop the saved token // and tell the trader, rather than retrying with it forever. if(status == 401 && g_token != "") { ForgetToken(); Print("PropGuardBridge: this terminal is no longer paired. Open the app, ", "get a new pairing code for this account, put it in the EA inputs ", "and reattach the EA."); return(false); } // Report problems at most once every 30 seconds to keep the log readable. if(TimeLocal() - g_lastErrorPrint >= 30) { g_lastErrorPrint = TimeLocal(); if(status == -1) { int error = GetLastError(); if(error == 4014) Print("PropGuardBridge: WebRequest is not allowed. Add ", InpBaseUrl, " under Tools > Options > Expert Advisors > Allow WebRequest for listed URL."); else Print("PropGuardBridge: request to ", path, " failed, error ", error, "."); } else Print("PropGuardBridge: backend replied ", status, " to ", path, ": ", replyText); } return(false); } //+------------------------------------------------------------------+