Warrior_EA/Expert/AIBase/AutoTune.mqh
AnimateDread 2de93539d4 refactor: split CExpertSignalAIBase implementation by responsibility
ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87
method bodies covering training, labelling, feature extraction, persistence,
chart drawing, online learning, the GA auto-tuner and inference, all in one
file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling
past the era loop.

Moved the bodies into Expert\AIBase\, included at the bottom of the original
after the class declaration:

  Training.mqh        1607  era loop, plateau ladder, checkpoint select, deploy
  Features.mqh        1093  indicator creation + per-bar input feature vector
  ChartUI.mqh          634  arrows, arrow persistence, status panel, cleanup
  Persistence.mqh      492  .stats/.cfg sidecars, CPU-inference validation, copy
  OnlineLearning.mqh   461  live continual learning, EMA shadow, OOS simulator
  Labels.mqh           309  ZigZag pivot labels, async label-cache prebuild
  AutoTune.mqh         275  genetic tuner (population, crossover, halving)
  Inference.mqh        235  softmax, prior calibration, class priors

  ExpertSignalAIBase.mqh  8216 -> 3131 (declaration + topology build only)

This is a pure relocation - verified mechanically, not by eye: HEAD's file
reconstructed from the eight partials plus the surviving remainder is
byte-identical to HEAD, span for span (scratchpad verify_split.py). No
declaration moved, no signature changed, no code rewritten, so behaviour is
unchanged by construction.

Compiles 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00

293 lines
13 KiB
MQL5

//+------------------------------------------------------------------+
//| Warrior_EA |
//| AnimateDread |
//| |
//| Genetic indicator auto-tuner (GA population, crossover, successi|
//| |
//| PARTIAL IMPLEMENTATION FILE - not standalone. |
//| This holds CExpertSignalAIBase method BODIES only. The class |
//| declaration lives in Expert\ExpertSignalAIBase.mqh, which |
//| #includes this file at the bottom, after the declaration. Do not |
//| include it anywhere else and do not compile it on its own. |
//| |
//| Split out purely to make the 8216-line original navigable; the |
//| code inside was moved verbatim, not rewritten. |
//+------------------------------------------------------------------+
#ifndef WARRIOR_AIBASE_AUTOTUNE_MQH
#define WARRIOR_AIBASE_AUTOTUNE_MQH
//+------------------------------------------------------------------+
//| Successive-halving era budget for rung 0..GA_RUNGS-1: cheap 3-era |
//| screen first, then promote survivors to progressively longer, |
//| more-truthful training. See TuneIndicatorsAndTrain / GA_RUNGS. |
//+------------------------------------------------------------------+
int CExpertSignalAIBase::GaRungEras(int rung)
{
switch(rung)
{
case 0: return 3; // screening rung (the operator's "3 eras" - noisy but cheap)
case 1: return 8; // survivors get more budget
default: return 20; // finalists validated at a realistic budget
}
}
//+------------------------------------------------------------------+
void CExpertSignalAIBase::GaExtract(int candIdx, double &out[])
{
ArrayResize(out, AD_TUNE_PARAM_COUNT);
int base = candIdx * AD_TUNE_PARAM_COUNT;
for(int k = 0; k < AD_TUNE_PARAM_COUNT; k++)
out[k] = m_gaPop[base + k];
}
//+------------------------------------------------------------------+
void CExpertSignalAIBase::GaStore(int candIdx, const double &in[])
{
int base = candIdx * AD_TUNE_PARAM_COUNT;
for(int k = 0; k < AD_TUNE_PARAM_COUNT; k++)
m_gaPop[base + k] = in[k];
}
//+------------------------------------------------------------------+
//| Mutate a candidate IN its valid ranges by reusing the tuner's own |
//| PerturbRandom() (single source of truth for per-param bounds and |
//| preset snapping) - Unflatten -> perturb N times -> Flatten back. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::GaMutate(double &cand[], int mutations)
{
m_indicatorTuner.Unflatten(cand);
for(int m = 0; m < mutations; m++)
m_indicatorTuner.PerturbRandom(m_useADCumulativeDelta, m_useADShorteningOfThrust, m_useADWyckoffEventStream,
m_useADWyckoffFailedStructure, m_useADWyckoffSignificantBarInversion, m_useMA, m_useRSI,
m_useMACD, m_useIchimoku);
m_indicatorTuner.Flatten(cand);
}
//+------------------------------------------------------------------+
void CExpertSignalAIBase::GaRandomCandidate(const double &base[], double &out[], int mutations)
{
ArrayResize(out, AD_TUNE_PARAM_COUNT);
for(int k = 0; k < AD_TUNE_PARAM_COUNT; k++)
out[k] = base[k];
GaMutate(out, mutations);
}
//+------------------------------------------------------------------+
//| Block (whole-indicator) uniform crossover: each indicator's param |
//| BLOCK is taken intact from one parent or the other, so a child |
//| recombines settings ACROSS indicators (the interaction-aware part)|
//| while keeping each indicator's own params internally coherent. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::GaBlockCrossover(const double &pa[], const double &pb[], double &child[])
{
ArrayResize(child, AD_TUNE_PARAM_COUNT);
//--- block boundaries in the ADIndicatorTuner Flatten() layout: CumDelta[0..6], SOT[7..9],
//--- WES[10..17], WFS[18..25], WSBI[26..29], MA[30..31], RSI[32].
int bounds[] = {0, 7, 10, 18, 26, 30, 32, AD_TUNE_PARAM_COUNT};
int nblocks = ArraySize(bounds) - 1;
for(int b = 0; b < nblocks; b++)
{
bool fromA = (MathRand() % 2 == 0);
for(int k = bounds[b]; k < bounds[b + 1]; k++)
child[k] = fromA ? pa[k] : pb[k];
}
}
//+------------------------------------------------------------------+
//| Sort the ALIVE candidate indices (m_gaAlive[0..m_gaAliveCount-1]) |
//| by their m_gaScore descending (simple insertion sort - counts are |
//| tiny). After this the best survivors sit at the front. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::GaSortAliveByScoreDesc(void)
{
for(int i = 1; i < m_gaAliveCount; i++)
{
int key = m_gaAlive[i];
double keyScore = m_gaScore[key];
int j = i - 1;
while(j >= 0 && m_gaScore[m_gaAlive[j]] < keyScore)
{
m_gaAlive[j + 1] = m_gaAlive[j];
j--;
}
m_gaAlive[j + 1] = key;
}
}
//+------------------------------------------------------------------+
//| Build the next generation: elite (global best) carried unchanged, |
//| the rest bred by block crossover of two survivors + mutation. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::GaBreedNextGeneration(void)
{
int stride = AD_TUNE_PARAM_COUNT;
double next[];
ArrayResize(next, m_gaPopSize * stride);
//--- child 0 = elitism: the best candidate found so far, unchanged
for(int k = 0; k < stride; k++)
next[k] = m_gaBestParams[k];
int parents = MathMax(1, m_gaAliveCount); // survivors of the just-finished generation (sorted best-first)
for(int c = 1; c < m_gaPopSize; c++)
{
double pa[], pb[], child[];
GaExtract(m_gaAlive[MathRand() % parents], pa);
GaExtract(m_gaAlive[MathRand() % parents], pb);
GaBlockCrossover(pa, pb, child);
GaMutate(child, 1 + MathRand() % 3);
for(int k = 0; k < stride; k++)
next[c * stride + k] = child[k];
}
ArrayCopy(m_gaPop, next);
}
//+------------------------------------------------------------------+
//| Outer loop around Train(). AutoTuneIndicators off (or nothing |
//| tunable) = pure pass-through to Train(). Otherwise runs a genetic |
//| population search with successive-halving evaluation: candidates |
//| (full indicator-param vectors, MA type included) are scored in a |
//| THROWAWAY net (m_evalMode - the deployed weights are never touched |
//| by the search), fixed-seed and averaged over GA_SEEDS runs, ranked |
//| on BALANCED accuracy; block crossover recombines settings across |
//| indicators (interaction-aware). Only the final winner gets one |
//| full deploy-retrain. All state is resumable across chunked calls. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::TuneIndicatorsAndTrain(datetime StartTrainBar = 0)
{
bool anyTunable = (m_useADCumulativeDelta || m_useADShorteningOfThrust || m_useADWyckoffEventStream ||
m_useADWyckoffFailedStructure || m_useADWyckoffSignificantBarInversion ||
m_useMA || m_useRSI || m_useMACD || m_useIchimoku);
if(!m_autoTuneIndicators || !anyTunable)
{
Train(StartTrainBar);
return;
}
//--- ---- initialize a fresh search ----
if(!m_gaActive)
{
m_gaActive = true;
m_gaFinalRetrain = false;
m_evalMode = false;
m_tuneStartTrainBar = StartTrainBar;
m_gaPopSize = (int)MathMax(4, m_indicatorTuneTrials);
m_gaMaxGen = GA_MAX_GENERATIONS;
m_gaGen = 0;
m_gaRung = 0;
m_gaCandPos = 0;
m_gaSeedIdx = 0;
ArrayResize(m_gaPop, m_gaPopSize * AD_TUNE_PARAM_COUNT);
ArrayResize(m_gaScore, m_gaPopSize);
ArrayResize(m_gaScoreSum, m_gaPopSize);
ArrayResize(m_gaAlive, m_gaPopSize);
ArrayInitialize(m_gaScore, 0.0);
ArrayInitialize(m_gaScoreSum, 0.0);
//--- seed population: candidate 0 = the user's configured starting params (elite seed), the rest are
//--- random mutations of it so generation 0 already spans the space
double base[];
m_indicatorTuner.Flatten(base);
GaStore(0, base);
for(int c = 1; c < m_gaPopSize; c++)
{
double rc[];
GaRandomCandidate(base, rc, 5);
GaStore(c, rc);
}
for(int a = 0; a < m_gaPopSize; a++)
m_gaAlive[a] = a;
m_gaAliveCount = m_gaPopSize;
ArrayResize(m_gaBestParams, AD_TUNE_PARAM_COUNT);
for(int k = 0; k < AD_TUNE_PARAM_COUNT; k++)
m_gaBestParams[k] = base[k]; // fallback winner = base until a finalist beats it
m_gaBestScore = -1;
m_gaHaveBest = true;
Print(ID + ": auto-tune GENETIC search started - population " + IntegerToString(m_gaPopSize) +
", " + IntegerToString(m_gaMaxGen) + " generations, " + IntegerToString(GA_RUNGS) +
" successive-halving rungs, " + IntegerToString(GA_SEEDS) + "-seed averaged, ranked on balanced accuracy");
}
//--- ---- final full retrain on the winning params (deploys) ----
if(m_gaFinalRetrain)
{
Train(m_tuneStartTrainBar);
if(m_trainRunActive)
return; // still training the winner - resume next call
m_gaActive = false;
m_gaFinalRetrain = false;
m_tuneTrialIndex = -1;
Print(ID + ": auto-tune complete - deployed winner (balanced acc " + DoubleToString(m_gaBestScore, 1) + "%) after full retrain");
return;
}
//--- ---- evaluate the current candidate at the current rung/seed (sandboxed, throwaway net) ----
int candIdx = m_gaAlive[m_gaCandPos];
if(!m_trainRunActive)
{
double cand[];
GaExtract(candIdx, cand);
m_indicatorTuner.Unflatten(cand);
ReInitADIndicators(m_indicatorsPtr); // also invalidates the feature cache (params changed)
MathSrand(GA_SEED_BASE + m_gaSeedIdx); // fixed per-seed init -> reproducible, removes init luck
BuildFreshTopology(); // builds into the throwaway Net (overwritten each candidate)
m_evalMode = true;
m_evalEraBudget = GaRungEras(m_gaRung);
dError = -1;
dUndefine = 0;
dForecast = 0;
dOosForecast = 0;
m_oosSamples = 0;
dOosError = -1;
SetStatusLabel(StringFormat(ID + " : auto-tune gen %d/%d, rung %d, cand %d/%d, seed %d/%d",
m_gaGen + 1, m_gaMaxGen, m_gaRung, m_gaCandPos + 1, m_gaAliveCount,
m_gaSeedIdx + 1, GA_SEEDS));
}
Train(m_tuneStartTrainBar);
if(m_trainRunActive)
return; // candidate's capped eval yielded mid-chunk - resume next call
//--- this (candidate, seed) run finished: accumulate its balanced-accuracy score
m_gaScoreSum[candIdx] += MathMax(0.0, m_bestBalancedOos);
m_gaSeedIdx++;
if(m_gaSeedIdx < GA_SEEDS)
return; // run the next fixed seed for this same candidate
//--- all seeds done for this candidate: finalize its averaged score
m_gaScore[candIdx] = m_gaScoreSum[candIdx] / GA_SEEDS;
m_gaScoreSum[candIdx] = 0.0;
m_gaSeedIdx = 0;
//--- only the FINAL rung (realistic budget) is allowed to set the deployable global best, so the winner
//--- is never an unvalidated candidate that got a lucky 3-era screen
if(m_gaRung == GA_RUNGS - 1 && m_gaScore[candIdx] > m_gaBestScore)
{
m_gaBestScore = m_gaScore[candIdx];
GaExtract(candIdx, m_gaBestParams);
}
Print(ID + StringFormat(": auto-tune gen %d/%d rung %d - candidate %d/%d balanced acc %.1f%%",
m_gaGen + 1, m_gaMaxGen, m_gaRung, m_gaCandPos + 1, m_gaAliveCount, m_gaScore[candIdx]));
m_gaCandPos++;
if(m_gaCandPos < m_gaAliveCount)
return; // next candidate at this rung
//--- every alive candidate scored at this rung: rank them
GaSortAliveByScoreDesc();
if(m_gaRung < GA_RUNGS - 1)
{
//--- successive-halving: promote the top half to the next, longer rung
m_gaAliveCount = (int)MathMax(2, m_gaAliveCount / 2);
m_gaRung++;
m_gaCandPos = 0;
return;
}
//--- final rung done -> this generation is complete
m_gaGen++;
if(m_gaGen < m_gaMaxGen)
{
GaBreedNextGeneration();
m_gaRung = 0;
m_gaCandPos = 0;
m_gaSeedIdx = 0;
m_gaAliveCount = m_gaPopSize;
for(int a = 0; a < m_gaPopSize; a++)
m_gaAlive[a] = a;
return;
}
//--- search exhausted -> set up the single full retrain on the winning params, which deploys normally
m_evalMode = false;
m_indicatorTuner.Unflatten(m_gaBestParams);
ReInitADIndicators(m_indicatorsPtr);
MathSrand(GetTickCount()); // fresh (non-fixed) init for the real deployed model
BuildFreshTopology();
dError = -1;
dUndefine = 0;
dForecast = 0;
dOosForecast = 0;
m_oosSamples = 0;
dOosError = -1;
m_gaFinalRetrain = true;
Print(ID + ": auto-tune search done - retraining the winner to convergence for deployment");
}
#endif // WARRIOR_AIBASE_AUTOTUNE_MQH