dualea/Experts/Advisors/DualEA/Tests/Train_MarketModel.mq5
2026-10-28 00:31:51 -04:00
359 lines
18 KiB
MQL5
#property strict
#include "../Include/CMarketModelFeatures.mqh"
input string BuildOutputDirectory="DualEA\\model_build\\universal_h1_logistic_v1";
input string TrainingSymbols="EURUSD,GBPUSD,USDCHF,USDJPY,USDCAD,AUDUSD,NZDUSD,EURGBP,EURJPY,GBPJPY,XAUUSD,XAGUSD,XAUEUR,AUS200,GER40,NAS100,UK100,US30,US500,BRENT,WTI,BTCUSD,ETHUSD";
input int DecisionStrideBars=1;
input int TrainingEpochs=300;
input double LearningRate=0.08;
input double L2Penalty=0.01;
input datetime TrainingStart=D'2024.01.01';
input datetime TrainingEnd=D'2026.07.01';
input datetime ValidationEnd=D'2026.09.01';
input datetime TestEnd=D'2026.10.01';
#define MODEL_FEATURES 12
#define ROW_FEATURES 16
struct STrainingRow
{
int symbol_index;
datetime time;
datetime target_end;
int direction;
int split;
int label;
double net_return;
double entry_spread;
double exit_spread;
double x[ROW_FEATURES];
};
STrainingRow rows[];
string training_symbols[],rejected_symbols[];
double means[],scales[],weights[],effective_weights[];
double effective_intercept=0;
int symbol_counts[],symbol_positives[];
double intercept=0.0;
int counts[3],positives[3];
bool trained=false;
double Logistic(double score)
{
if(score>=0.0) return 1.0/(1.0+MathExp(-MathMin(score,700.0)));
double e=MathExp(MathMax(score,-700.0));
return e/(1.0+e);
}
double Prediction(const STrainingRow &row)
{
double score=effective_intercept;
for(int j=0;j<ROW_FEATURES;j++)
{
int feature=j<MODEL_FEATURES ? j : MODEL_FEATURES+4*row.symbol_index+j-MODEL_FEATURES;
score+=effective_weights[feature]*row.x[j];
}
return Logistic(score);
}
void RefreshEffectiveWeights()
{
effective_intercept=intercept;
for(int j=0;j<ArraySize(weights);j++)
{
effective_weights[j]=weights[j]/scales[j];
effective_intercept-=weights[j]*means[j]/scales[j];
}
}
string Number(double value) { return DoubleToString(value,16); }
string VectorJSON(const double &values[])
{
string result="[";
for(int j=0;j<ArraySize(values);j++) { if(j) result+=","; result+=Number(values[j]); }
return result+"]";
}
string SplitName(int split) { return split==0 ? "train" : (split==1 ? "validation" : "test"); }
bool WriteText(string name,string content)
{
int h=FileOpen(BuildOutputDirectory+"\\"+name,FILE_WRITE|FILE_TXT|FILE_ANSI,0,CP_UTF8);
if(h==INVALID_HANDLE) { Print("[MODEL-TRAIN FAIL] Cannot write ",name," error=",GetLastError()); return false; }
FileWriteString(h,content);
FileFlush(h);
FileClose(h);
return true;
}
bool LoadRealRows()
{
string requested[];
int requested_count=StringSplit(TrainingSymbols,',',requested);
int used=0;
ArrayResize(rows,0);
ArrayResize(training_symbols,0);
ArrayResize(rejected_symbols,0);
ArrayInitialize(counts,0);
ArrayInitialize(positives,0);
for(int symbol_slot=0;symbol_slot<requested_count;symbol_slot++)
{
string symbol=requested[symbol_slot];
StringTrimLeft(symbol);StringTrimRight(symbol);
if(symbol=="") continue;
bool duplicate=false;
for(int k=0;k<ArraySize(training_symbols);k++) if(training_symbols[k]==symbol) duplicate=true;
if(duplicate) continue;
MqlRates rates[];
ArraySetAsSeries(rates,false);
int n=0;
if(SymbolSelect(symbol,true))
{
// The first cross-symbol request can initiate asynchronous history loading.
for(int retry=0;retry<12;retry++)
{
n=CopyRates(symbol,PERIOD_H1,TrainingStart,TestEnd-1,rates);
if(n>=1500) break;
Sleep(250);
}
}
double point=SymbolInfoDouble(symbol,SYMBOL_POINT);
int local_counts[3],local_positive[3];
ArrayInitialize(local_counts,0);ArrayInitialize(local_positive,0);
int start=used;
int accepted_index=ArraySize(training_symbols);
if(n>=1500 && point>0)
{
ArrayResize(rows,used+n*2);
for(int d=15;d+4<n;d+=DecisionStrideBars)
{
datetime decision=rates[d].time,end=rates[d+4].time;
if(decision<TrainingStart || end>=TestEnd) continue;
bool continuous=true;
for(int k=d;k<d+4;k++) if(rates[k+1].time-rates[k].time!=3600) continuous=false;
if(!continuous || rates[d].open<=0 || rates[d+4].open<=0 || rates[d].spread<0 || rates[d+4].spread<0) continue;
int split=decision<TrainingEnd ? 0 : (decision<ValidationEnd ? 1 : 2);
datetime boundary=split==0 ? TrainingEnd : (split==1 ? ValidationEnd : TestEnd);
if(end>=boundary) continue;
for(int direction=0;direction<2;direction++)
{
double f[];
if(!MarketModelFeaturesFromRates(rates,d-1,direction,point,decision,f) || ArraySize(f)!=MODEL_FEATURES) continue;
double entry_bid=rates[d].open,exit_bid=rates[d+4].open;
double entry_ask=entry_bid+rates[d].spread*point,exit_ask=exit_bid+rates[d+4].spread*point;
if(entry_ask<=0 || exit_ask<=0) continue;
double target=direction==0 ? MathLog(exit_bid/entry_ask) : MathLog(entry_bid/exit_ask);
if(!MathIsValidNumber(target)) continue;
rows[used].symbol_index=accepted_index;
rows[used].time=decision;rows[used].target_end=end;
rows[used].direction=direction;rows[used].split=split;
rows[used].net_return=target;rows[used].label=target>0 ? 1 : 0;
rows[used].entry_spread=rates[d].spread;rows[used].exit_spread=rates[d+4].spread;
for(int j=0;j<MODEL_FEATURES;j++) rows[used].x[j]=f[j];
rows[used].x[12]=1;
rows[used].x[13]=f[0]/MathMax(f[4],1e-8);
rows[used].x[14]=f[1]/MathMax(f[4],1e-8);
rows[used].x[15]=f[3];
local_counts[split]++;local_positive[split]+=rows[used].label;
used++;
}
}
}
// Eligibility must not select the universe using held-out labels or scores.
bool eligible=local_counts[0]>=1000 && local_positive[0]>=20 && local_counts[0]-local_positive[0]>=20;
if(!eligible)
{
used=start;ArrayResize(rows,used);
int rejected=ArraySize(rejected_symbols);ArrayResize(rejected_symbols,rejected+1);rejected_symbols[rejected]=symbol;
PrintFormat("[MODEL-TRAIN SKIP] %s insufficient real history/class counts: bars=%d train=%d val=%d test=%d",symbol,n,local_counts[0],local_counts[1],local_counts[2]);
continue;
}
ArrayResize(training_symbols,accepted_index+1);training_symbols[accepted_index]=symbol;
ArrayResize(symbol_counts,(accepted_index+1)*3);ArrayResize(symbol_positives,(accepted_index+1)*3);
for(int split=0;split<3;split++)
{
counts[split]+=local_counts[split];positives[split]+=local_positive[split];
symbol_counts[accepted_index*3+split]=local_counts[split];symbol_positives[accepted_index*3+split]=local_positive[split];
PrintFormat("[MODEL-TRAIN DATA] %s %s rows=%d positive=%d negative=%d",symbol,SplitName(split),local_counts[split],local_positive[split],local_counts[split]-local_positive[split]);
}
ArrayResize(rows,used);
}
string feature_names[];
if(ArraySize(training_symbols)<2 || !UniversalModelFeatureNames(training_symbols,feature_names)) return false;
int width=ArraySize(feature_names);
ArrayResize(means,width);ArrayResize(scales,width);ArrayResize(weights,width);ArrayResize(effective_weights,width);
return true;
}
void FitTrainingOnly()
{
int width=ArraySize(weights);
ArrayInitialize(means,0.0);ArrayInitialize(scales,0.0);ArrayInitialize(weights,0.0);
for(int i=0;i<ArraySize(rows);i++)
if(rows[i].split==0)
for(int j=0;j<ROW_FEATURES;j++)
{
int f=j<MODEL_FEATURES ? j : MODEL_FEATURES+4*rows[i].symbol_index+j-MODEL_FEATURES;
means[f]+=rows[i].x[j]/counts[0];
scales[f]+=rows[i].x[j]*rows[i].x[j]/counts[0];
}
// E[x^2]-E[x]^2 includes the inactive per-symbol zeros exactly.
for(int j=0;j<width;j++) {scales[j]=MathSqrt(MathMax(0.0,scales[j]-means[j]*means[j]));if(scales[j]<1e-12)scales[j]=1.0;}
double prior=(double)positives[0]/counts[0];
intercept=MathLog(prior/(1.0-prior));
double gradient[];ArrayResize(gradient,width);
for(int epoch=0;epoch<TrainingEpochs;epoch++)
{
RefreshEffectiveWeights();
ArrayInitialize(gradient,0.0);
double bias_gradient=0.0;
for(int i=0;i<ArraySize(rows);i++)
{
if(rows[i].split!=0) continue;
double error=Prediction(rows[i])-rows[i].label;
bias_gradient+=error/counts[0];
for(int j=0;j<ROW_FEATURES;j++)
{
int f=j<MODEL_FEATURES ? j : MODEL_FEATURES+4*rows[i].symbol_index+j-MODEL_FEATURES;
gradient[f]+=error*(rows[i].x[j]/scales[f])/counts[0];
}
}
for(int j=0;j<width;j++)
{
gradient[j]-=(means[j]/scales[j])*bias_gradient;
weights[j]-=LearningRate*(gradient[j]+L2Penalty*weights[j]);
}
intercept-=LearningRate*bias_gradient;
if(epoch%50==0) PrintFormat("[MODEL-TRAIN FIT] epoch=%d/%d real_train_rows=%d width=%d",epoch,TrainingEpochs,counts[0],width);
}
RefreshEffectiveWeights();
}
string MetricsJSON(int split,int symbol_index=-1)
{
double ranked[][2];
int expected_count=symbol_index<0 ? counts[split] : symbol_counts[symbol_index*3+split];
int expected_positive=symbol_index<0 ? positives[split] : symbol_positives[symbol_index*3+split];
if(expected_count==0)
return "{\"rows\":0,\"positive\":0,\"negative\":0,\"auc\":null,\"log_loss\":null,\"constant_train_prior_log_loss\":null,\"brier_score\":null,\"accuracy\":null}";
ArrayResize(ranked,expected_count);
double loss=0,brier=0,baseline_loss=0,baseline_brier=0;
int correct=0,slot=0;
double prior=(double)positives[0]/counts[0];
for(int i=0;i<ArraySize(rows);i++)
{
if(rows[i].split!=split || (symbol_index>=0 && rows[i].symbol_index!=symbol_index)) continue;
double p=Prediction(rows[i]);
p=MathMax(1e-12,MathMin(1.0-1e-12,p));
int y=rows[i].label;
loss-=y*MathLog(p)+(1-y)*MathLog(1-p);
brier+=MathPow(p-y,2);
baseline_loss-=y*MathLog(prior)+(1-y)*MathLog(1-prior);
baseline_brier+=MathPow(prior-y,2);
if((p>=0.5 ? 1 : 0)==y) correct++;
ranked[slot][0]=p;
ranked[slot][1]=y;
slot++;
}
ArraySort(ranked);
double positive_rank_sum=0;
int begin=0;
while(begin<slot)
{
int end=begin+1;
while(end<slot && ranked[end][0]==ranked[begin][0]) end++;
double average_rank=((begin+1)+end)*0.5;
for(int k=begin;k<end;k++) if(ranked[k][1]>0.5) positive_rank_sum+=average_rank;
begin=end;
}
double pos=expected_positive,neg=expected_count-expected_positive;
double auc=pos>0 && neg>0 ? (positive_rank_sum-pos*(pos+1)/2)/(pos*neg) : 0;
string auc_json=pos>0 && neg>0 ? Number(auc) : "null";
PrintFormat("[MODEL-TRAIN METRICS] %s AUC=%s logloss=%.6f baseline=%.6f accuracy=%.6f",SplitName(split),auc_json,loss/slot,baseline_loss/slot,(double)correct/slot);
return "{\"rows\":"+IntegerToString(slot)+",\"positive\":"+IntegerToString(expected_positive)+",\"negative\":"+IntegerToString(expected_count-expected_positive)+",\"auc\":"+auc_json+",\"log_loss\":"+Number(loss/slot)+",\"constant_train_prior_log_loss\":"+Number(baseline_loss/slot)+",\"constant_train_prior_brier\":"+Number(baseline_brier/slot)+",\"constant_train_prior_accuracy\":"+Number(prior>=0.5 ? pos/slot : neg/slot)+",\"brier_score\":"+Number(brier/slot)+",\"accuracy\":"+Number((double)correct/slot)+"}";
}
string StringArrayJSON(const string &values[])
{
string output="[";
for(int j=0;j<ArraySize(values);j++){if(j)output+=",";output+="\""+values[j]+"\"";}
return output+"]";
}
bool SaveArtifacts()
{
FolderCreate("DualEA");FolderCreate("DualEA\\model_build");FolderCreate(BuildOutputDirectory);
string names[],base_names[];
if(!UniversalModelFeatureNames(training_symbols,names)) return false;
MarketModelFeatureNames(base_names);
string joined_names="",joined_symbols="";
for(int j=0;j<ArraySize(names);j++){if(j)joined_names+="|";joined_names+=names[j];}
for(int j=0;j<ArraySize(training_symbols);j++){if(j)joined_symbols+="|";joined_symbols+=training_symbols[j];}
string model="{\"schema\":\"market_universal_v1\",\"symbol\":\"*\",\"supported_symbols\":"+StringArrayJSON(training_symbols)+",\"rejected_symbols\":"+StringArrayJSON(rejected_symbols)+",\"timeframe\":16385,\"horizon_bars\":4,\"feature_names\":"+StringArrayJSON(names)+",\"weights\":"+VectorJSON(weights)+",\"intercept\":"+Number(intercept)+",\"scaler_mean\":"+VectorJSON(means)+",\"scaler_scale\":"+VectorJSON(scales)+",\"training_start\":\""+TimeToString(TrainingStart,TIME_DATE)+"\",\"training_end\":\""+TimeToString(TrainingEnd,TIME_DATE)+"\",\"validation_end\":\""+TimeToString(ValidationEnd,TIME_DATE)+"\",\"test_end\":\""+TimeToString(TestEnd,TIME_DATE)+"\",\"target\":\"positive_side_aware_four_bar_log_return_after_recorded_spread\",\"commission\":0,\"slippage\":0,\"decision_stride_bars\":"+IntegerToString(DecisionStrideBars)+",\"epochs\":"+IntegerToString(TrainingEpochs)+",\"learning_rate\":"+Number(LearningRate)+",\"l2\":"+Number(L2Penalty)+"}";
if(!WriteText("model_data.json",model)) return false;
string metrics="{\"train\":"+MetricsJSON(0)+",\"validation\":"+MetricsJSON(1)+",\"test\":"+MetricsJSON(2)+",\"per_symbol\":{";
for(int s=0;s<ArraySize(training_symbols);s++)
{
if(s)metrics+=",";
metrics+="\""+training_symbols[s]+"\":{\"train\":"+MetricsJSON(0,s)+",\"validation\":"+MetricsJSON(1,s)+",\"test\":"+MetricsJSON(2,s)+"}";
}
metrics+="},\"selection\":\"fixed_hyperparameters_no_validation_or_test_selection\",\"training_data\":\"broker_H1_OHLC_and_recorded_spread\",\"purge\":\"target_end_before_split_boundary\",\"supported_symbols\":"+StringArrayJSON(training_symbols)+",\"rejected_symbols\":"+StringArrayJSON(rejected_symbols)+"}";
if(!WriteText("metrics.json",metrics)) return false;
string ini="version=1\nfeature_contract=market_universal_v1\ntrained_symbol=*\nsupported_symbols="+joined_symbols+"\ntrained_timeframe=16385\nprediction_horizon_bars=4\ntraining_start="+TimeToString(TrainingStart,TIME_DATE)+"\ntraining_end="+TimeToString(TrainingEnd,TIME_DATE)+"\nvalidation_end="+TimeToString(ValidationEnd,TIME_DATE)+"\ntest_end="+TimeToString(TestEnd,TIME_DATE)+"\nfeature_count="+IntegerToString(ArraySize(names))+"\nfeature_names="+joined_names+"\nscaler_mean=";
for(int j=0;j<ArraySize(means);j++){if(j)ini+="|";ini+=Number(means[j]);}
ini+="\nscaler_scale=";
for(int j=0;j<ArraySize(scales);j++){if(j)ini+="|";ini+=Number(scales[j]);}
ini+="\ninput_name=model_input\noutput_name=probability\nlabel_mode=future_side_aware_return\n";
if(!WriteText("onnx_config.ini",ini)) return false;
int dataset=FileOpen(BuildOutputDirectory+"\\dataset.csv",FILE_WRITE|FILE_CSV|FILE_ANSI,',',CP_UTF8);
int parity=FileOpen(BuildOutputDirectory+"\\parity_samples.csv",FILE_WRITE|FILE_CSV|FILE_ANSI,',',CP_UTF8);
if(dataset==INVALID_HANDLE || parity==INVALID_HANDLE){if(dataset!=INVALID_HANDLE)FileClose(dataset);if(parity!=INVALID_HANDLE)FileClose(parity);return false;}
string header="decision_time,target_end,split,symbol,direction,label,net_log_return,entry_spread_points,exit_spread_points";
for(int j=0;j<MODEL_FEATURES;j++)header+=","+base_names[j];
header+=",active_symbol_flag,active_ret1,active_ret3,active_zscore";
FileWriteString(dataset,header+"\r\n");
string parity_header="split,decision_time,symbol,direction,expected_probability";
for(int j=0;j<ArraySize(names);j++)parity_header+=","+names[j];
FileWriteString(parity,parity_header+"\r\n");
int selected[];ArrayResize(selected,ArraySize(training_symbols)*3);ArrayInitialize(selected,0);
for(int i=0;i<ArraySize(rows);i++)
{
string symbol=training_symbols[rows[i].symbol_index];
string line=TimeToString(rows[i].time,TIME_DATE|TIME_MINUTES)+","+TimeToString(rows[i].target_end,TIME_DATE|TIME_MINUTES)+","+SplitName(rows[i].split)+","+symbol+","+IntegerToString(rows[i].direction)+","+IntegerToString(rows[i].label)+","+Number(rows[i].net_return)+","+Number(rows[i].entry_spread)+","+Number(rows[i].exit_spread);
for(int j=0;j<ROW_FEATURES;j++)line+=","+Number(rows[i].x[j]);
FileWriteString(dataset,line+"\r\n");
int group=rows[i].symbol_index*3+rows[i].split;
if(selected[group]<8)
{
string sample=SplitName(rows[i].split)+","+TimeToString(rows[i].time,TIME_DATE|TIME_MINUTES)+","+symbol+","+IntegerToString(rows[i].direction)+","+Number(Prediction(rows[i]));
for(int j=0;j<ArraySize(names);j++)
{
double value=0;
if(j<MODEL_FEATURES)value=rows[i].x[j];
else if((j-MODEL_FEATURES)/4==rows[i].symbol_index)value=rows[i].x[MODEL_FEATURES+(j-MODEL_FEATURES)%4];
sample+=","+Number(value);
}
FileWriteString(parity,sample+"\r\n");selected[group]++;
}
}
FileFlush(dataset);FileClose(dataset);FileFlush(parity);FileClose(parity);
return true;
}
int OnInit()
{
if(!MQLInfoInteger(MQL_TESTER) || _Period!=PERIOD_H1) {Print("[MODEL-TRAIN FAIL] Run only in H1 Strategy Tester.");return INIT_FAILED;}
if(!(TrainingStart<TrainingEnd && TrainingEnd<ValidationEnd && ValidationEnd<TestEnd && TestEnd<=TimeCurrent()) ||
TrainingStart%86400!=0 || TrainingEnd%86400!=0 || ValidationEnd%86400!=0 || TestEnd%86400!=0)
{
Print("[MODEL-TRAIN FAIL] Date boundaries must be ordered midnight dates, with TestEnd no later than tester time.");
return INIT_PARAMETERS_INCORRECT;
}
if(DecisionStrideBars<1 || DecisionStrideBars>24 || TrainingEpochs<1 || TrainingEpochs>2000 ||
!MathIsValidNumber(LearningRate) || !MathIsValidNumber(L2Penalty) ||
LearningRate<=0 || LearningRate>1 || L2Penalty<0) return INIT_PARAMETERS_INCORRECT;
if(!LoadRealRows()) {Print("[MODEL-TRAIN FAIL] Real history/class-count requirements were not met.");return INIT_FAILED;}
FitTrainingOnly();
if(!MathIsValidNumber(intercept)) return INIT_FAILED;
for(int j=0;j<ArraySize(weights);j++)
if(!MathIsValidNumber(weights[j]) || !MathIsValidNumber(means[j]) || !MathIsValidNumber(scales[j]) || scales[j]<=0)
{
Print("[MODEL-TRAIN FAIL] Invalid fitted coefficient/scaler at ",j);
return INIT_FAILED;
}
if(!SaveArtifacts()) return INIT_FAILED;
trained=true;
PrintFormat("[MODEL-TRAIN RESULT] rows=%d train=%d validation=%d test=%d output=%s",ArraySize(rows),counts[0],counts[1],counts[2],BuildOutputDirectory);
return INIT_SUCCEEDED;
}
void OnTick() {}
double OnTester() { return trained ? ArraySize(rows) : -1; }