#include "RuntimeStorage.mqh" #ifndef __MODEL_PREDICTOR_MQH__ #define __MODEL_PREDICTOR_MQH__ // Native MT5 ONNX works in the terminal and Strategy Tester without DLL imports. // Supported binary classifiers: one float probability tensor, or an int64 label // tensor followed by a float [1,2] probability tensor (snapshot XGBoost export). class CModelPredictor { private: struct SCategoricalMap { string field; string keys[]; int values[]; }; long m_session; bool m_ready; int m_output_count; int m_probability_count; double m_last_probability; string m_last_error; string m_trained_symbol; string m_feature_contract; string m_supported_symbols[]; int m_trained_timeframe; string m_feature_names[]; double m_scaler_mean[]; double m_scaler_scale[]; SCategoricalMap m_cat_maps[]; static string Trim(string value) { StringTrimLeft(value); StringTrimRight(value); return value; } static string NormalizeKey(string value) { value = Trim(value); if(StringLen(value)>0 && StringGetCharacter(value,0)==0xFEFF) value=StringSubstr(value,1); StringToLower(value); return value; } void Error(string message) { m_last_error=message; Print("[ModelPredictor] ERROR: ",message); } bool ReadBytes(string path, uchar &bytes[]) { StringReplace(path,"/","\\"); string prefix=TerminalInfoString(TERMINAL_COMMONDATA_PATH)+"\\Files\\"; string local_prefix=TerminalInfoString(TERMINAL_DATA_PATH)+"\\MQL5\\Files\\"; int flags=0; if(StringFind(path,prefix)==0) { path=StringSubstr(path,StringLen(prefix)); flags=DUALEA_FILE_COMMON; } else if(StringFind(path,local_prefix)==0) path=StringSubstr(path,StringLen(local_prefix)); ResetLastError(); int file=FileOpen(path,FILE_READ|FILE_BIN|flags|FILE_SHARE_READ); if(file==INVALID_HANDLE) { Error(StringFormat("Cannot read %s (error=%d)",path,GetLastError())); return false; } ulong size=FileSize(file); if(size==0 || size>2147483647) { FileClose(file); Error("Empty or oversized model/config file: "+path); return false; } ArrayResize(bytes,(int)size); uint read=FileReadArray(file,bytes,0,(int)size); FileClose(file); if(read!=size) { Error("Incomplete model/config read: "+path); return false; } return true; } bool LoadConfig(string path) { uchar bytes[]; if(!ReadBytes(path,bytes)) return false; string content; int size=ArraySize(bytes); if(size>=2 && ((bytes[0]==255 && bytes[1]==254) || (bytes[0]==254 && bytes[1]==255))) { bool little=(bytes[0]==255); ushort chars[]; int count=(size-2)/2; ArrayResize(chars,count); for(int i=0;i0 && declared_count!=features) || ArraySize(m_scaler_mean)!=features || ArraySize(m_scaler_scale)!=features) { Error("INI feature count/scaler metadata is missing or inconsistent"); return false; } for(int i=0;i0 && model_input.tensor.dimensions[0]!=1) || (model_input.tensor.dimensions[1]>0 && model_input.tensor.dimensions[1]!=FeatureCount())) { Error("Model must accept float [1,feature_count] model_input matching the INI"); return false; } long input_shape[]={1,FeatureCount()}; if(!OnnxSetInputShape(m_session,0,input_shape)) return false; m_output_count=(int)OnnxGetOutputCount(m_session); if(m_output_count!=1 && m_output_count!=2) { Error("Unsupported classifier output count"); return false; } if(m_output_count==2) { OnnxTypeInfo label; if(!OnnxGetOutputTypeInfo(m_session,0,label) || label.type!=ONNX_TYPE_TENSOR || label.tensor.data_type!=ONNX_DATA_TYPE_INT64 || ArraySize(label.tensor.dimensions)!=1 || (label.tensor.dimensions[0]>0 && label.tensor.dimensions[0]!=1)) { Error("First classifier output must be an int64 [1] label tensor"); return false; } long label_shape[]={1}; if(!OnnxSetOutputShape(m_session,0,label_shape)) return false; } OnnxTypeInfo probability; int output=m_output_count-1; if(!OnnxGetOutputTypeInfo(m_session,output,probability) || probability.type!=ONNX_TYPE_TENSOR || probability.tensor.data_type!=ONNX_DATA_TYPE_FLOAT) { Error("Probability output must be a float tensor; ZipMap/sequence outputs require tensor export"); return false; } int rank=ArraySize(probability.tensor.dimensions); if(rank<1 || rank>2 || (rank==2 && probability.tensor.dimensions[0]>0 && probability.tensor.dimensions[0]!=1)) { Error("Probability output must have shape [1], [2], [1,1], or [1,2]"); return false; } m_probability_count=(int)probability.tensor.dimensions[rank-1]; if(m_probability_count!=1 && m_probability_count!=2) { Error("Probability output must have one sigmoid value or two binary class probabilities"); return false; } if(m_output_count==2 && m_probability_count!=2) { Error("Label-plus-probability classifier must export two binary class probabilities"); return false; } long shape[]; ArrayResize(shape,rank); if(rank==2) shape[0]=1; shape[rank-1]=m_probability_count; return OnnxSetOutputShape(m_session,output,shape); } public: CModelPredictor() : m_session(INVALID_HANDLE),m_ready(false),m_output_count(0),m_probability_count(0),m_last_probability(-1.0),m_last_error("") {} ~CModelPredictor() { Shutdown(); } bool Init(const string model_path,const string config_json_path,const string config_ini_path) { Shutdown(); m_last_error=""; if(!LoadConfig(config_ini_path)) return false; uchar model[]; if(!ReadBytes(model_path,model)) return false; ResetLastError(); m_session=OnnxCreateFromBuffer(model,ONNX_USE_CPU_ONLY); if(m_session==INVALID_HANDLE) { Error(StringFormat("Native ONNX model creation failed (error=%d)",GetLastError())); return false; } if(!ConfigureModel()) { if(m_last_error=="") Error(StringFormat("ONNX tensor configuration failed (error=%d)",GetLastError())); Shutdown(); return false; } m_ready=true; PrintFormat("[ModelPredictor] Native ONNX initialized: features=%d, outputs=%d, probability tensor=%s",FeatureCount(),m_output_count,OnnxGetOutputName(m_session,m_output_count-1)); return true; } void Shutdown() { if(m_session!=INVALID_HANDLE) OnnxRelease(m_session); m_session=INVALID_HANDLE; m_ready=false; m_last_probability=-1.0; } bool IsReady() const { return m_ready; } double LastProbability() const { return m_last_probability; } string LastError() const { return m_last_error; } int FeatureCount() const { return ArraySize(m_feature_names); } string TrainedSymbol() const { return m_trained_symbol; } int TrainedTimeframe() const { return m_trained_timeframe; } string FeatureContract() const { return m_feature_contract; } bool GetSupportedSymbols(string &symbols[]) const { ArrayCopy(symbols,m_supported_symbols); return ArraySize(symbols)>0; } bool SupportsSymbol(string symbol) const { for(int i=0;i0; } int EncodeCategorical(const string field,const string value) const { string key=NormalizeKey(field), label=NormalizeKey(value); for(int i=0;i3.402823466e38) { Error("Nonfinite or overflowing model model_input at feature "+IntegerToString(i)); return -1.0; } model_input[i]=(float)scaled; } long label[1]; ResetLastError(); bool ok; if(m_output_count==2) ok=OnnxRun(m_session,ONNX_NO_CONVERSION,model_input,label,probabilities); else ok=OnnxRun(m_session,ONNX_NO_CONVERSION,model_input,probabilities); if(!ok) { Error(StringFormat("Native ONNX inference failed (error=%d)",GetLastError())); return -1.0; } for(int i=0;i1) { Error("Model returned an invalid probability"); return -1.0; } if(m_probability_count==2 && MathAbs((double)probabilities[0]+(double)probabilities[1]-1.0)>0.001) { Error("Binary class probabilities do not sum to one"); return -1.0; } m_last_probability=probabilities[m_probability_count-1]; m_last_error=""; return m_last_probability; } }; #endif