//+------------------------------------------------------------------+ //| CSDPEncoder.mqh | //| Centaur Quant Architecture — Network Module | //| Standardized Data Protocol (SDP) JSON Serializer | //+------------------------------------------------------------------+ //| PURPOSE | //| Single source of truth for formatting every outgoing JSON | //| payload from the MT5 EA. Enforces the SDP envelope schema, | //| dot-decimal number formatting, JSON string escaping, and | //| ISO-8601 UTC timestamps. Raw JSON building is FORBIDDEN | //| anywhere else in the MQL5 codebase. | //+------------------------------------------------------------------+ #property strict #ifndef CSDPENCODER_MQH #define CSDPENCODER_MQH //--- SDP action types. Wire names are stable contracts for the Python router. --- enum ENUM_SDP_ACTION { SDP_ACTION_HEARTBEAT = 0, // Heartbeat SDP_ACTION_TICK_HARVEST = 1, // Tick_Harvest SDP_ACTION_SETUP_DETECTED = 2, // Setup_Detected SDP_ACTION_TRADE_OPENED = 3, // Trade_Opened SDP_ACTION_TRADE_CLOSED = 4 // Trade_Closed }; //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ class CSDPEncoder { private: string m_sdp_version; // wire SDP protocol version carried by every envelope //--- wire name of an action (single source of truth for the enum) --- string ActionToString(const ENUM_SDP_ACTION action); //--- ISO-8601 UTC "2026-08-12T09:20:00.000Z" from any datetime --- string IsoTimestamp(const datetime t); string IsoTimestampUTC(); // convenience: uses TimeGMT() //--- JSON-safe string escaping (" \ \n \r \t + control chars) --- string EscapeJson(const string value); //--- dot-decimal number formatting; never locale-dependent --- string FormatDouble(const double value, const int digits); //--- wire name of an ENUM_TIMEFRAMES value (e.g., PERIOD_M15 -> "M15") --- string PeriodName(const ENUM_TIMEFRAMES period); //--- resolve empty timeframe to the current chart period --- string ResolveTimeframe(const string timeframe); //--- live digits of a symbol; 8 as a neutral formatting fallback --- int SymbolDigits(const string symbol); //--- clamp confidence into [0.0, 100.0]; logs on out-of-range/NaN --- double ClampConfidence(const double confidence); //--- assemble the full SDP envelope around a prebuilt payload object --- string BuildEnvelope(const ENUM_SDP_ACTION action, const string symbol, const string timeframe, const double confidence, const string payload_json, const string historical_context_json = ""); public: CSDPEncoder(); //--- SDP protocol version embedded in every envelope --- string Version() const { return m_sdp_version; } //--- action-specific serializers; each returns "" on invalid input --- string EncodeHeartbeat(const string symbol, const string timeframe); string EncodeTickHarvest(const string symbol, const string timeframe, const double bid, const double ask, const datetime tick_time, const long tick_volume); string EncodeSetup(const string symbol, const string timeframe, const string setup_type, const double entry, const double sl, const double tp, const double confidence, const string historical_context_json = ""); string EncodeTradeOpened(const string symbol, const string timeframe, const ulong ticket, const ENUM_POSITION_TYPE side, const double lot, const double entry_price, const double sl, const double tp, const double confidence, const string historical_context_json = ""); string EncodeTradeClosed(const string symbol, const ulong ticket, const double profit, const double r_multiple, const double initial_ai_score, const string timeframe = ""); //--- charter-required historical context array serializer --- string BuildHistoricalContext(const string symbol, const double &swing_high[], const double &swing_low[], const datetime &swing_time[]); }; //+------------------------------------------------------------------+ //| Constructor — fix the ratified wire version of the SDP protocol. | //+------------------------------------------------------------------+ CSDPEncoder::CSDPEncoder() : m_sdp_version("1.0.0") { } //+------------------------------------------------------------------+ //| ActionToString — maps the enum to its stable wire string. | //+------------------------------------------------------------------+ string CSDPEncoder::ActionToString(const ENUM_SDP_ACTION action) { switch(action) { case SDP_ACTION_HEARTBEAT: return "Heartbeat"; case SDP_ACTION_TICK_HARVEST: return "Tick_Harvest"; case SDP_ACTION_SETUP_DETECTED: return "Setup_Detected"; case SDP_ACTION_TRADE_OPENED: return "Trade_Opened"; case SDP_ACTION_TRADE_CLOSED: return "Trade_Closed"; default: PrintFormat("[CSDPEncoder] ERROR: unknown action %d. Using 'Unknown'.", (int)action); return "Unknown"; } } //+------------------------------------------------------------------+ //| IsoTimestamp — ISO-8601 UTC with millisecond precision. | //| datetime has 1-second resolution, so the ms field is literal. | //+------------------------------------------------------------------+ string CSDPEncoder::IsoTimestamp(const datetime t) { datetime ts = t; if(ts <= 0) { PrintFormat("[CSDPEncoder] WARNING: invalid timestamp value; using TimeGMT()."); ts = TimeGMT(); } string stamp = TimeToString(ts, TIME_DATE | TIME_SECONDS); StringReplace(stamp, ".", "-"); // 2026-08-12 09:20:00 StringReplace(stamp, " ", "T"); // 2026-08-12T09:20:00 return stamp + ".000Z"; // ISO-8601 UTC, millisecond field } //+------------------------------------------------------------------+ //| IsoTimestampUTC — current GMT wall clock as an ISO-8601 string. | //+------------------------------------------------------------------+ string CSDPEncoder::IsoTimestampUTC() { return IsoTimestamp(TimeGMT()); } //+------------------------------------------------------------------+ //| EscapeJson — escapes every character that would break JSON: | //| quotes, backslashes, control codes, and all bytes < 0x20 | //| (\\b, \\f, \\v etc. are emitted as valid \\uXXXX escapes). | //+------------------------------------------------------------------+ string CSDPEncoder::EscapeJson(const string value) { string result = ""; const int len = StringLen(value); for(int i = 0; i < len; i++) { const ushort c = StringGetCharacter(value, i); switch(c) { case '"': result += "\\\""; break; case '\\': result += "\\\\"; break; case '\n': result += "\\n"; break; case '\r': result += "\\r"; break; case '\t': result += "\\t"; break; default: if(c < 0x20) result += StringFormat("\\u%04x", c); // backspace, form feed, etc. else result += ShortToString(c); break; } } return result; } //+------------------------------------------------------------------+ //| PeriodName — wire name of a timeframe value. | //| PERIOD_CURRENT resolves to the actual chart period. | //+------------------------------------------------------------------+ string CSDPEncoder::PeriodName(const ENUM_TIMEFRAMES period) { ENUM_TIMEFRAMES p = period; if(p == PERIOD_CURRENT) p = (ENUM_TIMEFRAMES)_Period; string name = EnumToString(p); // e.g. "PERIOD_M15" StringReplace(name, "PERIOD_", ""); // "M15" return name; } //+------------------------------------------------------------------+ //| FormatDouble — dot-decimal string. DoubleToString already emits a | //| dot; the replace is a defensive guard against any locale quirk. | //+------------------------------------------------------------------+ string CSDPEncoder::FormatDouble(const double value, const int digits) { string s = DoubleToString(value, digits); StringReplace(s, ",", "."); return s; } //+------------------------------------------------------------------+ //| ResolveTimeframe — trims the input; empty falls back to the | //| current chart period so the envelope's timeframe key is never | //| blank (the schema mandates it for every action). | //+------------------------------------------------------------------+ string CSDPEncoder::ResolveTimeframe(const string timeframe) { string tf = timeframe; StringTrimLeft(tf); StringTrimRight(tf); if(StringLen(tf) == 0) { tf = PeriodName((ENUM_TIMEFRAMES)_Period); PrintFormat("[CSDPEncoder] WARNING: empty timeframe; falling back to current chart '%s'.", tf); } return tf; } //+------------------------------------------------------------------+ //| SymbolDigits — live SYMBOL_DIGITS for price formatting. 8 is a | //| neutral fallback only (covers crypto-style precision); it is not | //| a per-symbol point buffer. | //+------------------------------------------------------------------+ int CSDPEncoder::SymbolDigits(const string symbol) { int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS); if(digits <= 0) { PrintFormat("[CSDPEncoder] WARNING: SYMBOL_DIGITS unavailable for %s; using 8.", symbol); digits = 8; } return digits; } //+------------------------------------------------------------------+ //| ClampConfidence — enforces the SDP contract 0.0 <= score <= 100.0.| //+------------------------------------------------------------------+ double CSDPEncoder::ClampConfidence(const double confidence) { if(!MathIsValidNumber(confidence)) { PrintFormat("[CSDPEncoder] ERROR: confidence is NaN/Inf; using 0.0."); return 0.0; } if(confidence < 0.0 || confidence > 100.0) { PrintFormat("[CSDPEncoder] WARNING: confidence %G outside [0,100]; clamped.", confidence); return MathMax(0.0, MathMin(confidence, 100.0)); } return confidence; } //+------------------------------------------------------------------+ //| BuildEnvelope — assembles the mandatory SDP envelope around a | //| prebuilt payload object. Adds the charter-mandated | //| "historical_context" key only when a non-empty array is supplied. | //+------------------------------------------------------------------+ string CSDPEncoder::BuildEnvelope(const ENUM_SDP_ACTION action, const string symbol, const string timeframe, const double confidence, const string payload_json, const string historical_context_json) { const string tf = ResolveTimeframe(timeframe); if(StringLen(symbol) == 0) { PrintFormat("[CSDPEncoder] ERROR: empty symbol for action %s. Envelope not built.", ActionToString(action)); return ""; } if(StringLen(payload_json) == 0 || StringGetCharacter(payload_json, 0) != '{') { PrintFormat("[CSDPEncoder] ERROR: invalid payload object for action %s. Envelope not built.", ActionToString(action)); return ""; } string env = "{"; env += "\"sdp_version\":" + "\"" + m_sdp_version + "\","; env += "\"timestamp\":\"" + IsoTimestampUTC() + "\","; env += "\"symbol\":\"" + EscapeJson(symbol) + "\","; env += "\"timeframe\":\"" + EscapeJson(tf) + "\","; env += "\"action_type\":\"" + ActionToString(action) + "\","; env += "\"algorithmic_confidence_score\":" + FormatDouble(ClampConfidence(confidence), 2); // Charter: recent swing context MUST ride Setup_Detected / Trade_* actions. if(StringLen(historical_context_json) > 0) env += ",\"historical_context\":" + historical_context_json; env += ",\"payload\":" + payload_json; env += "}"; return env; } //+------------------------------------------------------------------+ //| EncodeHeartbeat — lightweight liveness signal. Confidence is not | //| applicable for heartbeats, so the envelope carries 0.0. | //+------------------------------------------------------------------+ string CSDPEncoder::EncodeHeartbeat(const string symbol, const string timeframe) { const string payload = "{\"status\":\"alive\"}"; return BuildEnvelope(SDP_ACTION_HEARTBEAT, symbol, timeframe, 0.0, payload); } //+------------------------------------------------------------------+ //| EncodeTickHarvest — one tick for the Data Harvest Core. Raw bid/ | //| ask in price terms plus spread in points and tick volume. | //+------------------------------------------------------------------+ string CSDPEncoder::EncodeTickHarvest(const string symbol, const string timeframe, const double bid, const double ask, const datetime tick_time, const long tick_volume) { if(!MathIsValidNumber(bid) || !MathIsValidNumber(ask) || bid <= 0.0 || ask <= 0.0) { PrintFormat("[CSDPEncoder] ERROR: invalid bid/ask (%G / %G) for %s. Tick_Harvest dropped.", bid, ask, symbol); return ""; } if(ask < bid) { PrintFormat("[CSDPEncoder] WARNING: ask %G < bid %G for %s; values forwarded as-is.", ask, bid, symbol); } const int digits = SymbolDigits(symbol); const double point = SymbolInfoDouble(symbol, SYMBOL_POINT); const long spread_points = (point > 0.0) ? (long)MathRound((ask - bid) / point) : 0; string payload = "{"; payload += "\"bid\":" + FormatDouble(bid, digits) + ","; payload += "\"ask\":" + FormatDouble(ask, digits) + ","; payload += "\"spread_points\":" + IntegerToString(spread_points) + ","; payload += "\"tick_time\":\"" + IsoTimestamp(tick_time) + "\","; payload += "\"tick_volume\":" + IntegerToString(tick_volume); payload += "}"; return BuildEnvelope(SDP_ACTION_TICK_HARVEST, symbol, timeframe, 0.0, payload); } //+------------------------------------------------------------------+ //| EncodeSetup — a detected setup proposal. Carries prices, the | //| setup label, and a computed |R:R| for the Python side. | //+------------------------------------------------------------------+ string CSDPEncoder::EncodeSetup(const string symbol, const string timeframe, const string setup_type, const double entry, const double sl, const double tp, const double confidence, const string historical_context_json) { if(StringLen(setup_type) == 0) { PrintFormat("[CSDPEncoder] ERROR: empty setup_type for %s. Setup_Detected dropped.", symbol); return ""; } if(!MathIsValidNumber(entry) || !MathIsValidNumber(sl) || !MathIsValidNumber(tp) || entry <= 0.0 || sl <= 0.0 || tp <= 0.0) { PrintFormat("[CSDPEncoder] ERROR: invalid prices (entry=%G sl=%G tp=%G) for %s. Setup_Detected dropped.", entry, sl, tp, symbol); return ""; } const int digits = SymbolDigits(symbol); double rr = 0.0; const double risk = entry - sl; if(risk != 0.0) rr = MathAbs((tp - entry) / risk); string payload = "{"; payload += "\"setup_type\":\"" + EscapeJson(setup_type) + "\","; payload += "\"entry\":" + FormatDouble(entry, digits) + ","; payload += "\"sl\":" + FormatDouble(sl, digits) + ","; payload += "\"tp\":" + FormatDouble(tp, digits) + ","; payload += "\"risk_reward\":" + FormatDouble(rr, 2); payload += "}"; return BuildEnvelope(SDP_ACTION_SETUP_DETECTED, symbol, timeframe, confidence, payload, historical_context_json); } //+------------------------------------------------------------------+ //| EncodeTradeOpened — actual execution record: ticket, direction, | //| lot, and the live levels. Confidence reflects the score at open. | //+------------------------------------------------------------------+ string CSDPEncoder::EncodeTradeOpened(const string symbol, const string timeframe, const ulong ticket, const ENUM_POSITION_TYPE side, const double lot, const double entry_price, const double sl, const double tp, const double confidence, const string historical_context_json) { string side_str = ""; if(side == POSITION_TYPE_BUY) side_str = "buy"; else if(side == POSITION_TYPE_SELL) side_str = "sell"; else { PrintFormat("[CSDPEncoder] ERROR: invalid position side %d for %s. Trade_Opened dropped.", (int)side, symbol); return ""; } if(ticket == 0) { PrintFormat("[CSDPEncoder] ERROR: zero ticket for %s. Trade_Opened dropped.", symbol); return ""; } if(!MathIsValidNumber(lot) || lot <= 0.0 || !MathIsValidNumber(entry_price) || entry_price <= 0.0 || !MathIsValidNumber(sl) || sl <= 0.0 || !MathIsValidNumber(tp) || tp <= 0.0) { PrintFormat("[CSDPEncoder] ERROR: invalid trade fields (lot=%G entry=%G sl=%G tp=%G) for %s. Trade_Opened dropped.", lot, entry_price, sl, tp, symbol); return ""; } const int digits = SymbolDigits(symbol); string payload = "{"; payload += "\"ticket\":" + IntegerToString(ticket) + ","; payload += "\"direction\":\"" + side_str + "\","; payload += "\"lot\":" + FormatDouble(lot, 2) + ","; payload += "\"entry_price\":" + FormatDouble(entry_price, digits) + ","; payload += "\"sl\":" + FormatDouble(sl, digits) + ","; payload += "\"tp\":" + FormatDouble(tp, digits); payload += "}"; return BuildEnvelope(SDP_ACTION_TRADE_OPENED, symbol, timeframe, confidence, payload, historical_context_json); } //+------------------------------------------------------------------+ //| EncodeTradeClosed — closes the feedback loop: final PnL and | //| R-multiple linked to the original AI score. Envelope confidence | //| carries that original score (schema mandates the field). | //+------------------------------------------------------------------+ string CSDPEncoder::EncodeTradeClosed(const string symbol, const ulong ticket, const double profit, const double r_multiple, const double initial_ai_score, const string timeframe) { if(StringLen(symbol) == 0 || ticket == 0) { PrintFormat("[CSDPEncoder] ERROR: invalid symbol/ticket ('%s', %I64u). Trade_Closed dropped.", symbol, ticket); return ""; } if(!MathIsValidNumber(profit) || !MathIsValidNumber(r_multiple)) { PrintFormat("[CSDPEncoder] ERROR: invalid profit/R-multiple (%G / %G) for ticket %I64u. Trade_Closed dropped.", profit, r_multiple, ticket); return ""; } string payload = "{"; payload += "\"ticket\":" + IntegerToString(ticket) + ","; payload += "\"profit\":" + FormatDouble(profit, 2) + ","; payload += "\"r_multiple\":" + FormatDouble(r_multiple, 2) + ","; payload += "\"initial_ai_score\":" + FormatDouble(ClampConfidence(initial_ai_score), 2); payload += "}"; return BuildEnvelope(SDP_ACTION_TRADE_CLOSED, symbol, timeframe, initial_ai_score, payload); } //+------------------------------------------------------------------+ //| BuildHistoricalContext — serializes recent swing structure into a | //| JSON array for the charter-mandated "historical_context" key. | //| Arrays must be equal-length and non-empty; "" returned otherwise. | //+------------------------------------------------------------------+ string CSDPEncoder::BuildHistoricalContext(const string symbol, const double &swing_high[], const double &swing_low[], const datetime &swing_time[]) { const int n_high = ArraySize(swing_high); const int n_low = ArraySize(swing_low); const int n_time = ArraySize(swing_time); if(n_high == 0 || n_high != n_low || n_high != n_time) { PrintFormat("[CSDPEncoder] ERROR: historical context arrays mismatch (%d/%d/%d). Empty array returned.", n_high, n_low, n_time); return ""; } const int digits = SymbolDigits(symbol); string arr = "["; for(int i = 0; i < n_high; i++) { if(i > 0) arr += ","; arr += "{\"swing_high\":" + FormatDouble(swing_high[i], digits) + ","; arr += "\"swing_low\":" + FormatDouble(swing_low[i], digits) + ","; arr += "\"time\":\"" + IsoTimestamp(swing_time[i]) + "\"}"; } arr += "]"; return arr; } #endif // CSDPENCODER_MQH