//+------------------------------------------------------------------+ //| TJConnector.mq5 - Trading Journal connector for MetaTrader 5 | //| | //| Reads the account history and sends it to Trading Journal: | //| the whole history once, then every new deal within seconds, and | //| M1 bars around your trades when the journal asks for them. | //| It never places, modifies or closes orders or positions, needs | //| no DLL and no trading permission. CI rejects any trade call here | //| (scripts/check-connector-readonly.ts). | //| | //| The API it speaks: PRD "Accounts", section 7 (/api/ingest/v1). | //+------------------------------------------------------------------+ #property copyright "Trading Journal" #property link "https://trading-jornal-ashen.vercel.app" #property version "1.00" #property description "Sends your account history to Trading Journal." #property description "Read-only: never places, modifies or closes trades. No DLL." input string InpKey = ""; // Key from Trading Journal input string InpBaseUrl = "https://trading-jornal-ashen.vercel.app"; // Trading Journal address #define TJ_VERSION "1.00" #define TJ_TIMEOUT_MS 10000 #define TJ_DAY_MS 86400000 #define TJ_BAR_CHUNK 5000 string g_key = ""; string g_base = ""; string g_instance = ""; int g_money = 2; // decimals of the account currency bool g_started = false; // the server cursor is known long g_cursor = -1; // ms (server clock) of the newest deal the server accepted ulong g_atCursor[]; // deals already sent at exactly g_cursor int g_batch = 500; int g_poll = 5; // seconds between checks for new deals long g_resync = -1; // ms from the server: send the history again from here int g_queued = 0; // deals not sent yet int g_backoff = 0; // seconds, 5 -> 300 on errors ulong g_nextTry = 0; // no request before this (GetTickCount64) ulong g_nextCheck = 0; ulong g_nextBeat = 0; ulong g_nextBars = 0; datetime g_lastSent = 0; string g_error = ""; // Bars windows the server asked for (heartbeat "bars_wanted"), server seconds. string g_barSym[]; long g_barFrom[]; long g_barTo[]; int g_barTries[]; //+------------------------------------------------------------------+ int OnInit() { g_key = CleanKey(InpKey); g_base = InpBaseUrl; StringTrimLeft(g_base); StringTrimRight(g_base); while(StringLen(g_base) > 0 && StringGetCharacter(g_base, StringLen(g_base) - 1) == '/') g_base = StringSubstr(g_base, 0, StringLen(g_base) - 1); g_instance = InstanceId(); long digits = AccountInfoInteger(ACCOUNT_CURRENCY_DIGITS); g_money = (int)MathMax(2, MathMin(8, digits)); EventSetTimer(1); Show(); return(INIT_SUCCEEDED); } void OnDeinit(const int reason) { EventKillTimer(); Comment(""); } void OnTimer() { if(g_key == "") { g_error = "Paste your key into InpKey (right click the chart > Expert list > Properties > Inputs)"; Show(); return; } ulong now = GetTickCount64(); if(now < g_nextTry) { Show(); return; } if(!g_started) Start(); else if(g_resync >= 0) { long from = g_resync; g_resync = -1; SendFrom(from); } else { if(now >= g_nextCheck) { g_nextCheck = now + (ulong)g_poll * 1000; SendNew(); } if(GetTickCount64() >= g_nextTry && GetTickCount64() >= g_nextBeat) Heartbeat(); else if(g_queued == 0 && ArraySize(g_barSym) > 0 && GetTickCount64() >= g_nextTry && GetTickCount64() >= g_nextBars) SendBars(); } Show(); } //+------------------------------------------------------------------+ //| Start: where the server is, then the history from there | //+------------------------------------------------------------------+ void Start() { string resp; if(Http("GET", "cursor", "", resp) != 200) return; g_batch = (int)MathMax(1, MathMin(1000, JsonLong(resp, "batch_size", 500))); g_poll = (int)MathMax(1, MathMin(60, JsonLong(resp, "poll_seconds", 5))); long from = JsonLong(resp, "from_time_msc", 0); long resync = JsonLong(resp, "resync_from", -1); if(resync >= 0 && resync < from) from = resync; g_started = true; g_error = ""; // The day of overlap only at the start (and on resync): the server ignores what it has. g_cursor = (long)MathMax(0, from - TJ_DAY_MS) - 1; ArrayResize(g_atCursor, 0); Heartbeat(); SendNew(); } // Send the history again from `from_msc` (ms, server clock), a day of overlap included. void SendFrom(const long from_msc) { g_cursor = (long)MathMax(0, from_msc - TJ_DAY_MS) - 1; ArrayResize(g_atCursor, 0); SendNew(); } //+------------------------------------------------------------------+ //| Deals newer than the local cursor, oldest first, in batches; | //| the cursor moves only after the server accepted a batch | //+------------------------------------------------------------------+ void SendNew() { datetime from = (datetime)(g_cursor > 0 ? g_cursor / 1000 : 0); if(!HistorySelect(from, TimeCurrent() + 86400)) return; int total = HistoryDealsTotal(); long keys[][2]; ArrayResize(keys, total); int n = 0; for(int i = 0; i < total; i++) { ulong t = HistoryDealGetTicket(i); if(t == 0) continue; long ms = HistoryDealGetInteger(t, DEAL_TIME_MSC); if(ms < g_cursor || (ms == g_cursor && SentAtCursor(t))) continue; keys[n][0] = ms; keys[n][1] = (long)t; n++; } ArrayResize(keys, n); g_queued = n; if(n == 0) return; ArraySort(keys); for(int b = 0; b < n; b += g_batch) { int e = MinInt(n, b + g_batch); if(!PostDeals(keys, b, e)) return; long last = keys[e - 1][0]; if(last != g_cursor) { g_cursor = last; ArrayResize(g_atCursor, 0); } for(int k = b; k < e; k++) if(keys[k][0] == g_cursor) AddAtCursor((ulong)keys[k][1]); g_queued = n - e; g_lastSent = TimeLocal(); Show(); // A long first upload still says it is alive (and shows progress on the site). if(g_queued > 0 && GetTickCount64() >= g_nextBeat) { Heartbeat(); HistorySelect(from, TimeCurrent() + 86400); } } } bool PostDeals(long &keys[][2], const int b, const int e) { string deals = ""; string symbols = ""; string seen = "|"; for(int k = b; k < e; k++) { ulong t = (ulong)keys[k][1]; string sym = HistoryDealGetString(t, DEAL_SYMBOL); if(k > b) StringAdd(deals, ","); StringAdd(deals, DealJson(t, sym)); if(sym != "" && StringFind(seen, "|" + sym + "|") < 0) { seen += sym + "|"; string spec = SymbolJson(sym); if(spec != "") { if(symbols != "") StringAdd(symbols, ","); StringAdd(symbols, spec); } } } long offset = (long)(TimeTradeServer() - TimeGMT()); string body = "{\"platform\":\"mt5\",\"server_gmt_offset_sec\":" + IntegerToString(offset) + ",\"symbols\":[" + symbols + "],\"deals\":[" + deals + "]}"; string resp; int code = Http("POST", "deals", body, resp); return(code >= 200 && code < 300); } string DealJson(const ulong t, const string sym) { string s = "{\"ticket\":\"" + IntegerToString((long)t) + "\""; long order = HistoryDealGetInteger(t, DEAL_ORDER); if(order > 0) s += ",\"order\":\"" + IntegerToString(order) + "\""; long position = HistoryDealGetInteger(t, DEAL_POSITION_ID); if(position > 0) s += ",\"position_id\":\"" + IntegerToString(position) + "\""; s += ",\"time_msc\":" + IntegerToString(HistoryDealGetInteger(t, DEAL_TIME_MSC)); s += ",\"symbol\":\"" + Esc(StringSubstr(sym, 0, 32)) + "\""; s += ",\"type\":\"" + DealType(t) + "\""; string entry = EntryName((ENUM_DEAL_ENTRY)HistoryDealGetInteger(t, DEAL_ENTRY)); if(entry != "") s += ",\"entry\":\"" + entry + "\""; s += ",\"volume\":\"" + Num(HistoryDealGetDouble(t, DEAL_VOLUME), 8) + "\""; s += ",\"price\":\"" + Num(HistoryDealGetDouble(t, DEAL_PRICE), PriceDigits(sym)) + "\""; s += ",\"commission\":\"" + Money(HistoryDealGetDouble(t, DEAL_COMMISSION)) + "\""; s += ",\"fee\":\"" + Money(HistoryDealGetDouble(t, DEAL_FEE)) + "\""; s += ",\"swap\":\"" + Money(HistoryDealGetDouble(t, DEAL_SWAP)) + "\""; s += ",\"profit\":\"" + Money(HistoryDealGetDouble(t, DEAL_PROFIT)) + "\""; long magic = HistoryDealGetInteger(t, DEAL_MAGIC); if(magic > 0) s += ",\"magic\":\"" + IntegerToString(magic) + "\""; string reason = EnumName(EnumToString((ENUM_DEAL_REASON)HistoryDealGetInteger(t, DEAL_REASON)), "DEAL_REASON_", ""); if(reason != "") s += ",\"reason\":\"" + reason + "\""; string comment = HistoryDealGetString(t, DEAL_COMMENT); if(comment != "") s += ",\"comment\":\"" + Esc(StringSubstr(comment, 0, 64)) + "\""; return(s + "}"); } // buy | sell | balance | credit | charge | commission | ... | buy_canceled (the server skips those) string DealType(const ulong t) { return(EnumName(EnumToString((ENUM_DEAL_TYPE)HistoryDealGetInteger(t, DEAL_TYPE)), "DEAL_TYPE_", "other")); } string EntryName(const ENUM_DEAL_ENTRY e) { switch(e) { case DEAL_ENTRY_IN: return("in"); case DEAL_ENTRY_OUT: return("out"); case DEAL_ENTRY_INOUT: return("inout"); case DEAL_ENTRY_OUT_BY: return("out_by"); default: return(""); } } // Contract size, profit currency, digits and tick size: the journal's P&L uses the broker's own. string SymbolJson(const string sym) { double contract = 0, tick = 0; string currency = ""; long digits = 0; if(StringLen(sym) > 32 || !SymbolInfoDouble(sym, SYMBOL_TRADE_CONTRACT_SIZE, contract) || !SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE, tick) || !SymbolInfoString(sym, SYMBOL_CURRENCY_PROFIT, currency) || !SymbolInfoInteger(sym, SYMBOL_DIGITS, digits)) return(""); if(contract <= 0 || tick <= 0 || digits < 0 || digits > 10 || !IsCurrency(currency)) return(""); return("{\"symbol\":\"" + Esc(sym) + "\",\"contract_size\":\"" + Num(contract, 8) + "\",\"currency_profit\":\"" + currency + "\",\"digits\":" + IntegerToString(digits) + ",\"tick_size\":\"" + Num(tick, 10) + "\"}"); } //+------------------------------------------------------------------+ //| Heartbeat once a minute: alive, the server clock (its time zone), | //| what the history holds (the server compares it with its own) | //+------------------------------------------------------------------+ void Heartbeat() { g_nextBeat = GetTickCount64() + 60000; long total = 0; long from = 0; double profit = 0; int queued = 0; if(HistorySelect(0, TimeCurrent() + 86400)) { int n = HistoryDealsTotal(); for(int i = 0; i < n; i++) { ulong t = HistoryDealGetTicket(i); if(t == 0) continue; long ms = HistoryDealGetInteger(t, DEAL_TIME_MSC); if(from == 0 || ms / 1000 < from) from = ms / 1000; if(ms > g_cursor || (ms == g_cursor && !SentAtCursor(t))) queued++; string type = DealType(t); if(StringFind(type, "_canceled") >= 0) continue; double p = NormalizeDouble(HistoryDealGetDouble(t, DEAL_PROFIT), g_money); // The journal keeps every trade deal and the non-zero balance operations. if(type != "buy" && type != "sell" && IsZeroMoney(p)) continue; total++; profit += p; } } if(from == 0) from = (long)TimeTradeServer(); g_queued = queued; long age = (long)MathMax(0, (long)(TimeTradeServer() - TimeCurrent())); string body = "{\"server_time\":" + IntegerToString((long)TimeTradeServer()) + ",\"gmt_time\":" + IntegerToString((long)TimeGMT()) + ",\"terminal_build\":" + IntegerToString(TerminalInfoInteger(TERMINAL_BUILD)) + ",\"terminal_connected\":" + (TerminalInfoInteger(TERMINAL_CONNECTED) != 0 ? "true" : "false") + ",\"history_deals_total\":" + IntegerToString(total) + ",\"history_from\":" + IntegerToString(from) + ",\"history_profit_sum\":\"" + Money(profit) + "\"" + ",\"queued\":" + IntegerToString(queued) + ",\"tick_age_sec\":" + IntegerToString(age) + ",\"account_balance\":\"" + Money(AccountInfoDouble(ACCOUNT_BALANCE)) + "\"}"; string resp; if(Http("POST", "heartbeat", body, resp) != 200) return; g_poll = (int)MathMax(1, MathMin(60, JsonLong(resp, "poll_seconds", g_poll))); long resync = JsonLong(resp, "resync_from", -1); if(resync >= 0) g_resync = resync; QueueBars(resp); } //+------------------------------------------------------------------+ //| M1 bars around trades (the journal's trade chart and replay) | //+------------------------------------------------------------------+ void QueueBars(const string resp) { int p = StringFind(resp, "\"bars_wanted\""); if(p < 0) return; int end = StringFind(resp, "]", p); while(true) { int ob = StringFind(resp, "{", p); if(ob < 0 || (end >= 0 && ob > end)) break; int oe = StringFind(resp, "}", ob); if(oe < 0) break; string sym = ""; JsonRaw(resp, "symbol", ob, oe, sym); long from = JsonLong(resp, "from", -1, ob, oe); long to = JsonLong(resp, "to", -1, ob, oe); p = oe + 1; if(sym == "" || from < 0 || to < from || BarsQueued(sym, from, to)) continue; int k = ArraySize(g_barSym); ArrayResize(g_barSym, k + 1); ArrayResize(g_barFrom, k + 1); ArrayResize(g_barTo, k + 1); ArrayResize(g_barTries, k + 1); g_barSym[k] = sym; g_barFrom[k] = from; g_barTo[k] = to; g_barTries[k] = 0; } } bool BarsQueued(const string sym, const long from, const long to) { for(int i = 0; i < ArraySize(g_barSym); i++) if(g_barSym[i] == sym && g_barFrom[i] == from && g_barTo[i] == to) return(true); return(false); } void DropFirstBars() { int n = ArraySize(g_barSym); for(int i = 1; i < n; i++) { g_barSym[i - 1] = g_barSym[i]; g_barFrom[i - 1] = g_barFrom[i]; g_barTo[i - 1] = g_barTo[i]; g_barTries[i - 1] = g_barTries[i]; } ArrayResize(g_barSym, n - 1); ArrayResize(g_barFrom, n - 1); ArrayResize(g_barTo, n - 1); ArrayResize(g_barTries, n - 1); } // One window per call, in chunks; an empty answer marks the window's days as asked. void SendBars() { g_nextBars = GetTickCount64() + 2000; string want = g_barSym[0]; long from = g_barFrom[0]; long to = g_barTo[0]; string sym = TerminalSymbol(want); MqlRates r[]; int n = 0; if(sym != "") { ResetLastError(); n = CopyRates(sym, PERIOD_M1, (datetime)from, (datetime)to, r); if(n < 0) { // The terminal is still loading this history: come back later, a few times. g_barTries[0]++; if(g_barTries[0] < 5) { g_nextBars = GetTickCount64() + 30000; return; } n = 0; } } int digits = PriceDigits(sym); for(int c = 0; c == 0 || c < n; c += TJ_BAR_CHUNK) { int e = MinInt(n, c + TJ_BAR_CHUNK); string bars = ""; for(int i = c; i < e; i++) { double hi = MathMax(r[i].high, MathMax(r[i].open, r[i].close)); double lo = MathMin(r[i].low, MathMin(r[i].open, r[i].close)); if(i > c) StringAdd(bars, ","); StringAdd(bars, "{\"t\":" + IntegerToString((long)r[i].time) + ",\"o\":\"" + Num(r[i].open, digits) + "\",\"h\":\"" + Num(hi, digits) + "\",\"l\":\"" + Num(lo, digits) + "\",\"c\":\"" + Num(r[i].close, digits) + "\",\"v\":\"" + IntegerToString(r[i].tick_volume) + "\"}"); } string body = "{\"symbol\":\"" + Esc(want) + "\",\"timeframe\":\"M1\""; if(e >= n) body += ",\"from\":" + IntegerToString(from) + ",\"to\":" + IntegerToString(to); body += ",\"bars\":[" + bars + "]}"; string resp; int code = Http("POST", "bars", body, resp); if(code < 200 || code >= 300) return; if(e >= n) break; } DropFirstBars(); } // The terminal's name of a journal symbol (MT4 journals keep it upper case). string TerminalSymbol(const string want) { bool custom = false; if(SymbolExist(want, custom)) { SymbolSelect(want, true); return(want); } int total = SymbolsTotal(false); for(int i = 0; i < total; i++) { string name = SymbolName(i, false); string upper = name; StringToUpper(upper); if(upper == want) { SymbolSelect(name, true); return(name); } } return(""); } //+------------------------------------------------------------------+ //| HTTP | //+------------------------------------------------------------------+ int Http(const string method, const string path, const string body, string &resp) { char data[]; char result[]; string resultHeaders; if(body != "") { StringToCharArray(body, data, 0, WHOLE_ARRAY, CP_UTF8); ArrayResize(data, ArraySize(data) - 1); } ResetLastError(); int code = WebRequest(method, g_base + "/api/ingest/v1/" + path, Headers(), TJ_TIMEOUT_MS, data, result, resultHeaders); resp = CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8); if(code == -1) { int err = GetLastError(); if(err == 4014) Fail("Add " + g_base + " in Tools > Options > Expert Advisors > Allow WebRequest for listed URL", 30); else Fail("No connection to " + g_base + " (error " + IntegerToString(err) + "), retrying", 0); return(-1); } if(code >= 200 && code < 300) { g_backoff = 0; g_error = ""; return(code); } string msg = ""; JsonRaw(resp, "message", 0, -1, msg); if(code == 401) Fail("Key revoked or wrong: paste the key from Trading Journal into InpKey", 300); else if(code == 409) Fail(msg != "" ? msg : "This key belongs to another account", 300); else if(code == 426) Fail("Update the connector: " + g_base + "/connect/metatrader", 3600); else if(code == 429) Fail("Waiting: too many requests", (int)RetryAfter(resultHeaders)); else Fail("Server answered " + IntegerToString(code) + (msg != "" ? ": " + msg : ""), 0); return(code); } string Headers() { return("Authorization: Bearer " + g_key + "\r\n" + "Content-Type: application/json\r\n" + "X-TJ-Login: " + IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN)) + "\r\n" + "X-TJ-Server: " + Enc(AccountInfoString(ACCOUNT_SERVER)) + "\r\n" + "X-TJ-Company: " + Enc(AccountInfoString(ACCOUNT_COMPANY)) + "\r\n" + "X-TJ-Platform: mt5\r\n" + "X-TJ-Instance: " + g_instance + "\r\n" + "X-TJ-Version: " + TJ_VERSION + "\r\n"); } // Wait `wait` seconds before the next request; 0 = back off 5 s, 10 s ... 5 min. void Fail(const string text, int wait) { g_error = text; if(wait <= 0) { g_backoff = g_backoff == 0 ? 5 : MinInt(300, g_backoff * 2); wait = g_backoff; } g_nextTry = GetTickCount64() + (ulong)wait * 1000; } long RetryAfter(const string headers) { string h = headers; StringToLower(h); int p = StringFind(h, "retry-after:"); if(p < 0) return(60); string v = StringSubstr(h, p + 12, 12); int cut = StringFind(v, "\r"); if(cut >= 0) v = StringSubstr(v, 0, cut); StringTrimLeft(v); StringTrimRight(v); return((long)MathMax(1, MathMin(3600, StringToInteger(v)))); } void Show() { string text = "TJ Connector " + TJ_VERSION + " | " + IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN)) + " @ " + AccountInfoString(ACCOUNT_SERVER) + "\n"; if(g_error != "") text += g_error; else if(!g_started) text += "connecting..."; else text += (g_queued > 0 ? "sending history" : "online") + " | last sent " + (g_lastSent > 0 ? TimeToString(g_lastSent, TIME_MINUTES) : "-") + " | " + IntegerToString(g_queued) + " queued"; Comment(text); } //+------------------------------------------------------------------+ //| Small helpers | //+------------------------------------------------------------------+ // A random number kept by the terminal + this chart: two charts are two instances. string InstanceId() { string name = "TJC.instance"; if(!GlobalVariableCheck(name)) { MathSrand((int)GetTickCount()); GlobalVariableSet(name, (double)(MathRand() * 32768 + MathRand() + 1)); } return(IntegerToString((long)GlobalVariableGet(name)) + "-" + IntegerToString(ChartID())); } // Spaces and line breaks pasted with the key do not count. string CleanKey(const string s) { string out = ""; for(int i = 0; i < StringLen(s); i++) { ushort c = StringGetCharacter(s, i); if(c > 32 && c < 127) out += ShortToString(c); } return(out); } bool SentAtCursor(const ulong t) { for(int i = 0; i < ArraySize(g_atCursor); i++) if(g_atCursor[i] == t) return(true); return(false); } void AddAtCursor(const ulong t) { if(SentAtCursor(t)) return; int n = ArraySize(g_atCursor); ArrayResize(g_atCursor, n + 1); g_atCursor[n] = t; } int MinInt(const int a, const int b) { return(a < b ? a : b); } int PriceDigits(const string sym) { long d = 0; if(sym != "" && SymbolInfoInteger(sym, SYMBOL_DIGITS, d) && d >= 0 && d <= 10) return((int)d); return(8); } bool IsCurrency(const string s) { int n = StringLen(s); if(n < 3 || n > 4) return(false); for(int i = 0; i < n; i++) { ushort c = StringGetCharacter(s, i); if(c < 'A' || c > 'Z') return(false); } return(true); } bool IsZeroMoney(const double x) { return(MathAbs(x) < 0.5 / MathPow(10, g_money)); } // Decimals as strings (the API takes no floats). string Num(const double x, const int digits) { if(!MathIsValidNumber(x)) return("0"); return(DoubleToString(x, digits)); } string Money(const double x) { return(Num(x, g_money)); } // DEAL_TYPE_BUY -> buy; anything odd becomes `fallback`. string EnumName(string raw, const string prefix, const string fallback) { if(StringFind(raw, prefix) == 0) raw = StringSubstr(raw, StringLen(prefix)); StringToLower(raw); string out = ""; for(int i = 0; i < StringLen(raw) && StringLen(out) < 32; i++) { ushort c = StringGetCharacter(raw, i); if((c >= 'a' && c <= 'z') || c == '_') out += ShortToString(c); } return(out == "" ? fallback : out); } string Esc(const string s) { string out = ""; for(int i = 0; i < StringLen(s); i++) { ushort c = StringGetCharacter(s, i); if(c == '"') out += "\\\""; else if(c == '\\') out += "\\\\"; else if(c < 32) out += StringFormat("\\u%04x", c); else out += ShortToString(c); } return(out); } // Header values in ASCII: other bytes (a company name in Cyrillic) as %XX of their UTF-8. string Enc(const string s) { uchar bytes[]; int n = StringToCharArray(s, bytes, 0, WHOLE_ARRAY, CP_UTF8) - 1; string out = ""; for(int i = 0; i < n; i++) { uchar b = bytes[i]; if(b < 32 || b > 126 || b == '%') out += StringFormat("%%%02X", b); else out += CharToString(b); } return(out); } // The raw value of "key" between `from` and `to` (-1 = the end): a number, true/false/null, // or the text of a string. bool JsonRaw(const string json, const string key, const int from, const int to, string &out) { int p = StringFind(json, "\"" + key + "\"", from); if(p < 0 || (to >= 0 && p >= to)) return(false); int n = StringLen(json); p += StringLen(key) + 2; while(p < n && StringGetCharacter(json, p) != ':') p++; p++; while(p < n && StringGetCharacter(json, p) == ' ') p++; if(p >= n) return(false); if(StringGetCharacter(json, p) == '"') { string s = ""; for(int i = p + 1; i < n; i++) { ushort c = StringGetCharacter(json, i); if(c == '\\' && i + 1 < n) { i++; s += ShortToString(StringGetCharacter(json, i)); continue; } if(c == '"') break; s += ShortToString(c); } out = s; return(true); } int e = p; while(e < n) { ushort c = StringGetCharacter(json, e); if(c == ',' || c == '}' || c == ']' || c == ' ') break; e++; } out = StringSubstr(json, p, e - p); return(true); } long JsonLong(const string json, const string key, const long fallback, const int from = 0, const int to = -1) { string v = ""; if(!JsonRaw(json, key, from, to, v) || v == "" || v == "null") return(fallback); return(StringToInteger(v)); } //+------------------------------------------------------------------+