//+------------------------------------------------------------------+ //| CLLMClient.mqh | //| Centaur Quant Architecture — Models Module | //| "The Brain" Client for MQL5 — File-Based Periodic Bridge | //+------------------------------------------------------------------+ //| PURPOSE | //| Kanal LLM dari EA TANPA server HTTP dan tanpa WebRequest: | //| EA menulis MQL5\Files\ai_request.json (envelope SDP) | //| Python assistant_bridge.py polling periodik -> LLM | //| EA membaca MQL5\Files\ai_response.json (id+score+reason) | //| Satu panggilan LLM per setup (dedupe by id) — hemat token. | //| Anti-Veto: tanpa response sebelum deadline -> 40.0 (quarter-risk)| //+------------------------------------------------------------------+ #property strict #ifndef LLMCLIENT_MQH #define LLMCLIENT_MQH #define CLLM_FALLBACK_SCORE 40.0 #define CLLM_MAX_REASON_WORDS 10 #define CLLM_REQ_FILE "ai_request.json" #define CLLM_RES_FILE "ai_response.json" //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ class CLLMClient { private: int m_timeout_ms; // fallback deadline untuk menunggu response bool m_ready; bool m_pending; // request sedang menunggu response string m_pending_id; // id request aktif (dedupe di bridge) ulong m_deadline_ms; // GetTickCount64() batas tunggu ulong m_id_seq; // S6: penambah unik id (anti-collision 1 ms) string EscapeJson(const string value); string BuildRequestBody(const string symbol, const string timeframe, const string setup_type, const double entry, const double sl, const double tp, const string historical_context_json); bool ParseScoreDirect(const string raw, double &score, string &reason); public: CLLMClient(const int timeout_ms = 15000); ~CLLMClient(); bool IsReady() const { return m_ready; } bool IsPending() const { return m_pending; } bool DeadlinePassed() const { return (m_pending && GetTickCount64() > m_deadline_ms); } //--- tulis request (file); true bila terkirim --- bool SendRequest(const string symbol, const string timeframe, const string setup_type, const double entry, const double sl, const double tp, const string historical_context_json); //--- baca response (file) bila id cocok; konsumsi file --- bool TryReadResponse(double &score, string &reason); //--- batalkan pending (fallback / timeout) --- void ResetPending(); }; //+------------------------------------------------------------------+ //| Constructor — timeout = batas tunggu response (ms). | //+------------------------------------------------------------------+ CLLMClient::CLLMClient(const int timeout_ms) : m_timeout_ms(MathMax(timeout_ms, 3000)), m_ready(true), m_pending(false), m_pending_id(""), m_deadline_ms(0), m_id_seq(0) { PrintFormat("[CLLMClient] INFO: ready (file-based bridge; fallback timeout %d ms).", m_timeout_ms); } //+------------------------------------------------------------------+ //| Destructor — nothing to release. | //+------------------------------------------------------------------+ CLLMClient::~CLLMClient() { } //+------------------------------------------------------------------+ //| SendRequest — tulis envelope SDP ke ai_request.json (truncate). | //| id = GetTickCount64() agar bridge mendeteksi request baru. | //+------------------------------------------------------------------+ bool CLLMClient::SendRequest(const string symbol, const string timeframe, const string setup_type, const double entry, const double sl, const double tp, const string historical_context_json) { if(!m_ready) return false; if(m_pending) { PrintFormat("[CLLMClient] WARNING: request sebelumnya masih pending. Ditimpa."); } m_pending_id = IntegerToString((ulong)GetTickCount64()) + "-" + IntegerToString(m_id_seq++); const string body = BuildRequestBody(symbol, timeframe, setup_type, entry, sl, tp, historical_context_json); const string tmp_path = CLLM_REQ_FILE + ".tmp"; // S3: tulis atomik, rename setelah close const int h = FileOpen(tmp_path, FILE_WRITE | FILE_TXT | FILE_ANSI); if(h == INVALID_HANDLE) { PrintFormat("[CLLMClient] ERROR: FileOpen(%s) gagal. GetLastError=%d.", CLLM_REQ_FILE, GetLastError()); return false; } FileWrite(h, body); FileClose(h); if(!FileMove(tmp_path, 0, CLLM_REQ_FILE, FILE_REWRITE)) // S3: rename atomik { PrintFormat("[CLLMClient] ERROR: FileMove ke %s gagal. GetLastError=%d.", CLLM_REQ_FILE, GetLastError()); return false; } m_pending = true; m_deadline_ms = GetTickCount64() + m_timeout_ms; PrintFormat("[CLLMClient] INFO: request %s dikirim (LLM async).", m_pending_id); return true; } //+------------------------------------------------------------------+ //| TryReadResponse — baca ai_response.json; konsumsi hanya bila id | //| cocok dengan request aktif. File dihapus setelah dibaca. | //+------------------------------------------------------------------+ bool CLLMClient::TryReadResponse(double &score, string &reason) { score = -1.0; reason = ""; if(!m_pending) return false; const int h = FileOpen(CLLM_RES_FILE, FILE_READ | FILE_TXT | FILE_ANSI); if(h == INVALID_HANDLE) return false; // belum ada response string content = ""; while(!FileIsEnding(h)) content += FileReadString(h); FileClose(h); //--- cocokkan id request --- const string id_key = "\"id\":\""; const int ip = StringFind(content, id_key); if(ip >= 0) { string rid = StringSubstr(content, ip + StringLen(id_key)); const int ic = StringFind(rid, "\""); if(ic >= 0) rid = StringSubstr(rid, 0, ic); if(rid != m_pending_id) { PrintFormat("[CLLMClient] INFO: response id %s != request %s (menunggu).", rid, m_pending_id); return false; // response lama/menengah — jangan dikonsumsi } } else return false; if(!ParseScoreDirect(content, score, reason)) { PrintFormat("[CLLMClient] WARNING: response tidak punya score. Neutral %.1f.", CLLM_FALLBACK_SCORE); FileDelete(CLLM_RES_FILE); m_pending = false; score = CLLM_FALLBACK_SCORE; return true; } FileDelete(CLLM_RES_FILE); // konsumsi (proses sekali saja) m_pending = false; PrintFormat("[CLLMClient] INFO: response diterima — score %.2f.", score); return true; } //+------------------------------------------------------------------+ //| ResetPending — batalkan tunggu (fallback timeout / manual). | //+------------------------------------------------------------------+ void CLLMClient::ResetPending() { m_pending = false; m_pending_id = ""; FileDelete(CLLM_RES_FILE); } //+------------------------------------------------------------------+ //| BuildRequestBody — envelope SDP lengkap untuk MarketAnalyzer. | //+------------------------------------------------------------------+ string CLLMClient::BuildRequestBody(const string symbol, const string timeframe, const string setup_type, const double entry, const double sl, const double tp, const string historical_context_json) { string b = "{"; b += "\"id\":\"" + m_pending_id + "\","; b += "\"symbol\":\"" + EscapeJson(symbol) + "\","; b += "\"timeframe\":\"" + EscapeJson(timeframe) + "\","; b += "\"action_type\":\"Setup_Detected\","; b += "\"algorithmic_confidence_score\":0,"; b += "\"payload\":{"; b += "\"setup_type\":\"" + EscapeJson(setup_type) + "\","; b += "\"entry\":" + DoubleToString(entry, 5) + ","; b += "\"sl\":" + DoubleToString(sl, 5) + ","; b += "\"tp\":" + DoubleToString(tp, 5); b += "},"; b += "\"historical_context\":"; b += (StringLen(historical_context_json) > 0) ? historical_context_json : "[]"; b += "}"; return b; } //+------------------------------------------------------------------+ //| EscapeJson — minimal JSON string escaping. | //+------------------------------------------------------------------+ string CLLMClient::EscapeJson(const string value) { string out = ""; const int len = StringLen(value); for(int i = 0; i < len; i++) { const ushort c = StringGetCharacter(value, i); switch(c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; default: if(c < 0x20) out += StringFormat("\\u%04x", c); else out += ShortToString(c); } } return out; } //+------------------------------------------------------------------+ //| ParseScoreDirect — {"score": n, "reason": "..."} dalam teks apa | //| pun (response bridge). | //+------------------------------------------------------------------+ bool CLLMClient::ParseScoreDirect(const string raw, double &score, string &reason) { score = -1.0; reason = ""; const int sp = StringFind(raw, "\"score\""); int sq = (sp >= 0) ? StringFind(raw, ":", sp) : -1; if(sq < 0) return false; sq++; while(sq < StringLen(raw) && StringGetCharacter(raw, sq) == ' ') sq++; string num = ""; while(sq < StringLen(raw)) { const ushort c = StringGetCharacter(raw, sq); if((c < '0' || c > '9') && c != '.' && c != '-') break; num += ShortToString(c); sq++; } if(StringLen(num) == 0) return false; score = StringToDouble(num); if(!MathIsValidNumber(score) || score < 0.0 || score > 100.0) return false; const int rp = StringFind(raw, "\"reason\""); int rq = (rp >= 0) ? StringFind(raw, ":", rp) : -1; if(rq >= 0) { rq++; while(rq < StringLen(raw) && StringGetCharacter(raw, rq) != '"') rq++; rq++; string r = ""; while(rq < StringLen(raw) && StringGetCharacter(raw, rq) != '"') { r += ShortToString(StringGetCharacter(raw, rq)); rq++; } string words[]; const int wn = StringSplit(r, ' ', words); if(wn > CLLM_MAX_REASON_WORDS) { r = ""; for(int i = 0; i < CLLM_MAX_REASON_WORDS; i++) r += (i > 0 ? " " : "") + words[i]; } reason = r; } return true; } #endif // LLMCLIENT_MQH