//+------------------------------------------------------------------+ //| SetupBookSync.mq5 | //| SetupBook trading journal, by ALAZ | //| https://setupbook.io | //+------------------------------------------------------------------+ // Sends this account's trade history to your SetupBook journal and // keeps it in sync while the terminal runs. Read-only: it never // places, changes or closes an order. Your password never leaves // the terminal; the EA identifies itself with the sync token you // copy from SetupBook > Settings > Connections. // // Install (3 steps): // 1. Put this file in /MQL5/Experts and compile it in // MetaEditor (F7), or drop the .ex5 there. // 2. Tools > Options > Expert Advisors: tick "Allow WebRequest for // listed URL" and add https://setupbook.io // 3. Drag SetupBookSync onto any chart, paste the token, press OK. // The chart's top-left corner shows what it is doing. // // Source: https://setupbook.io/ea/SetupBookSync.mq5 //+------------------------------------------------------------------+ #property copyright "ALAZ" #property link "https://setupbook.io" #property version "1.00" #property description "Syncs your deals to SetupBook (read-only)." input string InpToken = ""; // SetupBook sync token input string InpEndpoint = "https://setupbook.io/api/sync/mt"; // Sync endpoint input int InpIntervalSec = 60; // Check every (seconds) input int InpPageSize = 300; // Deals per request input bool InpVerbose = false; // Log every request #define SB_EA_VERSION "SetupBookSync/1.00 MT5" #define SB_TIMER_SEC 5 #define SB_OVERLAP_MS 1000 long g_cursorMs = -1; // newest deal time (server ms) the server holds; -1 = unknown bool g_dirty = true; // something new to send datetime g_lastPush = 0; datetime g_lastOk = 0; int g_failures = 0; long g_sentDeals = 0; string g_status = "starting"; string g_sentSymbols = ";"; // ";EURUSD;XAUUSD;" symbols whose contract size was sent int g_lastDealsTotal = -1; int g_lastOpenTotal = -1; //+------------------------------------------------------------------+ int OnInit() { if(StringLen(InpToken) < 20 || StringFind(InpToken, "sbk_") != 0) { SetStatus("paste the sync token from SetupBook > Settings > Connections"); Print("SetupBookSync: no token set."); return(INIT_PARAMETERS_INCORRECT); } if(StringFind(InpEndpoint, "https://") != 0) { SetStatus("the endpoint must start with https://"); return(INIT_PARAMETERS_INCORRECT); } if(MQLInfoInteger(MQL_TESTER)) { SetStatus("WebRequest is not available in the Strategy Tester"); return(INIT_SUCCEEDED); } EventSetTimer(SB_TIMER_SEC); g_dirty = true; SetStatus("waiting for the first sync"); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ void OnDeinit(const int reason) { EventKillTimer(); Comment(""); } //+------------------------------------------------------------------+ void OnTick() {} //+------------------------------------------------------------------+ //| A new deal or a changed position: sync on the next timer tick. | //+------------------------------------------------------------------+ void OnTradeTransaction(const MqlTradeTransaction &trans, const MqlTradeRequest &request, const MqlTradeResult &result) { if(trans.type == TRADE_TRANSACTION_DEAL_ADD || trans.type == TRADE_TRANSACTION_POSITION) g_dirty = true; } //+------------------------------------------------------------------+ void OnTimer() { if(MQLInfoInteger(MQL_TESTER)) return; datetime now = TimeCurrent(); // A closed or opened position the transaction event missed (terminal // started with the EA already attached, connection drop) still shows // up as a changed count. if(HistorySelect(0, now + 7 * 86400)) { int deals = HistoryDealsTotal(); int open = PositionsTotal(); if(deals != g_lastDealsTotal || open != g_lastOpenTotal) { g_dirty = true; g_lastDealsTotal = deals; g_lastOpenTotal = open; } } // Back off after failures: 1, 2, 3 … up to 10 intervals. int wait = MathMax(InpIntervalSec, 10) * MathMin(MathMax(g_failures, 1), 10); if(g_failures > 0 && now - g_lastPush < wait) return; if(!g_dirty && now - g_lastPush < MathMax(InpIntervalSec, 10)) return; Sync(); } //+------------------------------------------------------------------+ //| One sync run: hello (gets the cursor), then pages of deals. | //+------------------------------------------------------------------+ void Sync() { g_lastPush = TimeCurrent(); if(g_cursorMs < 0) { // First contact: account details, open positions, no deals. The // answer carries the cursor of what the server already holds. if(!Push("", 0, true, true)) return; } int pages = 0; while(pages < 200) { int count = 0; string deals = CollectDeals(g_cursorMs, count); bool last = count < InpPageSize; if(count == 0) { // Nothing new in the history; still refresh the open positions. if(pages == 0) Push("", 0, false, true); break; } if(!Push(deals, count, pages == 0, true)) return; pages++; g_sentDeals += count; if(last) break; } g_dirty = false; g_failures = 0; g_lastOk = TimeCurrent(); SetStatus("in sync"); } //+------------------------------------------------------------------+ //| Deals newer than the cursor (with a one-second overlap), oldest | //| first, at most InpPageSize. Balance operations are included so | //| the journal can take the first deposit as the starting balance. | //+------------------------------------------------------------------+ string CollectDeals(long cursorMs, int &count) { count = 0; if(!HistorySelect(0, TimeCurrent() + 7 * 86400)) return ""; string out = ""; int total = HistoryDealsTotal(); long from = cursorMs < 0 ? 0 : cursorMs - SB_OVERLAP_MS; for(int i = 0; i < total && count < InpPageSize; i++) { ulong ticket = HistoryDealGetTicket(i); if(ticket == 0) continue; long msc = HistoryDealGetInteger(ticket, DEAL_TIME_MSC); if(msc < from) continue; long type = HistoryDealGetInteger(ticket, DEAL_TYPE); // Buy, sell and balance deals; credit, charges, corrections and the rest are not trades. if(type != DEAL_TYPE_BUY && type != DEAL_TYPE_SELL && type != DEAL_TYPE_BALANCE) continue; string symbol = HistoryDealGetString(ticket, DEAL_SYMBOL); if(count > 0) out += ","; out += "{\"ticket\":" + (string)ticket + ",\"order\":" + (string)HistoryDealGetInteger(ticket, DEAL_ORDER) + ",\"position\":" + (string)HistoryDealGetInteger(ticket, DEAL_POSITION_ID) + ",\"time\":" + (string)(long)HistoryDealGetInteger(ticket, DEAL_TIME) + ",\"msc\":" + (string)msc + ",\"type\":" + (string)type + ",\"entry\":" + (string)HistoryDealGetInteger(ticket, DEAL_ENTRY) + ",\"reason\":" + (string)HistoryDealGetInteger(ticket, DEAL_REASON) + ",\"symbol\":\"" + JsonEscape(symbol) + "\"" + ",\"volume\":" + Num(HistoryDealGetDouble(ticket, DEAL_VOLUME)) + ",\"price\":" + Num(HistoryDealGetDouble(ticket, DEAL_PRICE)) + ",\"commission\":" + Num(HistoryDealGetDouble(ticket, DEAL_COMMISSION)) + ",\"swap\":" + Num(HistoryDealGetDouble(ticket, DEAL_SWAP)) + ",\"fee\":" + Num(HistoryDealGetDouble(ticket, DEAL_FEE)) + ",\"profit\":" + Num(HistoryDealGetDouble(ticket, DEAL_PROFIT)) + ",\"sl\":" + Num(HistoryDealGetDouble(ticket, DEAL_SL)) + ",\"tp\":" + Num(HistoryDealGetDouble(ticket, DEAL_TP)) + ",\"comment\":\"" + JsonEscape(HistoryDealGetString(ticket, DEAL_COMMENT)) + "\"" + ",\"magic\":" + (string)HistoryDealGetInteger(ticket, DEAL_MAGIC) + "}"; count++; } return out; } //+------------------------------------------------------------------+ //| The contract size and path of every symbol in this page that was | //| not sent before. | //+------------------------------------------------------------------+ string CollectSymbols(const string deals) { string out = ""; // Symbols of the deals in the page, plus the open positions. string names[]; int n = 0; int pos = 0; while(true) { int at = StringFind(deals, "\"symbol\":\"", pos); if(at < 0) break; at += 10; int end = StringFind(deals, "\"", at); if(end < 0) break; string s = StringSubstr(deals, at, end - at); pos = end + 1; if(s == "" || StringFind(g_sentSymbols, ";" + s + ";") >= 0) continue; ArrayResize(names, n + 1); names[n++] = s; g_sentSymbols += s + ";"; } for(int i = 0; i < PositionsTotal(); i++) { ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; string s = PositionGetString(POSITION_SYMBOL); if(s == "" || StringFind(g_sentSymbols, ";" + s + ";") >= 0) continue; ArrayResize(names, n + 1); names[n++] = s; g_sentSymbols += s + ";"; } for(int i = 0; i < n; i++) { double contract = SymbolInfoDouble(names[i], SYMBOL_TRADE_CONTRACT_SIZE); if(contract <= 0) continue; if(out != "") out += ","; out += "\"" + JsonEscape(names[i]) + "\":{\"contract\":" + Num(contract) + ",\"digits\":" + (string)SymbolInfoInteger(names[i], SYMBOL_DIGITS) + ",\"path\":\"" + JsonEscape(SymbolInfoString(names[i], SYMBOL_PATH)) + "\"" + ",\"currencyProfit\":\"" + JsonEscape(SymbolInfoString(names[i], SYMBOL_CURRENCY_PROFIT)) + "\"" + ",\"description\":\"" + JsonEscape(SymbolInfoString(names[i], SYMBOL_DESCRIPTION)) + "\"}"; } return out; } //+------------------------------------------------------------------+ string CollectPositions() { string out = ""; for(int i = 0; i < PositionsTotal(); i++) { ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; if(out != "") out += ","; // POSITION_IDENTIFIER is what the deals call DEAL_POSITION_ID. out += "{\"ticket\":" + (string)PositionGetInteger(POSITION_IDENTIFIER) + ",\"symbol\":\"" + JsonEscape(PositionGetString(POSITION_SYMBOL)) + "\"" + ",\"type\":" + (string)PositionGetInteger(POSITION_TYPE) + ",\"volume\":" + Num(PositionGetDouble(POSITION_VOLUME)) + ",\"priceOpen\":" + Num(PositionGetDouble(POSITION_PRICE_OPEN)) + ",\"sl\":" + Num(PositionGetDouble(POSITION_SL)) + ",\"tp\":" + Num(PositionGetDouble(POSITION_TP)) + ",\"time\":" + (string)(long)PositionGetInteger(POSITION_TIME) + "}"; } return out; } //+------------------------------------------------------------------+ string AccountJson() { return "{\"login\":" + (string)AccountInfoInteger(ACCOUNT_LOGIN) + ",\"server\":\"" + JsonEscape(AccountInfoString(ACCOUNT_SERVER)) + "\"" + ",\"company\":\"" + JsonEscape(AccountInfoString(ACCOUNT_COMPANY)) + "\"" + ",\"currency\":\"" + JsonEscape(AccountInfoString(ACCOUNT_CURRENCY)) + "\"" + ",\"mode\":" + (string)AccountInfoInteger(ACCOUNT_TRADE_MODE) + ",\"hedging\":" + (AccountInfoInteger(ACCOUNT_MARGIN_MODE) == ACCOUNT_MARGIN_MODE_RETAIL_HEDGING ? "true" : "false") + ",\"balance\":" + Num(AccountInfoDouble(ACCOUNT_BALANCE)) + ",\"equity\":" + Num(AccountInfoDouble(ACCOUNT_EQUITY)) + ",\"leverage\":" + (string)AccountInfoInteger(ACCOUNT_LEVERAGE) + ",\"name\":\"" + JsonEscape(AccountInfoString(ACCOUNT_NAME)) + "\"}"; } //+------------------------------------------------------------------+ //| Minutes the broker's clock is ahead of UTC, to the quarter hour. | //+------------------------------------------------------------------+ int ServerOffsetMinutes() { long diff = (long)TimeTradeServer() - (long)TimeGMT(); return (int)(MathRound(diff / 900.0) * 15); } //+------------------------------------------------------------------+ //| One request. Returns true on 200 and reads the cursor back. | //+------------------------------------------------------------------+ bool Push(const string deals, const int count, const bool withAccount, const bool withPositions) { string body = "{\"v\":1,\"platform\":\"mt5\",\"ea\":\"" + SB_EA_VERSION + "\"" + ",\"build\":" + (string)TerminalInfoInteger(TERMINAL_BUILD) + ",\"serverOffsetMinutes\":" + (string)ServerOffsetMinutes() + ",\"serverTime\":" + (string)(long)TimeTradeServer(); if(withAccount) body += ",\"account\":" + AccountJson(); string symbols = CollectSymbols(deals); if(symbols != "") body += ",\"symbols\":{" + symbols + "}"; body += ",\"deals\":[" + deals + "]"; if(withPositions) body += ",\"positions\":[" + CollectPositions() + "]"; body += ",\"done\":" + (count < InpPageSize ? "true" : "false") + "}"; char data[]; int len = StringToCharArray(body, data, 0, WHOLE_ARRAY, CP_UTF8); if(len > 0 && data[len - 1] == 0) ArrayResize(data, len - 1); char result[]; string resultHeaders; string headers = "Content-Type: application/json\r\nAuthorization: Bearer " + InpToken + "\r\nUser-Agent: " + SB_EA_VERSION + "\r\n"; ResetLastError(); int code = WebRequest("POST", InpEndpoint, headers, 15000, data, result, resultHeaders); string text = CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8); if(InpVerbose) Print("SetupBookSync: POST ", count, " deals -> ", code, " ", StringSubstr(text, 0, 200)); if(code == -1) { int err = GetLastError(); g_failures++; SetStatus("cannot reach " + InpEndpoint + " (error " + (string)err + "). Add https://setupbook.io under Tools > Options > Expert Advisors > Allow WebRequest."); Print("SetupBookSync: WebRequest failed, error ", err, ". Is the URL in the allowed list?"); return false; } if(code == 401) { g_failures++; SetStatus("token rejected: make a new one in SetupBook > Settings > Connections and update the EA input"); Print("SetupBookSync: 401 ", text); return false; } if(code != 200) { g_failures++; SetStatus("server answered " + (string)code + "; retrying later"); Print("SetupBookSync: HTTP ", code, " ", StringSubstr(text, 0, 300)); return false; } long cursor = JsonLong(text, "cursorMs"); if(cursor >= 0) g_cursorMs = cursor; else if(g_cursorMs < 0) g_cursorMs = 0; if(StringFind(text, "\"paused\":true") >= 0) SetStatus("paused in SetupBook"); return true; } //+------------------------------------------------------------------+ //| Helpers | //+------------------------------------------------------------------+ string Num(double v) { string s = DoubleToString(v, 8); int dot = StringFind(s, "."); if(dot >= 0) { int end = StringLen(s); while(end > dot + 1 && StringGetCharacter(s, end - 1) == '0') end--; if(end == dot + 1) end = dot; s = StringSubstr(s, 0, end); } if(s == "-0") s = "0"; return s; } string JsonEscape(string s) { StringReplace(s, "\\", "\\\\"); StringReplace(s, "\"", "\\\""); StringReplace(s, "\n", " "); StringReplace(s, "\r", " "); StringReplace(s, "\t", " "); return s; } // Reads an integer value for "key": from a flat JSON answer; -1 when absent or null. long JsonLong(const string json, const string key) { int at = StringFind(json, "\"" + key + "\":"); if(at < 0) return -1; at += StringLen(key) + 3; int end = at; while(end < StringLen(json)) { ushort c = StringGetCharacter(json, end); if(!(c >= '0' && c <= '9') && c != '-') break; end++; } if(end == at) return -1; return StringToInteger(StringSubstr(json, at, end - at)); } void SetStatus(const string status) { g_status = status; string lastOk = g_lastOk == 0 ? "never" : TimeToString(g_lastOk, TIME_DATE | TIME_MINUTES); Comment("SetupBook Sync " + SB_EA_VERSION + "\n" + "Status: " + g_status + "\n" + "Last successful sync: " + lastOk + "\n" + "Deals sent this session: " + (string)g_sentDeals); } //+------------------------------------------------------------------+