Centaur_Quant_Architecture/MQL5/Include/Models/CLLMClient.mqh

302 lines
12 KiB
MQL5
Raw Permalink Normal View History

//+------------------------------------------------------------------+
//| 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