//+------------------------------------------------------------------+ //| SetupBookSync.mq4 | //| SetupBook trading journal, by ALAZ | //| https://setupbook.io | //+------------------------------------------------------------------+ // Sends this account's closed orders and open trades to your SetupBook // journal and keeps them 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 /MQL4/Experts and compile it in // MetaEditor (F7), or drop the .ex4 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. // In the Terminal window, Account History > right-click > // "All History", otherwise MetaTrader 4 shows the EA only the // last few days. // // Needs MetaTrader 4 build 600 or later. //+------------------------------------------------------------------+ #property copyright "ALAZ" #property link "https://setupbook.io" #property version "1.00" #property description "Syncs your orders to SetupBook (read-only)." #property strict 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; // Orders per request input bool InpVerbose = false; // Log every request #define SB_EA_VERSION "SetupBookSync/1.00 MT4" #define SB_TIMER_SEC 5 #define SB_OVERLAP_MS 1000 long g_cursorMs = -1; // newest close time (server ms) the server holds; -1 = unknown bool g_dirty = true; datetime g_lastPush = 0; datetime g_lastOk = 0; int g_failures = 0; long g_sentOrders = 0; string g_status = "starting"; string g_sentSymbols = ";"; int g_lastHistoryTotal = -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(IsTesting()) { 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() {} //+------------------------------------------------------------------+ void OnTimer() { if(IsTesting()) return; datetime now = TimeCurrent(); // MetaTrader 4 has no trade event for Expert Advisors: a changed order // count (closed, opened, partially closed) is the signal to sync. int history = OrdersHistoryTotal(); int open = OrdersTotal(); if(history != g_lastHistoryTotal || open != g_lastOpenTotal) { g_dirty = true; g_lastHistoryTotal = history; g_lastOpenTotal = open; } 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(); } //+------------------------------------------------------------------+ void Sync() { g_lastPush = TimeCurrent(); if(g_cursorMs < 0) { if(!Push("", "", 0, true, true)) return; } int pages = 0; while(pages < 200) { int count = 0; string deposits = ""; string orders = CollectClosedOrders(g_cursorMs, count, deposits); bool last = count < InpPageSize; if(count == 0) { if(pages == 0) Push("", deposits, 0, false, true); break; } if(!Push(orders, deposits, count, pages == 0, true)) return; pages++; g_sentOrders += count; if(last) break; } g_dirty = false; g_failures = 0; g_lastOk = TimeCurrent(); SetStatus("in sync"); } //+------------------------------------------------------------------+ //| Closed buy/sell orders with a close time past the cursor, oldest | //| first; balance entries go out as deposits. The history the EA | //| sees is what the Account History tab shows: set it to All History.| //+------------------------------------------------------------------+ string CollectClosedOrders(long cursorMs, int &count, string &deposits) { count = 0; deposits = ""; string out = ""; long from = cursorMs < 0 ? 0 : cursorMs - SB_OVERLAP_MS; int total = OrdersHistoryTotal(); // History is not guaranteed to be in close-time order: collect, then sort. long times[]; int index[]; int n = 0; for(int i = 0; i < total; i++) { if(!OrderSelect(i, SELECT_BY_POS, MODE_HISTORY)) continue; int type = OrderType(); long closeMs = (long)OrderCloseTime() * 1000; if(type == 6) { // Balance entry (deposit or withdrawal), keyed by its own time. if(closeMs < from) continue; if(deposits != "") deposits += ","; deposits += "{\"ticket\":" + (string)OrderTicket() + ",\"position\":0,\"time\":" + (string)(long)OrderCloseTime() + ",\"type\":2,\"entry\":0,\"symbol\":\"\",\"volume\":0,\"price\":0,\"profit\":" + Num(OrderProfit()) + "}"; continue; } if(type != OP_BUY && type != OP_SELL) continue; if(OrderCloseTime() == 0 || closeMs < from) continue; ArrayResize(times, n + 1); ArrayResize(index, n + 1); times[n] = closeMs; index[n] = i; n++; } // Insertion sort by close time (pages are small). for(int a = 1; a < n; a++) { long t = times[a]; int ix = index[a]; int b = a - 1; while(b >= 0 && times[b] > t) { times[b + 1] = times[b]; index[b + 1] = index[b]; b--; } times[b + 1] = t; index[b + 1] = ix; } for(int k = 0; k < n && count < InpPageSize; k++) { if(!OrderSelect(index[k], SELECT_BY_POS, MODE_HISTORY)) continue; if(count > 0) out += ","; out += OrderJson(true); count++; } return out; } //+------------------------------------------------------------------+ string OrderJson(const bool closed) { string s = "{\"ticket\":" + (string)OrderTicket() + ",\"type\":" + (string)OrderType() + ",\"symbol\":\"" + JsonEscape(OrderSymbol()) + "\"" + ",\"lots\":" + Num(OrderLots()) + ",\"openTime\":" + (string)(long)OrderOpenTime() + ",\"openPrice\":" + Num(OrderOpenPrice()) + ",\"sl\":" + Num(OrderStopLoss()) + ",\"tp\":" + Num(OrderTakeProfit()) + ",\"comment\":\"" + JsonEscape(OrderComment()) + "\"" + ",\"magic\":" + (string)OrderMagicNumber(); if(closed) s += ",\"closeTime\":" + (string)(long)OrderCloseTime() + ",\"closePrice\":" + Num(OrderClosePrice()) + ",\"commission\":" + Num(OrderCommission()) + ",\"swap\":" + Num(OrderSwap()) + ",\"profit\":" + Num(OrderProfit()); return s + "}"; } //+------------------------------------------------------------------+ //| Open buy/sell orders, as the "positions" snapshot. | //+------------------------------------------------------------------+ string CollectOpenOrders() { string out = ""; for(int i = 0; i < OrdersTotal(); i++) { if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue; if(OrderType() != OP_BUY && OrderType() != OP_SELL) continue; if(out != "") out += ","; out += "{\"ticket\":" + (string)OrderTicket() + ",\"symbol\":\"" + JsonEscape(OrderSymbol()) + "\"" + ",\"type\":" + (string)OrderType() + ",\"volume\":" + Num(OrderLots()) + ",\"priceOpen\":" + Num(OrderOpenPrice()) + ",\"sl\":" + Num(OrderStopLoss()) + ",\"tp\":" + Num(OrderTakeProfit()) + ",\"time\":" + (string)(long)OrderOpenTime() + "}"; } return out; } //+------------------------------------------------------------------+ string CollectSymbols(const string orders) { string out = ""; string names[]; int n = 0; int pos = 0; while(true) { int at = StringFind(orders, "\"symbol\":\"", pos); if(at < 0) break; at += 10; int end = StringFind(orders, "\"", at); if(end < 0) break; string s = StringSubstr(orders, 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 < OrdersTotal(); i++) { if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue; string s = OrderSymbol(); 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 = MarketInfo(names[i], MODE_LOTSIZE); if(contract <= 0) continue; if(out != "") out += ","; out += "\"" + JsonEscape(names[i]) + "\":{\"contract\":" + Num(contract) + ",\"digits\":" + (string)(int)MarketInfo(names[i], MODE_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 AccountJson() { return "{\"login\":" + (string)AccountNumber() + ",\"server\":\"" + JsonEscape(AccountServer()) + "\"" + ",\"company\":\"" + JsonEscape(AccountCompany()) + "\"" + ",\"currency\":\"" + JsonEscape(AccountCurrency()) + "\"" + ",\"mode\":" + (IsDemo() ? "0" : "2") + ",\"hedging\":true" + ",\"balance\":" + Num(AccountBalance()) + ",\"equity\":" + Num(AccountEquity()) + ",\"leverage\":" + (string)AccountLeverage() + ",\"name\":\"" + JsonEscape(AccountName()) + "\"}"; } //+------------------------------------------------------------------+ int ServerOffsetMinutes() { long diff = (long)TimeCurrent() - (long)TimeGMT(); return (int)(MathRound(diff / 900.0) * 15); } //+------------------------------------------------------------------+ bool Push(const string orders, const string deposits, const int count, const bool withAccount, const bool withPositions) { string body = "{\"v\":1,\"platform\":\"mt4\",\"ea\":\"" + SB_EA_VERSION + "\"" + ",\"build\":" + (string)TerminalInfoInteger(TERMINAL_BUILD) + ",\"serverOffsetMinutes\":" + (string)ServerOffsetMinutes() + ",\"serverTime\":" + (string)(long)TimeCurrent(); if(withAccount) body += ",\"account\":" + AccountJson(); string symbols = CollectSymbols(orders); if(symbols != "") body += ",\"symbols\":{" + symbols + "}"; body += ",\"orders\":[" + orders + "]"; if(deposits != "") body += ",\"deals\":[" + deposits + "]"; if(withPositions) body += ",\"positions\":[" + CollectOpenOrders() + "]"; 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, " orders -> ", 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; } 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" + "Orders sent this session: " + (string)g_sentOrders); } //+------------------------------------------------------------------+