//+------------------------------------------------------------------+ //| GuardianBridge.mq4 - Guardian Copy by SkyEleven (v2) | //| - Manda equity/balance + DETALLE de cada posicion cada 5s | //| - Reporta deals cerrados desde el ultimo heartbeat | //| - Cachea el MODO (monitor/enforcer/copy) del backend | //| - Ejecuta comandos: close_all, close_ticket, force_sl, open | //| - Freeze: cierra al toque tickets nuevos no autorizados | //| - Kill switch: si el backend responde 401, se apaga solo | //| Token va por HEADER (X-EA-Token), no en la URL. | //+------------------------------------------------------------------+ #property copyright "SkyEleven" #property link "https://traderia.skyeleven.com.ar" #property version "2.12" #property strict // v2.12: CAPA 0 — reflejo LOCAL (piso duro + dead-man switch). El EA cierra // todo por su cuenta si el DD flotante entra en zona de muerte (LocalFloorPct) // o si pierde contacto con el server (DeadManMinutes) mientras sangra // (DeadManDDPct). Protege AUNQUE se caiga Railway o internet del VPS. // v2.10: JSON a prueba de locale. StringFormat("%.2f") en Windows en español // escribe "4017,04" (coma) → JSON inválido → el backend rechaza el heartbeat // en silencio. DoubleToString/IntegerToString SIEMPRE usan punto. string N2(double v) { return DoubleToString(v, 2); } string N5(double v) { return DoubleToString(v, 5); } string NI(long v) { return IntegerToString((int)v); } input string GuardianURL = "https://traderia.skyeleven.com.ar"; input string EAToken = ""; // Pega aqui el token que te dio Guardian input int HeartbeatSecs = 5; input int ModeRefreshMin = 5; // cada cuantos minutos refresca el modo input bool AllowClose = true; // permitir cierre remoto (ademas el plan manda) // ── CAPA 0: reflejo LOCAL (funciona aunque se caiga el servidor/internet) ── input bool LocalFloorOn = true; // piso duro local: cierra todo si se pasa input double LocalFloorPct = 50.0; // DD flotante % (vs balance) = ZONA DE MUERTE → cerrar todo local input int DeadManMinutes = 3; // sin contacto con el server por X min... input double DeadManDDPct = 25.0; // ...Y flotando peor que esto → el EA corta solo datetime lastBeat = 0; datetime lastModeRefresh = 0; datetime lastClosedReported = 0; // ultimo OrderCloseTime ya reportado datetime lastServerOK = 0; // ultimo heartbeat aceptado por el server bool g_localTripped = false; // ya disparo el reflejo local (evita spam) string g_mode = "monitor"; bool g_freeze = false; bool g_active = true; // kill switch local int OnInit() { if(StringLen(EAToken) < 10) { Print("[Guardian] ERROR: EAToken vacio. Pega el token en Inputs."); return INIT_PARAMETERS_INCORRECT; } lastClosedReported = TimeCurrent(); // no reportar historia vieja al arrancar Print("[Guardian] Conectado a ", GuardianURL); RefreshMode(); EventSetTimer(HeartbeatSecs); return INIT_SUCCEEDED; } void OnDeinit(const int reason) { EventKillTimer(); } void OnTimer() { if(!g_active) return; LocalSafetyCheck(); // CAPA 0 primero: no depende del server SendHeartbeat(); if(g_freeze) EnforceFreeze(); string cmd = GetPendingCommand(); if(StringLen(cmd) > 0) { Print("[Guardian] Comando: ", cmd); ExecuteCommand(cmd); } if(TimeCurrent() - lastModeRefresh >= ModeRefreshMin * 60) RefreshMode(); } void OnTick() { if(!g_active) return; LocalSafetyCheck(); // reflejo local en cada tick (reacciona rapido) if(g_freeze) EnforceFreeze(); } //+------------------------------------------------------------------+ //| CAPA 0 — Reflejo LOCAL. El airbag que funciona aunque el | //| servidor/internet esten caidos. Ultima linea de defensa. | //+------------------------------------------------------------------+ void LocalSafetyCheck() { if(!LocalFloorOn) return; double equity = AccountEquity(); double balance = AccountBalance(); if(balance <= 0) return; double floatDD = (balance - equity) / balance * 100.0; // % flotante negativo // Reset del latch cuando la cuenta vuelve a estar plana/sana if(g_localTripped && OrdersTotal() == 0) g_localTripped = false; if(g_localTripped) return; // 1) Piso duro: zona de muerte, se pase lo que se pase con el server if(floatDD >= LocalFloorPct) { Print("[Guardian] PISO LOCAL: DD flotante ", DoubleToString(floatDD,1), "% >= ", DoubleToString(LocalFloorPct,1), "% -> CERRAR TODO (local)"); CloseAllPositions(); g_localTripped = true; return; } // 2) Dead-man switch: sin contacto con el server + sangrando = el EA corta solo bool noServer = (lastServerOK > 0) && (TimeCurrent() - lastServerOK >= DeadManMinutes * 60); if(noServer && floatDD >= DeadManDDPct) { Print("[Guardian] DEAD-MAN: sin server hace ", (int)((TimeCurrent()-lastServerOK)/60), " min y DD ", DoubleToString(floatDD,1), "% -> CERRAR TODO (local)"); CloseAllPositions(); g_localTripped = true; } } //+------------------------------------------------------------------+ //| Heartbeat: cuenta + posiciones + deals cerrados | //+------------------------------------------------------------------+ void SendHeartbeat() { double equity = AccountEquity(); double balance = AccountBalance(); double mfree = AccountFreeMargin(); double mlevel = AccountMargin() > 0 ? (equity / AccountMargin()) * 100.0 : 0.0; int posCount = 0; string positions = BuildPositionsJson(posCount); string closed = BuildClosedDealsJson(); string body = "{\"login\":\"" + NI(AccountNumber()) + "\"" + ",\"equity\":" + N2(equity) + ",\"balance\":" + N2(balance) + ",\"margin_free\":" + N2(mfree) + ",\"margin_level\":" + N2(mlevel) + ",\"positions_count\":" + NI(posCount) + ",\"positions\":" + positions + ",\"closed_deals\":" + closed + "}"; int status = 0; HttpPost("/api/guardian/ea/heartbeat", body, status); if(status >= 200 && status < 300) lastServerOK = TimeCurrent(); // server vivo → dead-man en reposo HandleAuth(status); } string BuildPositionsJson(int &count) { string out = "["; count = 0; for(int i = 0; i < OrdersTotal(); i++) { if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue; if(OrderType() != OP_BUY && OrderType() != OP_SELL) continue; string side = (OrderType() == OP_BUY) ? "buy" : "sell"; if(count > 0) out += ","; out += "{\"ticket\":" + NI(OrderTicket()) + ",\"symbol\":\"" + OrderSymbol() + "\",\"type\":\"" + side + "\"" + ",\"lots\":" + N2(OrderLots()) + ",\"open_price\":" + N5(OrderOpenPrice()) + ",\"sl\":" + N5(OrderStopLoss()) + ",\"tp\":" + N5(OrderTakeProfit()) + ",\"profit\":" + N2(OrderProfit()) + ",\"swap\":" + N2(OrderSwap()) + ",\"comment\":\"" + JsonEscape(OrderComment()) + "\",\"magic\":" + NI(OrderMagicNumber()) + "}"; count++; } out += "]"; return out; } string BuildClosedDealsJson() { string out = "["; int n = 0; datetime maxClose = lastClosedReported; for(int i = OrdersHistoryTotal() - 1; i >= 0; i--) { if(!OrderSelect(i, SELECT_BY_POS, MODE_HISTORY)) continue; if(OrderType() != OP_BUY && OrderType() != OP_SELL) continue; datetime ct = OrderCloseTime(); if(ct <= lastClosedReported) continue; // ya reportado string side = (OrderType() == OP_BUY) ? "buy" : "sell"; if(n > 0) out += ","; out += "{\"ticket\":" + NI(OrderTicket()) + ",\"symbol\":\"" + OrderSymbol() + "\",\"type\":\"" + side + "\"" + ",\"lots\":" + N2(OrderLots()) + ",\"open_price\":" + N5(OrderOpenPrice()) + ",\"close_price\":" + N5(OrderClosePrice()) + ",\"profit\":" + N2(OrderProfit()) + ",\"swap\":" + N2(OrderSwap()) + ",\"commission\":" + N2(OrderCommission()) + ",\"comment\":\"" + JsonEscape(OrderComment()) + "\",\"magic\":" + NI(OrderMagicNumber()) + "}"; if(ct > maxClose) maxClose = ct; n++; if(n >= 30) break; // tanda maxima por heartbeat } out += "]"; lastClosedReported = maxClose; return out; } //+------------------------------------------------------------------+ //| Modo (monitor/enforcer/copy) + freeze | //+------------------------------------------------------------------+ void RefreshMode() { int status = 0; string resp = HttpGet("/api/guardian/ea/mode", status); HandleAuth(status); if(StringLen(resp) == 0) return; g_mode = JsonStr(resp, "mode"); g_freeze = (StringFind(resp, "\"freeze_new\":true") >= 0); lastModeRefresh = TimeCurrent(); Comment("Guardian: modo=", g_mode, " freeze=", (g_freeze?"SI":"no")); } // Freeze = frenar aperturas nuevas: cerramos al toque cualquier ticket // abierto DESPUES de que arranco el lockout (enforcer/copy). void EnforceFreeze() { if(g_mode == "monitor") return; for(int i = OrdersTotal() - 1; i >= 0; i--) { if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue; if(OrderType() != OP_BUY && OrderType() != OP_SELL) continue; if(OrderOpenTime() >= lastModeRefresh - 5) { // recien abierto durante el freeze double price = (OrderType() == OP_BUY) ? Bid : Ask; bool ok = OrderClose(OrderTicket(), OrderLots(), price, 5, clrOrange); Print("[Guardian] Freeze: cerre ticket nuevo ", OrderTicket(), " ok=", ok); } } } //+------------------------------------------------------------------+ //| Comandos | //+------------------------------------------------------------------+ string GetPendingCommand() { int status = 0; string response = HttpGet("/api/guardian/ea/commands", status); HandleAuth(status); return JsonStr(response, "command"); } void ExecuteCommand(string cmd) { if(cmd == "close_all") { if(!AllowClose) { AckCommand(cmd, "blocked_local"); return; } CloseAllPositions(); AckCommand(cmd, "ok"); } else if(StringFind(cmd, "close_ticket:") == 0) { if(!AllowClose) { AckCommand(cmd, "blocked_local"); return; } int t = (int)StringToInteger(StringSubstr(cmd, 13)); AckCommand(cmd, CloseByTicket(t) ? "ok" : "error"); } else if(StringFind(cmd, "force_sl:") == 0) { // force_sl:ticket:precio string rest = StringSubstr(cmd, 9); int sep = StringFind(rest, ":"); if(sep > 0) { int t = (int)StringToInteger(StringSubstr(rest, 0, sep)); double px = StringToDouble(StringSubstr(rest, sep + 1)); AckCommand(cmd, ForceSL(t, px) ? "ok" : "error"); } else AckCommand(cmd, "bad_params"); } else if(StringFind(cmd, "open:") == 0) { // open:SIMBOLO:side:lots:sl:tp (solo modo copy) if(g_mode != "copy") { AckCommand(cmd, "not_copy_mode"); return; } AckCommand(cmd, OpenFromCmd(StringSubstr(cmd, 5)) ? "ok" : "error"); } else AckCommand(cmd, "unknown_command"); } void CloseAllPositions() { for(int i = OrdersTotal() - 1; i >= 0; i--) { if(OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) { if(OrderType() == OP_BUY || OrderType() == OP_SELL) { double price = (OrderType() == OP_BUY) ? Bid : Ask; bool ok = OrderClose(OrderTicket(), OrderLots(), price, 5, clrRed); Print("[Guardian] Close ", OrderTicket(), " ok=", ok); } } } } bool CloseByTicket(int ticket) { if(!OrderSelect(ticket, SELECT_BY_TICKET)) return false; if(OrderType() != OP_BUY && OrderType() != OP_SELL) return false; double price = (OrderType() == OP_BUY) ? Bid : Ask; return OrderClose(ticket, OrderLots(), price, 5, clrRed); } bool ForceSL(int ticket, double sl) { if(!OrderSelect(ticket, SELECT_BY_TICKET)) return false; if(OrderType() != OP_BUY && OrderType() != OP_SELL) return false; return OrderModify(ticket, OrderOpenPrice(), sl, OrderTakeProfit(), 0, clrYellow); } bool OpenFromCmd(string params) { // SIMBOLO:side:lots:sl:tp string parts[]; int k = StringSplit(params, ':', parts); if(k < 3) return false; string sym = parts[0]; int type = (parts[1] == "buy") ? OP_BUY : OP_SELL; double lots = StringToDouble(parts[2]); double sl = (k > 3) ? StringToDouble(parts[3]) : 0; double tp = (k > 4) ? StringToDouble(parts[4]) : 0; double price = (type == OP_BUY) ? MarketInfo(sym, MODE_ASK) : MarketInfo(sym, MODE_BID); int t = OrderSend(sym, type, lots, price, 5, sl, tp, "Guardian copy", 0, 0, clrBlue); return (t > 0); } void AckCommand(string cmd, string result) { string body = StringFormat("{\"command\":\"%s\",\"result\":\"%s\"}", cmd, result); int status = 0; HttpPost("/api/guardian/ea/ack", body, status); } //+------------------------------------------------------------------+ //| Kill switch: 401 → apagar EA | //+------------------------------------------------------------------+ void HandleAuth(int status) { if(status == 401) { g_active = false; EventKillTimer(); Comment("⛔ GUARDIAN DESACTIVADO — token revocado por el servidor."); Print("[Guardian] 401: token revocado. EA desactivado."); } } //+------------------------------------------------------------------+ //| HTTP helpers (token por HEADER) | //+------------------------------------------------------------------+ string HttpPost(string path, string body, int &statusOut) { string url = GuardianURL + path; string headers = "Content-Type: application/json\r\nX-EA-Token: " + EAToken + "\r\n"; // v2.11: StringToCharArray con count por defecto incluye el null final; // el resize -1 quita SOLO ese null. (El bug anterior copiaba sin null y el // resize cortaba la ULTIMA LLAVE del JSON -> 422 silencioso en el server.) char post[]; StringToCharArray(body, post); ArrayResize(post, ArraySize(post) - 1); char result[]; string rh; int res = WebRequest("POST", url, headers, 5000, post, result, rh); statusOut = res; if(res == -1) { int err = GetLastError(); Print("[Guardian] POST error ", err, " url=", url); if(err == 4060) Print("[Guardian] Habilita la URL en Herramientas->Opciones->Asesores expertos->WebRequest"); return ""; } if(res >= 400) Print("[Guardian] POST HTTP ", res, " ", path, " resp=", CharArrayToString(result)); return CharArrayToString(result); } string HttpGet(string path, int &statusOut) { string url = GuardianURL + path; string headers = "X-EA-Token: " + EAToken + "\r\n"; char post[]; char result[]; string rh; int res = WebRequest("GET", url, headers, 5000, post, result, rh); statusOut = res; if(res == -1) { Print("[Guardian] GET error ", GetLastError(), " url=", url); return ""; } return CharArrayToString(result); } //+------------------------------------------------------------------+ //| Mini JSON helpers | //+------------------------------------------------------------------+ string JsonStr(string json, string key) { string pat = "\"" + key + "\":\""; int p = StringFind(json, pat); if(p < 0) return ""; int start = p + StringLen(pat); int end = StringFind(json, "\"", start); if(end < 0) return ""; return StringSubstr(json, start, end - start); } string JsonEscape(string s) { string out = ""; for(int i = 0; i < StringLen(s); i++) { ushort c = StringGetCharacter(s, i); if(c == '"' || c == '\\') out += "\\"; if(c == '\n' || c == '\r') continue; out += ShortToString(c); } return out; }