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
|