2026-08-12 20:15:22 +07:00 | | | //+------------------------------------------------------------------+
|
| | | //| CLLMClient.mqh |
|
| | | //| Centaur Quant Architecture — Models Module |
|
2026-08-12 21:18:02 +07:00 | | | //| "The Brain" Client for MQL5 — File-Based Periodic Bridge |
|
2026-08-12 20:15:22 +07:00 | | | //+------------------------------------------------------------------+
|
| | | //| PURPOSE |
|
2026-08-12 21:18:02 +07:00 | | | //| 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)|
|
2026-08-12 20:15:22 +07:00 | | | //+------------------------------------------------------------------+
|
| | | #property strict
|
| | |
|
| | | #ifndef LLMCLIENT_MQH
|
| | | #define LLMCLIENT_MQH
|
| | |
|
| | | #define CLLM_FALLBACK_SCORE 40.0
|
| | | #define CLLM_MAX_REASON_WORDS 10
|
| | |
|
2026-08-12 21:18:02 +07:00 | | | #define CLLM_REQ_FILE "ai_request.json"
|
| | | #define CLLM_RES_FILE "ai_response.json"
|
| | |
|
2026-08-13 06:46:37 +07:00 | | | //+------------------------------------------------------------------+
|
| | | //| |
|
| | | //+------------------------------------------------------------------+
|
2026-08-12 20:15:22 +07:00 | | | class CLLMClient
|
| | | {
|
| | | private:
|
2026-08-12 21:18:02 +07:00 | | | int m_timeout_ms; // fallback deadline untuk menunggu response
|
2026-08-12 20:15:22 +07:00 | | | bool m_ready;
|
2026-08-12 21:18:02 +07:00 | | | bool m_pending; // request sedang menunggu response
|
| | | string m_pending_id; // id request aktif (dedupe di bridge)
|
| | | ulong m_deadline_ms; // GetTickCount64() batas tunggu
|
2026-08-13 06:46:37 +07:00 | | | ulong m_id_seq; // S6: penambah unik id (anti-collision 1 ms)
|
2026-08-12 20:15:22 +07:00 | | |
|
| | | string EscapeJson(const string value);
|
2026-08-12 21:18:02 +07:00 | | | 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);
|
2026-08-12 20:35:12 +07:00 | | | bool ParseScoreDirect(const string raw, double &score, string &reason);
|
2026-08-12 20:15:22 +07:00 | | |
|
| | | public:
|
2026-08-12 21:18:02 +07:00 | | | CLLMClient(const int timeout_ms = 15000);
|
2026-08-12 20:15:22 +07:00 | | | ~CLLMClient();
|
| | |
|
| | | bool IsReady() const { return m_ready; }
|
2026-08-12 21:18:02 +07:00 | | | bool IsPending() const { return m_pending; }
|
| | | bool DeadlinePassed() const
|
| | | {
|
| | | return (m_pending && GetTickCount64() > m_deadline_ms);
|
| | | }
|
2026-08-12 20:15:22 +07:00 | | |
|
2026-08-12 21:18:02 +07:00 | | | //--- 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);
|
2026-08-12 20:35:12 +07:00 | | |
|
2026-08-12 21:18:02 +07:00 | | | //--- baca response (file) bila id cocok; konsumsi file ---
|
| | | bool TryReadResponse(double &score, string &reason);
|
2026-08-12 20:35:12 +07:00 | | |
|
2026-08-12 21:18:02 +07:00 | | | //--- batalkan pending (fallback / timeout) ---
|
| | | void ResetPending();
|
2026-08-12 20:15:22 +07:00 | | | };
|
| | | //+------------------------------------------------------------------+
|
2026-08-12 21:18:02 +07:00 | | | //| Constructor — timeout = batas tunggu response (ms). |
|
2026-08-12 20:15:22 +07:00 | | | //+------------------------------------------------------------------+
|
2026-08-12 20:50:58 +07:00 | | | CLLMClient::CLLMClient(const int timeout_ms)
|
2026-08-12 21:18:02 +07:00 | | | : m_timeout_ms(MathMax(timeout_ms, 3000)),
|
| | | m_ready(true),
|
| | | m_pending(false),
|
| | | m_pending_id(""),
|
2026-08-13 06:46:37 +07:00 | | | m_deadline_ms(0),
|
| | | m_id_seq(0)
|
2026-08-12 20:15:22 +07:00 | | | {
|
2026-08-12 21:18:02 +07:00 | | | PrintFormat("[CLLMClient] INFO: ready (file-based bridge; fallback timeout %d ms).",
|
| | | m_timeout_ms);
|
2026-08-12 20:15:22 +07:00 | | | }
|
| | | //+------------------------------------------------------------------+
|
| | | //| Destructor — nothing to release. |
|
| | | //+------------------------------------------------------------------+
|
| | | CLLMClient::~CLLMClient()
|
| | | {
|
| | | }
|
| | | //+------------------------------------------------------------------+
|
2026-08-12 21:18:02 +07:00 | | | //| SendRequest — tulis envelope SDP ke ai_request.json (truncate). |
|
| | | //| id = GetTickCount64() agar bridge mendeteksi request baru. |
|
2026-08-12 20:15:22 +07:00 | | | //+------------------------------------------------------------------+
|
2026-08-12 21:18:02 +07:00 | | | 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)
|
2026-08-12 20:35:12 +07:00 | | | {
|
2026-08-12 21:18:02 +07:00 | | | if(!m_ready)
|
| | | return false;
|
| | | if(m_pending)
|
2026-08-12 20:42:14 +07:00 | | | {
|
2026-08-12 21:18:02 +07:00 | | | PrintFormat("[CLLMClient] WARNING: request sebelumnya masih pending. Ditimpa.");
|
2026-08-12 20:42:14 +07:00 | | | }
|
2026-08-13 06:46:37 +07:00 | | | m_pending_id = IntegerToString((ulong)GetTickCount64()) + "-" + IntegerToString(m_id_seq++);
|
2026-08-12 21:18:02 +07:00 | | | const string body = BuildRequestBody(symbol, timeframe, setup_type, entry, sl, tp,
|
| | | historical_context_json);
|
2026-08-13 06:46:37 +07:00 | | | 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);
|
2026-08-12 21:18:02 +07:00 | | | if(h == INVALID_HANDLE)
|
2026-08-12 20:42:14 +07:00 | | | {
|
2026-08-12 21:18:02 +07:00 | | | PrintFormat("[CLLMClient] ERROR: FileOpen(%s) gagal. GetLastError=%d.",
|
| | | CLLM_REQ_FILE, GetLastError());
|
| | | return false;
|
2026-08-12 20:42:14 +07:00 | | | }
|
2026-08-12 21:18:02 +07:00 | | | FileWrite(h, body);
|
| | | FileClose(h);
|
2026-08-13 06:46:37 +07:00 | | | 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;
|
| | | }
|
2026-08-12 21:18:02 +07:00 | | | m_pending = true;
|
| | | m_deadline_ms = GetTickCount64() + m_timeout_ms;
|
| | | PrintFormat("[CLLMClient] INFO: request %s dikirim (LLM async).", m_pending_id);
|
| | | return true;
|
2026-08-12 20:42:14 +07:00 | | | }
|
| | | //+------------------------------------------------------------------+
|
2026-08-12 21:18:02 +07:00 | | | //| TryReadResponse — baca ai_response.json; konsumsi hanya bila id |
|
| | | //| cocok dengan request aktif. File dihapus setelah dibaca. |
|
2026-08-12 20:35:12 +07:00 | | | //+------------------------------------------------------------------+
|
2026-08-12 21:18:02 +07:00 | | | bool CLLMClient::TryReadResponse(double &score, string &reason)
|
2026-08-12 20:35:12 +07:00 | | | {
|
2026-08-12 21:18:02 +07:00 | | | 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);
|
2026-08-13 06:46:37 +07:00 | | | //--- cocokkan id request ---
|
2026-08-12 21:18:02 +07:00 | | | const string id_key = "\"id\":\"";
|
| | | const int ip = StringFind(content, id_key);
|
| | | if(ip >= 0)
|
2026-08-12 20:35:12 +07:00 | | | {
|
2026-08-12 21:18:02 +07:00 | | | 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
|
| | | }
|
2026-08-12 20:35:12 +07:00 | | | }
|
2026-08-12 21:18:02 +07:00 | | | else
|
| | | return false;
|
| | | if(!ParseScoreDirect(content, score, reason))
|
2026-08-12 20:35:12 +07:00 | | | {
|
2026-08-12 21:18:02 +07:00 | | | 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;
|
2026-08-12 20:42:14 +07:00 | | | }
|
2026-08-12 21:18:02 +07:00 | | | FileDelete(CLLM_RES_FILE); // konsumsi (proses sekali saja)
|
| | | m_pending = false;
|
| | | PrintFormat("[CLLMClient] INFO: response diterima — score %.2f.", score);
|
2026-08-12 20:42:14 +07:00 | | | return true;
|
| | | }
|
| | | //+------------------------------------------------------------------+
|
2026-08-12 21:18:02 +07:00 | | | //| ResetPending — batalkan tunggu (fallback timeout / manual). |
|
2026-08-12 20:42:14 +07:00 | | | //+------------------------------------------------------------------+
|
2026-08-12 21:18:02 +07:00 | | | void CLLMClient::ResetPending()
|
2026-08-12 20:42:14 +07:00 | | | {
|
2026-08-12 21:18:02 +07:00 | | | m_pending = false;
|
| | | m_pending_id = "";
|
| | | FileDelete(CLLM_RES_FILE);
|
2026-08-12 20:42:14 +07:00 | | | }
|
| | | //+------------------------------------------------------------------+
|
2026-08-12 21:18:02 +07:00 | | | //| BuildRequestBody — envelope SDP lengkap untuk MarketAnalyzer. |
|
2026-08-12 20:35:12 +07:00 | | | //+------------------------------------------------------------------+
|
2026-08-12 21:18:02 +07:00 | | | 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)
|
2026-08-12 20:35:12 +07:00 | | | {
|
2026-08-12 21:18:02 +07:00 | | | 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\":{";
|
2026-08-12 20:35:12 +07:00 | | | 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. |
|
2026-08-12 20:15:22 +07:00 | | | //+------------------------------------------------------------------+
|
| | | 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)
|
| | | {
|
2026-08-13 06:46:37 +07:00 | | | case '"':
|
| | | out += "\\\"";
|
| | | break;
|
| | | case '\\':
|
| | | out += "\\\\";
|
| | | break;
|
| | | case '\n':
|
| | | out += "\\n";
|
| | | break;
|
| | | case '\r':
|
| | | out += "\\r";
|
| | | break;
|
| | | case '\t':
|
| | | out += "\\t";
|
| | | break;
|
2026-08-12 20:15:22 +07:00 | | | default:
|
| | | if(c < 0x20)
|
| | | out += StringFormat("\\u%04x", c);
|
| | | else
|
| | | out += ShortToString(c);
|
| | | }
|
| | | }
|
| | | return out;
|
| | | }
|
| | | //+------------------------------------------------------------------+
|
2026-08-12 20:35:12 +07:00 | | | //| ParseScoreDirect — {"score": n, "reason": "..."} dalam teks apa |
|
2026-08-12 21:18:02 +07:00 | | | //| pun (response bridge). |
|
2026-08-12 20:35:12 +07:00 | | | //+------------------------------------------------------------------+
|
| | | 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;
|
2026-08-12 20:15:22 +07:00 | | | if(sq < 0)
|
| | | return false;
|
| | | sq++;
|
2026-08-12 20:35:12 +07:00 | | | while(sq < StringLen(raw) && StringGetCharacter(raw, sq) == ' ')
|
2026-08-12 20:15:22 +07:00 | | | sq++;
|
| | | string num = "";
|
2026-08-12 20:35:12 +07:00 | | | while(sq < StringLen(raw))
|
2026-08-12 20:15:22 +07:00 | | | {
|
2026-08-12 20:35:12 +07:00 | | | const ushort c = StringGetCharacter(raw, sq);
|
2026-08-12 20:15:22 +07:00 | | | 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;
|
2026-08-12 20:35:12 +07:00 | | | const int rp = StringFind(raw, "\"reason\"");
|
| | | int rq = (rp >= 0) ? StringFind(raw, ":", rp) : -1;
|
2026-08-12 20:15:22 +07:00 | | | if(rq >= 0)
|
| | | {
|
| | | rq++;
|
2026-08-12 20:35:12 +07:00 | | | while(rq < StringLen(raw) && StringGetCharacter(raw, rq) != '"')
|
2026-08-12 20:15:22 +07:00 | | | rq++;
|
| | | rq++;
|
| | | string r = "";
|
2026-08-12 20:35:12 +07:00 | | | while(rq < StringLen(raw) && StringGetCharacter(raw, rq) != '"')
|
2026-08-12 20:15:22 +07:00 | | | {
|
2026-08-12 20:35:12 +07:00 | | | r += ShortToString(StringGetCharacter(raw, rq));
|
2026-08-12 20:15:22 +07:00 | | | 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
|