Warrior_EA/Expert/AIBase/Persistence.mqh

638 lines
39 KiB
MQL5
Raw Permalink Normal View History

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
//+------------------------------------------------------------------+
//| Warrior_EA |
//| AnimateDread |
//| |
//| Model .stats / .cfg sidecars, CPU-inference validation, share-aw|
//| |
//| 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_PERSISTENCE_MQH
#define WARRIOR_AIBASE_PERSISTENCE_MQH
//+------------------------------------------------------------------+
fix: stop a .nnw from pinning a superseded architecture A .nnw persists the ARCHITECTURE, not just the weights: Save writes (int)activation per neuron and Load reads it straight back. The activation chosen in BuildFreshTopology() therefore only ever reached a brand-new topology - every reload restored the file's value and the next save wrote it back out, so a wrong value could never heal while the source read as though it were already fixed. That is how five models kept training with an unbounded NONE classification head for a full day after the 07-28 revert to SIGMOID. Confirmed by parsing the binaries: 848cb42c.nnw / 2e754b43.nnw carry `act=NONE` on the 3-neuron output layer, while a genuinely reset model of the same config carries act=SIGMOID. In the log it showed as negative "OOS raw out" values - impossible under sigmoid - escalating to a 4.14e13 logit spread with all three classes numerically identical (input-independent output) and balanced accuracy pinned on the 33.3% one-class floor. - OutputLayerActivation() is now the single source of truth, called by both BuildFreshTopology() and the new load-time repair, so the two can no longer diverge the way a duplicated literal did. - CNet::EnforceOutputActivation() re-asserts it after Load and reports the stale value; CExpertSignalAIBase::EnforceTopologyContract() logs the repair loudly, since weights learned under the old head may not be worth keeping even once the head is corrected. - Hidden layers are deliberately left alone: they legitimately differ per stage (PRELU dense/conv, NONE pool, TANH LSTM). Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:00:40 -04:00
//| Re-assert the parts of a just-loaded net that the CODE owns but |
//| the FILE also stores. See OutputLayerActivation()'s declaration |
//| comment and CNet::EnforceOutputActivation() (AI\Network.mqh). |
//| |
//| Loud on purpose. A repair here means the model on disk was built |
//| by a superseded version of this EA and has been silently training |
//| against the wrong architecture ever since - the user needs to see |
//| that, because the weights learned under the old head are not |
//| necessarily worth keeping even once the head itself is corrected. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::EnforceTopologyContract(void)
{
if(CheckPointer(Net) == POINTER_INVALID)
return;
feat(ai): real conv receptive field + the reference's channel pool CONV's convolution used window = step = one bar, which is a per-bar projection - a 1x1 conv with a temporal receptive field of ONE BAR. It never mixed information across time, so "convolutional" described the layer type and nothing about what it computed. Same finding that sank HYBRID's LSTM. Pooling was removed on 2026-07-29 for being misconfigured against the conv output's memory layout. That removal was right; leaving the conv at a one-bar window was not. The two belong together: the NeuroNet_DNG reference (references\MQL5\Experts\EDL\Trajectory.mqh layers 2-5, kernels byte-identical to ours) pairs conv(window=2, step=1, window_out=4) with pool(window=4, step=4), and the pool only earns its place because a conv with a real receptive field sits above it. The input is bar-major (BufferTempData appends m_neuronsCount contiguous features per bar), so a flat window of k*m_neuronsCount spans exactly k bars - the receptive field needed NO kernel change. The conv output is position-major, so window == step == window_out is a clean max-over-channels, which is what the reference does and what the existing pool kernels already implement correctly. New chain at H1 defaults (420 = 20 bars x 21): conv1 w=42 s=21 out=8 -> 19 pos x 8 = 152 pool w=8 s=8 -> 19 conv2 w=2 s=1 out=8 -> 18 pos x 8 = 144 (effective field: 3 bars) We deliberately stop before the reference's SECOND pool: a channel pool emits one scalar per position, so a trailing pool would hand the dense stack 18 values and force it to fan out 18 -> 64. That is a bottleneck below every learnable layer - the same class of mistake the 2026-07-29 removal was about. Fixes a latent sizing bug this exposed: CNet's conv/pool position cursor tracked sliding POSITIONS, but a conv's real width is units_count * window_out. Any pool stacked on a conv would therefore have sized against a width window_out times too small and silently built the wrong shape. Both branches now read the built layer's actual Neurons(), which is what the batch-norm branch already did for the same reason. Also closes the architecture-pinning trap: a .nnw persists the window each conv was built with, so an existing CONV/HYBRID model would have loaded cleanly and gone on training under the OLD architecture. The conv weight tensor is (window+1)*window_out, so this cannot be repaired in place - EnforceTopologyContract now detects it, reports both shapes, and retrains. Conv chain shape is derived in one place (ConvReceptiveFieldBars / ConvFirstStagePositions / HasSecondConvStage / ConvOutputPositions / ConvOutputWidth) and consumed by AddConvStage, LstmFanIn and the startup config line, so what is built and what is logged cannot drift. Both builds compile 0 errors, 0 warnings. Forces a CONV and HYBRID retrain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:05:37 -04:00
//--- Stale conv receptive field. Unlike the activation below this cannot be repaired in place: the
//--- conv weight block is (window+1)*window_out, so a different window is a different tensor. Flag it
//--- and let the caller retrain - see m_topologySuperseded's use in InitNeuralNetwork.
if(UsesConvStage())
{
uint loadedWindow = Net.FirstConvWindow();
uint intendedWindow = (uint)(ConvReceptiveFieldBars() * m_neuronsCount);
if(loadedWindow > 0 && loadedWindow != intendedWindow)
{
m_topologySuperseded = true;
Print(ID + ": SUPERSEDED architecture on disk - the saved model's conv receptive field is " +
IntegerToString((int)loadedWindow) + " (" + IntegerToString((int)(loadedWindow / (uint)MathMax(1, m_neuronsCount))) +
" bars), this build specifies " + IntegerToString((int)intendedWindow) + " (" +
IntegerToString(ConvReceptiveFieldBars()) + " bars). The conv weight tensor is a different" +
" shape, so this cannot be repaired in place - retraining from era 0.");
}
}
fix: stop a .nnw from pinning a superseded architecture A .nnw persists the ARCHITECTURE, not just the weights: Save writes (int)activation per neuron and Load reads it straight back. The activation chosen in BuildFreshTopology() therefore only ever reached a brand-new topology - every reload restored the file's value and the next save wrote it back out, so a wrong value could never heal while the source read as though it were already fixed. That is how five models kept training with an unbounded NONE classification head for a full day after the 07-28 revert to SIGMOID. Confirmed by parsing the binaries: 848cb42c.nnw / 2e754b43.nnw carry `act=NONE` on the 3-neuron output layer, while a genuinely reset model of the same config carries act=SIGMOID. In the log it showed as negative "OOS raw out" values - impossible under sigmoid - escalating to a 4.14e13 logit spread with all three classes numerically identical (input-independent output) and balanced accuracy pinned on the 33.3% one-class floor. - OutputLayerActivation() is now the single source of truth, called by both BuildFreshTopology() and the new load-time repair, so the two can no longer diverge the way a duplicated literal did. - CNet::EnforceOutputActivation() re-asserts it after Load and reports the stale value; CExpertSignalAIBase::EnforceTopologyContract() logs the repair loudly, since weights learned under the old head may not be worth keeping even once the head is corrected. - Hidden layers are deliberately left alone: they legitimately differ per stage (PRELU dense/conv, NONE pool, TANH LSTM). Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:00:40 -04:00
ENUM_ACTIVATION intended = OutputLayerActivation();
ENUM_ACTIVATION stale = intended;
if(!Net.EnforceOutputActivation(intended, stale))
return;
Print(ID + ": REPAIRED loaded model - output layer activation was " + ActivationName(stale) +
" on disk, topology specifies " + ActivationName(intended) +
". The saved file was produced by an older build; it has been corrected in memory and the next" +
" save will persist the correction. If training looks wrong from here, reset weights and retrain -" +
" these weights were learned against the stale head.");
}
//+------------------------------------------------------------------+
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
//| Persist/restore the calibration state that must survive a restart |
//| for live trading to behave like training: the true class priors |
//| and m_confidenceCalScale. Same FILE_COMMON/tester write-guard as |
//| CNet::Save so a backtest never overwrites the shared production |
//| stats. Versioned/magic-prefixed; a mismatch is treated as absent. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::SaveModelStats(string fileName, bool common)
{
//--- mirror CNet::Save's guard: shared production stats are never written from inside a backtest
if(common && (MQLInfoInteger(MQL_OPTIMIZATION) || MQLInfoInteger(MQL_TESTER) || MQLInfoInteger(MQL_FORWARD)))
return true;
//--- Staged through a temp file + atomic rename (System\AtomicFile.mqh). Writing straight to
//--- .stats truncated it on open, so an interrupted write left a corrupt sidecar AND - because a
//--- writer with no FILE_SHARE_* blocks every concurrent open - defeated the share flags that
//--- LoadModelStats() carries specifically so a tester agent can read this while a live chart runs.
int statsCommonFlag = (common ? FILE_COMMON : 0);
string statsTmpName = "";
int handle = AtomicWriteBegin(fileName + ".stats", statsCommonFlag, statsTmpName);
if(handle == INVALID_HANDLE)
{
Print(__FUNCTION__ + ": FileOpen failed for " + statsTmpName + ", error " + IntegerToString(GetLastError()) +
" - calibration/online-learning state not persisted.");
return false;
}
//--- WST6 has the SAME field layout as WST5 - the bump exists to invalidate stale IS counters, whose
//--- MEANING changed: they used to count every oversampled occurrence, now they count each bar once
//--- (see m_isTrainQueuePrimary). Mixing the two would leave the panel averaging an inflated old
//--- number into a corrected new one for a very long time, so the reader below drops the IS pair from
//--- WST5-and-older files. The OOS pair was always per-bar and carries over untouched.
//--- Every write below is validated (<=0 means the call failed, e.g. a disk-full mid-write) - unlike
//--- the unchecked version, a truncated file is now detected and rejected instead of silently read
//--- back later as all-zero calibration state (see LoadModelStats).
bool ok = true;
if(FileWriteInteger(handle, 0x57535436) <= 0) // 'WST6' magic/version (WST5 = +compounded IS/OOS counts, WST4 = +live reliability, WST3 = +online state, WST2 = +CPU-marker, WST1 = base)
ok = false;
if(ok && FileWriteDouble(handle, m_priorBuy) <= 0)
ok = false;
if(ok && FileWriteDouble(handle, m_priorSell) <= 0)
ok = false;
if(ok && FileWriteDouble(handle, m_priorNeutral) <= 0)
ok = false;
if(ok && FileWriteDouble(handle, m_confidenceCalScale) <= 0)
ok = false;
//--- CPU-inference-safe marker (see ValidateCpuInference): gates whether an inference-only backtest
//--- of this model may run DLL-free. Appended after the v1 fields so a v1 reader stops cleanly before it.
if(ok && FileWriteInteger(handle, m_mqlInferenceValidated ? 1 : 0) <= 0)
ok = false;
//--- Online continual-learning state (WST3, see OnlineLearnStep) - the bar-time watermark of the
//--- newest bar already learned from, the rolling guardrail accuracy, and the cumulative update count.
//--- Appended after the WST2 fields so a WST2 reader stops cleanly before them.
if(ok && FileWriteLong(handle, (long)m_onlineLearnedUpToTime) <= 0)
ok = false;
if(ok && FileWriteDouble(handle, m_onlineRollingAcc) <= 0)
ok = false;
if(ok && FileWriteLong(handle, m_onlineSamples) <= 0)
ok = false;
//--- Deployed model's last-measured OOS reliability (WST4) - the live status panel shows this as the
//--- "signal hit-rate" so a freshly-reloaded, inference-only model still reports what to expect live
//--- (these members are computed only during training, so without persistence they read n/a on reload).
//--- Appended after the WST3 fields so a WST3 reader stops cleanly before them.
if(ok && FileWriteInteger(handle, m_lastBuyFiredPrecPct) <= 0)
ok = false;
if(ok && FileWriteInteger(handle, m_lastSellFiredPrecPct) <= 0)
ok = false;
if(ok && FileWriteInteger(handle, m_lastBuyRecallPct) <= 0)
ok = false;
if(ok && FileWriteInteger(handle, m_lastSellRecallPct) <= 0)
ok = false;
if(ok && FileWriteInteger(handle, m_lastBuyFired) <= 0)
ok = false;
if(ok && FileWriteInteger(handle, m_lastSellFired) <= 0)
ok = false;
//--- Compounded, persistent IS/OOS accuracy counts (WST5) - see m_cumIsCorrect. Carried across
//--- restarts so the panel's accuracy keeps compounding instead of restarting each session.
//--- Appended after the WST4 fields so a WST4 reader stops cleanly before them.
if(ok && FileWriteLong(handle, m_cumIsCorrect) <= 0)
ok = false;
if(ok && FileWriteLong(handle, m_cumIsTotal) <= 0)
ok = false;
if(ok && FileWriteLong(handle, m_cumOosCorrect) <= 0)
ok = false;
if(ok && FileWriteLong(handle, m_cumOosTotal) <= 0)
ok = false;
//--- A partial write (disk full mid-write) now discards the temp and leaves the previous good
//--- .stats in place, instead of publishing a truncated one that reads back as all-zero
//--- calibration state.
return AtomicWriteEnd(handle, fileName + ".stats", statsTmpName, statsCommonFlag, ok, __FUNCTION__);
}
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::LoadModelStats(string fileName, bool common)
{
if(!FileIsExist(fileName + ".stats", common ? FILE_COMMON : 0))
return false;
//--- share flags: read-only, must not fail just because another process holds the file - see CopySharedFile().
int handle = FileOpen(fileName + ".stats", (common ? FILE_COMMON : 0) | FILE_BIN | FILE_READ | FILE_SHARE_READ | FILE_SHARE_WRITE);
if(handle == INVALID_HANDLE)
return false;
int magic = FileReadInteger(handle);
if(magic != 0x57535431 && magic != 0x57535432 && magic != 0x57535433 && magic != 0x57535434 && magic != 0x57535435 && magic != 0x57535436)
{
FileClose(handle);
return false;
}
m_priorBuy = FileReadDouble(handle);
m_priorSell = FileReadDouble(handle);
m_priorNeutral = FileReadDouble(handle);
m_confidenceCalScale = FileReadDouble(handle);
//--- v2+ appended the CPU-inference-safe marker; v1 files predate it (treated as not-yet-validated so
//--- the model stays on the DLL path until re-deployed by a build that runs ValidateCpuInference).
m_mqlInferenceValidated = (magic == 0x57535432 || magic == 0x57535433 || magic == 0x57535434 || magic == 0x57535435 || magic == 0x57535436) ? (FileReadInteger(handle) != 0) : false;
//--- v3 appended the online continual-learning state. Older files predate it: leave the watermark at 0
//--- (OnlineLearnStep anchors it to the current frontier on first run - no retroactive backprop) and
//--- the rolling accuracy at -1 (re-seeded from the deploy baseline on the first update).
if(magic == 0x57535433 || magic == 0x57535434 || magic == 0x57535435 || magic == 0x57535436)
{
m_onlineLearnedUpToTime = (datetime)FileReadLong(handle);
m_onlineRollingAcc = FileReadDouble(handle);
m_onlineSamples = FileReadLong(handle);
}
//--- v4 appended the deployed model's last-measured OOS reliability (for the live status panel). Older
//--- files predate it: the members keep their -1 / 0 ctor defaults, so the panel shows no hit-rate line
//--- until the model is re-trained (or re-deployed) by a WST4+ build - exactly the pre-persistence behaviour.
if(magic == 0x57535434 || magic == 0x57535435 || magic == 0x57535436)
{
m_lastBuyFiredPrecPct = FileReadInteger(handle);
m_lastSellFiredPrecPct = FileReadInteger(handle);
m_lastBuyRecallPct = FileReadInteger(handle);
m_lastSellRecallPct = FileReadInteger(handle);
m_lastBuyFired = FileReadInteger(handle);
m_lastSellFired = FileReadInteger(handle);
}
//--- v5 appended the compounded/persistent IS/OOS accuracy counts (see m_cumIsCorrect). Older files
//--- predate it: the counts keep their 0 ctor defaults, so the panel shows "measuring" until the next
//--- era scores signals - then it resumes compounding from there.
if(magic == 0x57535435 || magic == 0x57535436)
{
m_cumIsCorrect = FileReadLong(handle);
m_cumIsTotal = FileReadLong(handle);
m_cumOosCorrect = FileReadLong(handle);
m_cumOosTotal = FileReadLong(handle);
//--- A WST5 file's IS pair counted every OVERSAMPLED OCCURRENCE, so it was measured against a
//--- ~58%-directional queue instead of the real ~6% distribution - not comparable with the OOS pair
//--- beside it, and the source of the "IS 77% / OOS 12%, looks like overfitting" reading. The fix
//--- (m_isTrainQueuePrimary) changed what the counter MEANS, not the file layout, so old totals must
//--- be discarded rather than compounded into the corrected ones. Dropped, not migrated: there is no
//--- per-bar count recoverable from an occurrence-weighted total. The panel shows "measuring" for one
//--- era, then compounds honestly. The OOS pair always counted bars once - keep it.
if(magic == 0x57535435)
{
m_cumIsCorrect = 0;
m_cumIsTotal = 0;
}
}
FileClose(handle);
return true;
}
//+------------------------------------------------------------------+
//| Deploy-time self-check (chart only, where a compute backend |
//| exists): run the just-saved deployed model through both the |
//| backend Net and a throwaway pure-MQL5 clone (CNet::SetCpuInference|
//| loaded from the same .nnw) on one real input window, and return |
//| true only if their outputs match within CPU_INFERENCE_MAX_DIFF. |
//| This is what lets an inference-only backtest run DLL-free; any |
//| error, size mismatch, or a not-yet-ported architecture (conv/LSTM |
//| CPU load fails) returns false -> the model stays on the DLL path. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::ValidateCpuInference(void)
{
//--- Chart-only: needs a real backend to compare against, and only the shared production model (not a
//--- per-agent optimization cache) is ever seeded into a buyer's inference-only backtest.
if(MQLInfoInteger(MQL_TESTER) || MQLInfoInteger(MQL_OPTIMIZATION) || MQLInfoInteger(MQL_FORWARD))
return false;
if(CheckPointer(Net) == POINTER_INVALID || CheckPointer(TempData) == POINTER_INVALID)
return false;
fix: the sequence models were reading the window backwards BuildFeatureWindow() replaces eight hand-rolled copies of the same loop and feeds the window OLDEST BAR FIRST. Every copy fed it newest-first, because MQL5 timeseries indices run backwards and `r + b` with b ascending walks into the past. Harmless for PAI and CONV - a dense layer learns a weight per position either way, a conv learns time-mirrored kernels. Not harmless for the recurrent stacks: - LSTM_SeqStepForward reads `inputs + t*Iw`, so step t is block t. - It writes output[] only when t == steps-1: the visible output IS the last hidden state. - c_t = f*c_{t-1} + i*g decays toward the start of the sequence. lstm_seq_flowcheck.cpp measured block 0's influence on the output at 1.2e-2 of block T-1's, at the shipped forget bias of 1.0. So the bar being PREDICTED sat at the far end of the decay and the output was handed to the OLDEST bar in the window - the exact inverse of what the window is for. ~80x backwards on LSTM and HYBRID, on all three tiers (OpenCL kernel, CPU DLL, pure-MQL5 inference), which is why it never surfaced as a backend discrepancy. This does not create edge - the MI diagnostics read at the noise floor (p=0.4975) with a working positive control. It makes the one hypothesis those diagnostics explicitly do NOT cover testable: they are marginal and per-bar, and state they "cannot rule out one that only exists in combination or across time". The sequence model is the instrument for across-time structure and it has been crippled, so that hypothesis has never been honestly tested. Fingerprint gets an unconditional |WIN:2 - the vector keeps its shape and its features, so a stale .nnw would load cleanly and run a model fitted to one ordering against the other, silently. Re-keying every config is the point, not collateral damage. FORCES A FULL RETRAIN. Also: the now-relative bar caches are re-keyed on the two live paths. EnsureBarCachesCapacity() was only ever called from training paths, but once m_trainingComplete is set ScheduleTrainingIfNeeded() routes every bar to RefreshConvergedSignal() and Train() is never re-entered - so nothing cleared the feature cache again for the life of the process. A chart that trained to convergence kept replaying the rows computed for the last training era's bar grid: the live signal froze at its convergence-time value, and OnlineLearnStep() backpropped those stale features against freshly resolved labels. Backtests were never affected (an inference-only process never allocates the arrays, so every read recomputes). Compiles clean: 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:28:44 -04:00
//--- Build the same latest-bar input window RefreshLatestSignal() feeds the deployed model - same
//--- builder, so "the same" is structural rather than a comment that has to stay true by hand.
if(!BuildFeatureWindow(0))
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
return false;
//--- Reference: the compute backend Net actually trained on. Batch-norm statistics are frozen for
//--- the duration: unfrozen, a forward pass ALSO advances them, so this reference pass would leave
//--- the live model one EMA step ahead of the .nnw the candidate below loads - and the check would
//--- then be measuring its own side effect rather than the two backends. See
//--- CNet::SetBatchNormFrozen. A no-op on topologies without normalization.
Net.SetBatchNormFrozen(true);
bool refOk = Net.feedForward(TempData);
Net.SetBatchNormFrozen(false);
if(!refOk)
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
return false;
CArrayDouble *refOut = new CArrayDouble();
if(CheckPointer(refOut) == POINTER_INVALID)
return false;
Net.getResults(refOut);
//--- Candidate: a throwaway pure-MQL5 clone loaded from the just-saved deployed weights.
CNet *cpu = new CNet(NULL);
if(CheckPointer(cpu) == POINTER_INVALID)
{
delete refOut;
return false;
}
cpu.SetCpuInference(true);
double e, u, f;
datetime tm;
long era;
bool complete;
double ip[];
bool loaded = cpu.Load(m_activeFileName + ".nnw", e, u, f, tm, m_activeFileCommon, era, complete, ip);
//--- Frozen for the same reason as the reference pass above - both sides must evaluate the SAME
//--- statistics, which are the ones sitting in the .nnw.
if(loaded)
cpu.SetBatchNormFrozen(true);
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
bool pass = false;
double maxDiff = DBL_MAX;
if(loaded && cpu.feedForward(TempData))
{
CArrayDouble *cpuOut = new CArrayDouble();
if(CheckPointer(cpuOut) != POINTER_INVALID)
{
cpu.getResults(cpuOut);
if(cpuOut.Total() == refOut.Total() && refOut.Total() > 0)
{
maxDiff = 0.0;
for(int i = 0; i < refOut.Total(); i++)
maxDiff = MathMax(maxDiff, MathAbs(refOut.At(i) - cpuOut.At(i)));
pass = (maxDiff <= CPU_INFERENCE_MAX_DIFF);
}
delete cpuOut;
}
}
delete cpu;
delete refOut;
PrintVerbose(ID + ": CPU-inference validation " + (pass ? "PASSED - backtests may run DLL-free" :
"FAILED - backtests keep using the DLL") + " (max |delta| = " +
(maxDiff == DBL_MAX ? "n/a" : DoubleToString(maxDiff, 8)) + ", tol " +
DoubleToString(CPU_INFERENCE_MAX_DIFF, 8) + ")");
return pass;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
feat(nn): derive dense depth, train on all history, pin the shape in .cfg Completes the derived-topology work. Three inputs removed. AIType loses its depth suffix - AI_MLP/AI_CONV/AI_LSTM/AI_HYBRID, five entries instead of eight. Depth is now derived from the two endpoints the taper already has to connect (derived first-layer width, output-tied final width) at a 2x per-layer compression target, clamped [2..5]. Asking a user to pick a layer count while the code derives the widths those layers taper between was asking for half a decision: at 64 units tapering to 12, four layers compress by 1.4x per step and five by 1.3x, so the extra depth bought no abstraction. On the shipping H1/10y default the derivation lands on 3 layers - the depth that actually won Run 2. StudyPeriods removed. There is no case for training on less data than the broker provides at a ~6% directional base rate; the honest generalization read comes from the OOS holdout, not from withholding history. Training now starts at the earliest available bar, floored by MinTrainYear, which answers a different question (excluding dubious pre-history) and stays. That required closing the hazard the old code documented: the capacity budget now MEASURES the symbol's real bar count, and a topology derived from a measurement would widen as history downloads. Both ends are now pinned. Every derived value left the weights-filename fingerprint - keying a filename on a measured quantity means the EA looks for a file that does not exist, starts from era 0 and orphans a trained model, silently, because a missing cache is the normal first-run state. The shape lives in the .cfg instead, where LoadAndCompare now ADOPTS the four derived fields rather than diffing them; a mismatch there would discard a fully-trained model over nothing the user did. Two fields appended to the .cfg for the conv/LSTM stages, length-guarded on read because FileReadInteger past EOF returns 0 with no error. ForceHiddenLayers, a compile-time constant like DebuggingMode, pins depth for diagnostic comparisons. It joins the fingerprint only when non-zero, so forced depths get their own files - sequential comparisons only, not simultaneous from one .ex5. Derived shape, H1/10y defaults (21 features x 20 bars): first layer 64, 3 dense, 8 conv filters, 16 LSTM units. The LSTM block halves from ~58k to ~28k weights. Both builds compile 0 errors, 0 warnings. Re-keys existing models. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:05:40 -04:00
bool CExpertSignalAIBase::SaveTopologyConfiguration(string fileName, int initialNeuronsCount, int hiddenLayersCount, double neuronsReduction, int minNeuronsCount, int optimizationAlgo, int historyBars, int outputNeuronsCount, int neuronsCount, int studyPeriod, int minTrainYear, bool isInitialized, int stopTrainWR, int fractalPeriods, int convFilterCount, int lstmHiddenSize, bool common)
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
{
string configFileName = fileName + ".cfg";
//--- Staged through a temp file + atomic rename (System\AtomicFile.mqh). This one matters most of
//--- the four sidecars: FileOpen(FILE_WRITE) truncates on open, so an interrupted write left a SHORT
//--- .cfg, and LoadAndCompareTopologyConfiguration() reads a short read as a mismatch - which
//--- discards the trained model and restarts from era 0. It also stops an exclusive writer from
//--- blocking that reader's FILE_SHARE_READ|FILE_SHARE_WRITE open on another instance.
int cfgCommonFlag = (common ? FILE_COMMON : 0);
string cfgTmpName = "";
int handle = AtomicWriteBegin(configFileName, cfgCommonFlag, cfgTmpName);
if(handle == INVALID_HANDLE)
{
Print("Error: Unable to open file ", cfgTmpName, " : Error code: ", GetLastError());
ResetLastError();
return false;
}
//--- ON-DISK LAYOUT - DO NOT REORDER OR RETYPE. LoadAndCompareTopologyConfiguration() reads these
//--- back positionally, and every existing .cfg on every deployed install has this exact sequence;
//--- changing it silently invalidates them all (-> "configuration mismatch" -> retrain from era 0).
//--- Appending a NEW field at the end is the only backward-safe change.
//--- This used to be 13 copy-pasted 6-line blocks that each returned WITHOUT FileClose(handle),
//--- leaking the handle on every write failure; the shared ok-chain closes exactly once below.
bool ok = (FileWriteInteger(handle, initialNeuronsCount) >= sizeof(int));
if(ok && FileWriteInteger(handle, hiddenLayersCount) < sizeof(int)) ok = false;
if(ok && FileWriteDouble(handle, neuronsReduction) < sizeof(double)) ok = false;
if(ok && FileWriteInteger(handle, minNeuronsCount) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, optimizationAlgo) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, historyBars) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, outputNeuronsCount) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, neuronsCount) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, studyPeriod) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, minTrainYear) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, isInitialized) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, stopTrainWR) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, fractalPeriods) < sizeof(int)) ok = false;
feat(nn): derive dense depth, train on all history, pin the shape in .cfg Completes the derived-topology work. Three inputs removed. AIType loses its depth suffix - AI_MLP/AI_CONV/AI_LSTM/AI_HYBRID, five entries instead of eight. Depth is now derived from the two endpoints the taper already has to connect (derived first-layer width, output-tied final width) at a 2x per-layer compression target, clamped [2..5]. Asking a user to pick a layer count while the code derives the widths those layers taper between was asking for half a decision: at 64 units tapering to 12, four layers compress by 1.4x per step and five by 1.3x, so the extra depth bought no abstraction. On the shipping H1/10y default the derivation lands on 3 layers - the depth that actually won Run 2. StudyPeriods removed. There is no case for training on less data than the broker provides at a ~6% directional base rate; the honest generalization read comes from the OOS holdout, not from withholding history. Training now starts at the earliest available bar, floored by MinTrainYear, which answers a different question (excluding dubious pre-history) and stays. That required closing the hazard the old code documented: the capacity budget now MEASURES the symbol's real bar count, and a topology derived from a measurement would widen as history downloads. Both ends are now pinned. Every derived value left the weights-filename fingerprint - keying a filename on a measured quantity means the EA looks for a file that does not exist, starts from era 0 and orphans a trained model, silently, because a missing cache is the normal first-run state. The shape lives in the .cfg instead, where LoadAndCompare now ADOPTS the four derived fields rather than diffing them; a mismatch there would discard a fully-trained model over nothing the user did. Two fields appended to the .cfg for the conv/LSTM stages, length-guarded on read because FileReadInteger past EOF returns 0 with no error. ForceHiddenLayers, a compile-time constant like DebuggingMode, pins depth for diagnostic comparisons. It joins the fingerprint only when non-zero, so forced depths get their own files - sequential comparisons only, not simultaneous from one .ex5. Derived shape, H1/10y defaults (21 features x 20 bars): first layer 64, 3 dense, 8 conv filters, 16 LSTM units. The LSTM block halves from ~58k to ~28k weights. Both builds compile 0 errors, 0 warnings. Re-keys existing models. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:05:40 -04:00
//--- APPENDED 2026-07-30, which the note above names as the only backward-safe change. These two are
//--- derived rather than configured (ComputeConvFilterCount/ComputeLstmHiddenSize) and left the
//--- weights-filename fingerprint when the capacity budget started measuring real bar counts, so this
//--- file is now the only place the shape of an existing model is recorded. A .cfg written before this
//--- shipped simply ends here; the loader checks the file length before reading them.
if(ok && FileWriteInteger(handle, convFilterCount) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, lstmHiddenSize) < sizeof(int)) ok = false;
feat: entry/SL/TP stop being inputs - the barrier geometry is measured Three enums left the Inputs tab. They were three things a user had to pick and, in the tester, three more axes for a genetic optimization to overfit. Entry_Multiplier is pinned to MARKET. Its pending modes place the entry at a LEVEL while the rest of the pipeline measures from the bar open - the exact mismatch that manufactured the +0.097 R "retail fade" result later retracted as a fill artifact. This codebase's fill model cannot honestly simulate a pending entry, so it is no longer offered. SL_Mode/TP_Mode become a STARTING pair. ReportBarrierGeometryScan now ADOPTS its winner instead of printing "set SL_Mode/TP_Mode to X and retrain": - only when it clears the family-wise gate from 04ee2e1 (beat the null of the MAXIMUM, not merely the incumbent). This is why that gate had to land first: without it, removing the inputs would hand a noise-picked geometry direct control over the training target with no human in the loop - strictly worse than the input it replaced. On SP500 H1 today it does NOT clear (p=0.1463), so 2:6 is what you get - now chosen by measurement rather than assumed. - only at m_eraCount == 0. Relabelling a partly-trained net moves the target out from under weights already fitted to the old one. THE GEOMETRY LEFT THE WEIGHTS-FILENAME HASH, because it is now measured. Same rule that moved the horizon and the derived topology values out: a filename keyed on a measured quantity changes the moment the measurement does - a few more bars shift which pairing wins - and the EA then looks for a file that does not exist, starts from era 0 and orphans a trained model silently. It is PINNED IN THE .cfg instead: appended at the end (the only backward-safe change), length-guarded like the 2026-07-30 derived pair, and ADOPTED on load rather than compared, so a trained model keeps the barriers it actually learned and never re-measures. Two traps closed while wiring it, neither of which announces itself: - m_barrierHorizonResolved latches the horizon ONCE PER PROCESS. Adopting 2:8 (wants ~192 bars) after it settled for 2:6 (128) would label the new target against the old ceiling - the truncation fixed in 168422f, where every model learned "target within 128 bars" while the EA holds to SL/TP. It lands in Neutral, not in the timeout counter watching for it. Unlatched on adoption, along with the label cache the old barriers filled. - the .cfg adopt runs at init, before the horizon latches and before any label is computed, so a resumed model has its pinned pair in place first. Verified, not assumed. FORCES A FULL RETRAIN: the fingerprint change orphans every existing .nnw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:39:30 -04:00
//--- APPENDED 2026-08-07, same backward-safe rule. The triple-barrier geometry became MEASURED rather
//--- than configured when SL_Mode/TP_Mode stopped being inputs, so it left the weights-filename hash
//--- (see BuildConfigFingerprint) and this file became the only record of which barriers a given model
//--- was trained against. Written unconditionally; the loader length-guards them exactly as it does
//--- the two above, so a .cfg from an older build simply ends before them.
if(ok && FileWriteInteger(handle, m_sl_mode) < sizeof(int)) ok = false;
if(ok && FileWriteInteger(handle, m_tp_mode) < sizeof(int)) ok = false;
feat: derive the ATR multiples from measured excursions - no hardcoded geometry The barrier was still two constants. SL_Mode/TP_Mode left the Inputs tab in 3482b6c, but the fallback was a hardcoded 2:6 and the geometry scan only ever chose from a hardcoded grid {2,3} x {2,3,4,6,8,10}. Picking the least-bad of eleven guesses is not deriving anything. WHY THE SCAN WAS THE WRONG INSTRUMENT, now measurable rather than argued. It ranks pairings by how predictable their OUTCOME is - a question about direction. The excursion test (2c78f3b) ran on SP500 H1 and direction is the one thing absent: ASYMMETRY p=0.0846, against RANGE/UP/DOWN all at p=0.0050, with RANGE scoring 0.01345 vs a 0.00343 null - 4x, where the barrier label sits at 1.01x. Hence the scan failing its own gate on every run, and its "winner" wandering 2:8 -> 3:8 -> 2:8 -> 2:4 across four runs of the same data. Excursion SIZE is strongly measurable, so derive the geometry from that instead. stop = q25 of measured ADVERSE travel (ordinary noise does not reach it) target = q50 of measured FAVOURABLE travel (reached ~half the time, by construction, inside the horizon) Continuous, in ATR units, superseding the enum multiples. Reachability ("target on X% of bars, stop on Y%") and the implied break-even are printed so the choice is auditable rather than trusted. FIXED-POINT ITERATION, not one-shot. ComputeBarrierHorizonBars scales the horizon with the target (first-passage time grows with the band) and the excursions are measured OVER the horizon, so target -> horizon -> excursions -> target is a real loop - deriving once sizes the target from travel measured under the PREVIOUS horizon. Re-measures until the multiples move <5%, capped at 3 passes, and says so if it does not settle. Does NOT create expectancy, and the log says as much: chance precision equals break-even at every geometry (m/(m+k) on both sides). It buys a target the market reaches and a stop that survives noise. Where Min_Risk_Reward_Ratio forces a target the market rarely reaches, it WARNS rather than overriding - the ratio is the user's risk policy, so the honest move is to state its cost. That is the collision that once rejected 100% of setups. Pinned in the .cfg as doubles appended AFTER this morning's two ints, so .cfg files written earlier today still load (their length guard finds no doubles) and a model that carries them was trained on them and never re-derives. Also fixes a message from e5ceed6 that claimed "this model resumed from disk" unconditionally - it printed above a "seeding era 0" line on a brand-new model, because the branch fires whenever the cache is not built, which is equally true before a fresh model's first prebuild. A diagnostic that misreports its own trigger is worse than one that says nothing: it gets quoted back as evidence. FORCES A FULL RETRAIN (labels change). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:06:25 -04:00
//--- APPENDED 2026-08-07 after the two ints above, which is why those stay: a .cfg written earlier
//--- today ends after them and its length guard below simply finds no doubles. These are the DERIVED
//--- continuous multiples (DeriveBarrierGeometry) and they supersede the enum pair when present -
//--- doubles because the whole point is to stop snapping the geometry to an integer grid.
if(ok && FileWriteDouble(handle, m_derivedSlMult) < sizeof(double)) ok = false;
if(ok && FileWriteDouble(handle, m_derivedTpMult) < sizeof(double)) ok = false;
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
if(!ok)
{
Print("Error writing ", configFileName, " : Error code: ", GetLastError());
ResetLastError();
}
return AtomicWriteEnd(handle, configFileName, cfgTmpName, cfgCommonFlag, ok, __FUNCTION__);
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
feat(nn): derive dense depth, train on all history, pin the shape in .cfg Completes the derived-topology work. Three inputs removed. AIType loses its depth suffix - AI_MLP/AI_CONV/AI_LSTM/AI_HYBRID, five entries instead of eight. Depth is now derived from the two endpoints the taper already has to connect (derived first-layer width, output-tied final width) at a 2x per-layer compression target, clamped [2..5]. Asking a user to pick a layer count while the code derives the widths those layers taper between was asking for half a decision: at 64 units tapering to 12, four layers compress by 1.4x per step and five by 1.3x, so the extra depth bought no abstraction. On the shipping H1/10y default the derivation lands on 3 layers - the depth that actually won Run 2. StudyPeriods removed. There is no case for training on less data than the broker provides at a ~6% directional base rate; the honest generalization read comes from the OOS holdout, not from withholding history. Training now starts at the earliest available bar, floored by MinTrainYear, which answers a different question (excluding dubious pre-history) and stays. That required closing the hazard the old code documented: the capacity budget now MEASURES the symbol's real bar count, and a topology derived from a measurement would widen as history downloads. Both ends are now pinned. Every derived value left the weights-filename fingerprint - keying a filename on a measured quantity means the EA looks for a file that does not exist, starts from era 0 and orphans a trained model, silently, because a missing cache is the normal first-run state. The shape lives in the .cfg instead, where LoadAndCompare now ADOPTS the four derived fields rather than diffing them; a mismatch there would discard a fully-trained model over nothing the user did. Two fields appended to the .cfg for the conv/LSTM stages, length-guarded on read because FileReadInteger past EOF returns 0 with no error. ForceHiddenLayers, a compile-time constant like DebuggingMode, pins depth for diagnostic comparisons. It joins the fingerprint only when non-zero, so forced depths get their own files - sequential comparisons only, not simultaneous from one .ex5. Derived shape, H1/10y defaults (21 features x 20 bars): first layer 64, 3 dense, 8 conv filters, 16 LSTM units. The LSTM block halves from ~58k to ~28k weights. Both builds compile 0 errors, 0 warnings. Re-keys existing models. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:05:40 -04:00
bool CExpertSignalAIBase::LoadAndCompareTopologyConfiguration(string fileName, int &initialNeuronsCount, int &hiddenLayersCount, double neuronsReduction, int minNeuronsCount, int optimizationAlgo, int historyBars, int outputNeuronsCount, int neuronsCount, int minTrainYear, bool isInitialized, int stopTrainWR, int fractalPeriods, int &convFilterCount, int &lstmHiddenSize, bool common)
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
{
string configFileName = fileName + ".cfg";
if(!FileIsExist(configFileName, common ? FILE_COMMON : 0))
{
//--- Not an error: no cached config for this parameter set yet - normal on the first run of a config
//--- (and every first tester/optimizer pass on a fresh agent). The caller treats a false return as
//--- "start fresh", so log it as informational rather than "Error" (which read as a real failure).
PrintVerbose(__FUNCTION__ + ": no cached topology config at " + configFileName + " yet - treating as a fresh start for this configuration");
return false;
}
//--- share flags: read-only, see CopySharedFile().
int handle = FileOpen(configFileName, FILE_READ | FILE_BIN | FILE_SHARE_READ | FILE_SHARE_WRITE | (common ? FILE_COMMON : 0));
if(handle == INVALID_HANDLE)
{
Print("Error: Unable to open file ", configFileName);
return false;
}
int savedInitialNeurons = FileReadInteger(handle);
int savedHiddenLayers = FileReadInteger(handle);
double savedReductionFactor = FileReadDouble(handle);
int savedMinNeurons = FileReadInteger(handle);
int savedOptimizationAlgo = FileReadInteger(handle);
int savedHistoryBars = FileReadInteger(handle);
int savedOutputNeuronsCount = FileReadInteger(handle);
int savedNeuronsCount = FileReadInteger(handle);
int savedStudyPeriod = FileReadInteger(handle);
int savedMinTrainYear = FileReadInteger(handle);
bool savedIsInitialized = FileReadInteger(handle); // read for layout only - NOT compared (see below)
int savedStopTrainWR = FileReadInteger(handle); // retired MinWR slot - layout only, NOT compared (see below)
int savedFractalPeriods = FileReadInteger(handle);
feat(nn): derive dense depth, train on all history, pin the shape in .cfg Completes the derived-topology work. Three inputs removed. AIType loses its depth suffix - AI_MLP/AI_CONV/AI_LSTM/AI_HYBRID, five entries instead of eight. Depth is now derived from the two endpoints the taper already has to connect (derived first-layer width, output-tied final width) at a 2x per-layer compression target, clamped [2..5]. Asking a user to pick a layer count while the code derives the widths those layers taper between was asking for half a decision: at 64 units tapering to 12, four layers compress by 1.4x per step and five by 1.3x, so the extra depth bought no abstraction. On the shipping H1/10y default the derivation lands on 3 layers - the depth that actually won Run 2. StudyPeriods removed. There is no case for training on less data than the broker provides at a ~6% directional base rate; the honest generalization read comes from the OOS holdout, not from withholding history. Training now starts at the earliest available bar, floored by MinTrainYear, which answers a different question (excluding dubious pre-history) and stays. That required closing the hazard the old code documented: the capacity budget now MEASURES the symbol's real bar count, and a topology derived from a measurement would widen as history downloads. Both ends are now pinned. Every derived value left the weights-filename fingerprint - keying a filename on a measured quantity means the EA looks for a file that does not exist, starts from era 0 and orphans a trained model, silently, because a missing cache is the normal first-run state. The shape lives in the .cfg instead, where LoadAndCompare now ADOPTS the four derived fields rather than diffing them; a mismatch there would discard a fully-trained model over nothing the user did. Two fields appended to the .cfg for the conv/LSTM stages, length-guarded on read because FileReadInteger past EOF returns 0 with no error. ForceHiddenLayers, a compile-time constant like DebuggingMode, pins depth for diagnostic comparisons. It joins the fingerprint only when non-zero, so forced depths get their own files - sequential comparisons only, not simultaneous from one .ex5. Derived shape, H1/10y defaults (21 features x 20 bars): first layer 64, 3 dense, 8 conv filters, 16 LSTM units. The LSTM block halves from ~58k to ~28k weights. Both builds compile 0 errors, 0 warnings. Re-keys existing models. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:05:40 -04:00
//--- Appended 2026-07-30 - guard on the actual file length rather than reading optimistically, because
//--- FileReadInteger past the end returns 0 with no error, and adopting a conv filter count of 0 would
//--- build a degenerate topology out of a file that was merely written by an older build.
bool haveDerivedStages = (FileSize(handle) >= (ulong)FileTell(handle) + 2 * sizeof(int));
int savedConvFilters = haveDerivedStages ? FileReadInteger(handle) : 0;
int savedLstmHidden = haveDerivedStages ? FileReadInteger(handle) : 0;
feat: entry/SL/TP stop being inputs - the barrier geometry is measured Three enums left the Inputs tab. They were three things a user had to pick and, in the tester, three more axes for a genetic optimization to overfit. Entry_Multiplier is pinned to MARKET. Its pending modes place the entry at a LEVEL while the rest of the pipeline measures from the bar open - the exact mismatch that manufactured the +0.097 R "retail fade" result later retracted as a fill artifact. This codebase's fill model cannot honestly simulate a pending entry, so it is no longer offered. SL_Mode/TP_Mode become a STARTING pair. ReportBarrierGeometryScan now ADOPTS its winner instead of printing "set SL_Mode/TP_Mode to X and retrain": - only when it clears the family-wise gate from 04ee2e1 (beat the null of the MAXIMUM, not merely the incumbent). This is why that gate had to land first: without it, removing the inputs would hand a noise-picked geometry direct control over the training target with no human in the loop - strictly worse than the input it replaced. On SP500 H1 today it does NOT clear (p=0.1463), so 2:6 is what you get - now chosen by measurement rather than assumed. - only at m_eraCount == 0. Relabelling a partly-trained net moves the target out from under weights already fitted to the old one. THE GEOMETRY LEFT THE WEIGHTS-FILENAME HASH, because it is now measured. Same rule that moved the horizon and the derived topology values out: a filename keyed on a measured quantity changes the moment the measurement does - a few more bars shift which pairing wins - and the EA then looks for a file that does not exist, starts from era 0 and orphans a trained model silently. It is PINNED IN THE .cfg instead: appended at the end (the only backward-safe change), length-guarded like the 2026-07-30 derived pair, and ADOPTED on load rather than compared, so a trained model keeps the barriers it actually learned and never re-measures. Two traps closed while wiring it, neither of which announces itself: - m_barrierHorizonResolved latches the horizon ONCE PER PROCESS. Adopting 2:8 (wants ~192 bars) after it settled for 2:6 (128) would label the new target against the old ceiling - the truncation fixed in 168422f, where every model learned "target within 128 bars" while the EA holds to SL/TP. It lands in Neutral, not in the timeout counter watching for it. Unlatched on adoption, along with the label cache the old barriers filled. - the .cfg adopt runs at init, before the horizon latches and before any label is computed, so a resumed model has its pinned pair in place first. Verified, not assumed. FORCES A FULL RETRAIN: the fingerprint change orphans every existing .nnw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:39:30 -04:00
//--- Appended 2026-08-07, length-guarded for the same reason: FileReadInteger past the end returns 0
//--- with no error, and adopting SL/TP mode 0 would relabel the whole run against a barrier nobody
//--- chose. ADOPTED rather than compared - the geometry is measured once and pinned, so the .cfg is
//--- the authority for a model that already exists and re-measuring it would be the very drift the
//--- fingerprint change was made to prevent.
bool haveBarrierGeometry = (FileSize(handle) >= (ulong)FileTell(handle) + 2 * sizeof(int));
int savedSlMode = haveBarrierGeometry ? FileReadInteger(handle) : 0;
int savedTpMode = haveBarrierGeometry ? FileReadInteger(handle) : 0;
feat: derive the ATR multiples from measured excursions - no hardcoded geometry The barrier was still two constants. SL_Mode/TP_Mode left the Inputs tab in 3482b6c, but the fallback was a hardcoded 2:6 and the geometry scan only ever chose from a hardcoded grid {2,3} x {2,3,4,6,8,10}. Picking the least-bad of eleven guesses is not deriving anything. WHY THE SCAN WAS THE WRONG INSTRUMENT, now measurable rather than argued. It ranks pairings by how predictable their OUTCOME is - a question about direction. The excursion test (2c78f3b) ran on SP500 H1 and direction is the one thing absent: ASYMMETRY p=0.0846, against RANGE/UP/DOWN all at p=0.0050, with RANGE scoring 0.01345 vs a 0.00343 null - 4x, where the barrier label sits at 1.01x. Hence the scan failing its own gate on every run, and its "winner" wandering 2:8 -> 3:8 -> 2:8 -> 2:4 across four runs of the same data. Excursion SIZE is strongly measurable, so derive the geometry from that instead. stop = q25 of measured ADVERSE travel (ordinary noise does not reach it) target = q50 of measured FAVOURABLE travel (reached ~half the time, by construction, inside the horizon) Continuous, in ATR units, superseding the enum multiples. Reachability ("target on X% of bars, stop on Y%") and the implied break-even are printed so the choice is auditable rather than trusted. FIXED-POINT ITERATION, not one-shot. ComputeBarrierHorizonBars scales the horizon with the target (first-passage time grows with the band) and the excursions are measured OVER the horizon, so target -> horizon -> excursions -> target is a real loop - deriving once sizes the target from travel measured under the PREVIOUS horizon. Re-measures until the multiples move <5%, capped at 3 passes, and says so if it does not settle. Does NOT create expectancy, and the log says as much: chance precision equals break-even at every geometry (m/(m+k) on both sides). It buys a target the market reaches and a stop that survives noise. Where Min_Risk_Reward_Ratio forces a target the market rarely reaches, it WARNS rather than overriding - the ratio is the user's risk policy, so the honest move is to state its cost. That is the collision that once rejected 100% of setups. Pinned in the .cfg as doubles appended AFTER this morning's two ints, so .cfg files written earlier today still load (their length guard finds no doubles) and a model that carries them was trained on them and never re-derives. Also fixes a message from e5ceed6 that claimed "this model resumed from disk" unconditionally - it printed above a "seeding era 0" line on a brand-new model, because the branch fires whenever the cache is not built, which is equally true before a fresh model's first prebuild. A diagnostic that misreports its own trigger is worse than one that says nothing: it gets quoted back as evidence. FORCES A FULL RETRAIN (labels change). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:06:25 -04:00
bool haveDerivedGeometry = (FileSize(handle) >= (ulong)FileTell(handle) + 2 * sizeof(double));
double savedDerivedSl = haveDerivedGeometry ? FileReadDouble(handle) : 0.0;
double savedDerivedTp = haveDerivedGeometry ? FileReadDouble(handle) : 0.0;
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
FileClose(handle);
feat: entry/SL/TP stop being inputs - the barrier geometry is measured Three enums left the Inputs tab. They were three things a user had to pick and, in the tester, three more axes for a genetic optimization to overfit. Entry_Multiplier is pinned to MARKET. Its pending modes place the entry at a LEVEL while the rest of the pipeline measures from the bar open - the exact mismatch that manufactured the +0.097 R "retail fade" result later retracted as a fill artifact. This codebase's fill model cannot honestly simulate a pending entry, so it is no longer offered. SL_Mode/TP_Mode become a STARTING pair. ReportBarrierGeometryScan now ADOPTS its winner instead of printing "set SL_Mode/TP_Mode to X and retrain": - only when it clears the family-wise gate from 04ee2e1 (beat the null of the MAXIMUM, not merely the incumbent). This is why that gate had to land first: without it, removing the inputs would hand a noise-picked geometry direct control over the training target with no human in the loop - strictly worse than the input it replaced. On SP500 H1 today it does NOT clear (p=0.1463), so 2:6 is what you get - now chosen by measurement rather than assumed. - only at m_eraCount == 0. Relabelling a partly-trained net moves the target out from under weights already fitted to the old one. THE GEOMETRY LEFT THE WEIGHTS-FILENAME HASH, because it is now measured. Same rule that moved the horizon and the derived topology values out: a filename keyed on a measured quantity changes the moment the measurement does - a few more bars shift which pairing wins - and the EA then looks for a file that does not exist, starts from era 0 and orphans a trained model silently. It is PINNED IN THE .cfg instead: appended at the end (the only backward-safe change), length-guarded like the 2026-07-30 derived pair, and ADOPTED on load rather than compared, so a trained model keeps the barriers it actually learned and never re-measures. Two traps closed while wiring it, neither of which announces itself: - m_barrierHorizonResolved latches the horizon ONCE PER PROCESS. Adopting 2:8 (wants ~192 bars) after it settled for 2:6 (128) would label the new target against the old ceiling - the truncation fixed in 168422f, where every model learned "target within 128 bars" while the EA holds to SL/TP. It lands in Neutral, not in the timeout counter watching for it. Unlatched on adoption, along with the label cache the old barriers filled. - the .cfg adopt runs at init, before the horizon latches and before any label is computed, so a resumed model has its pinned pair in place first. Verified, not assumed. FORCES A FULL RETRAIN: the fingerprint change orphans every existing .nnw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:39:30 -04:00
if(haveBarrierGeometry && savedSlMode != 0 && savedTpMode != 0
&& (savedSlMode != m_sl_mode || savedTpMode != m_tp_mode))
{
PrintFormat("%s: adopting the barrier geometry this model was trained on - SL %d TP %d (was about "
"to use SL %d TP %d). Measured once at era 0 and pinned; it is not re-measured for an "
"existing model.", __FUNCTION__, savedSlMode, savedTpMode, m_sl_mode, m_tp_mode);
m_sl_mode = savedSlMode;
m_tp_mode = savedTpMode;
}
feat: derive the ATR multiples from measured excursions - no hardcoded geometry The barrier was still two constants. SL_Mode/TP_Mode left the Inputs tab in 3482b6c, but the fallback was a hardcoded 2:6 and the geometry scan only ever chose from a hardcoded grid {2,3} x {2,3,4,6,8,10}. Picking the least-bad of eleven guesses is not deriving anything. WHY THE SCAN WAS THE WRONG INSTRUMENT, now measurable rather than argued. It ranks pairings by how predictable their OUTCOME is - a question about direction. The excursion test (2c78f3b) ran on SP500 H1 and direction is the one thing absent: ASYMMETRY p=0.0846, against RANGE/UP/DOWN all at p=0.0050, with RANGE scoring 0.01345 vs a 0.00343 null - 4x, where the barrier label sits at 1.01x. Hence the scan failing its own gate on every run, and its "winner" wandering 2:8 -> 3:8 -> 2:8 -> 2:4 across four runs of the same data. Excursion SIZE is strongly measurable, so derive the geometry from that instead. stop = q25 of measured ADVERSE travel (ordinary noise does not reach it) target = q50 of measured FAVOURABLE travel (reached ~half the time, by construction, inside the horizon) Continuous, in ATR units, superseding the enum multiples. Reachability ("target on X% of bars, stop on Y%") and the implied break-even are printed so the choice is auditable rather than trusted. FIXED-POINT ITERATION, not one-shot. ComputeBarrierHorizonBars scales the horizon with the target (first-passage time grows with the band) and the excursions are measured OVER the horizon, so target -> horizon -> excursions -> target is a real loop - deriving once sizes the target from travel measured under the PREVIOUS horizon. Re-measures until the multiples move <5%, capped at 3 passes, and says so if it does not settle. Does NOT create expectancy, and the log says as much: chance precision equals break-even at every geometry (m/(m+k) on both sides). It buys a target the market reaches and a stop that survives noise. Where Min_Risk_Reward_Ratio forces a target the market rarely reaches, it WARNS rather than overriding - the ratio is the user's risk policy, so the honest move is to state its cost. That is the collision that once rejected 100% of setups. Pinned in the .cfg as doubles appended AFTER this morning's two ints, so .cfg files written earlier today still load (their length guard finds no doubles) and a model that carries them was trained on them and never re-derives. Also fixes a message from e5ceed6 that claimed "this model resumed from disk" unconditionally - it printed above a "seeding era 0" line on a brand-new model, because the branch fires whenever the cache is not built, which is equally true before a fresh model's first prebuild. A diagnostic that misreports its own trigger is worse than one that says nothing: it gets quoted back as evidence. FORCES A FULL RETRAIN (labels change). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:06:25 -04:00
//--- Derived multiples, same adopt-don't-compare rule and the same length guard. A model that carries
//--- them was TRAINED on them, so re-deriving would relabel a finished run against a target it never
//--- saw - the drift the fingerprint change was made to prevent, arriving through the .cfg instead.
if(haveDerivedGeometry && savedDerivedSl > 0.0 && savedDerivedTp > 0.0)
{
m_derivedSlMult = savedDerivedSl;
m_derivedTpMult = savedDerivedTp;
m_geometryDerived = true;
//--- Block the fixed-point iteration: it only ever runs for a model that has none pinned.
m_geometryDerivePasses = BARRIER_DERIVE_MAX_PASSES;
PrintFormat("%s: adopting the DERIVED barrier this model was trained on - stop %.2f*ATR, target "
"%.2f*ATR. Measured once from the excursion distribution and pinned; not re-derived.",
__FUNCTION__, m_derivedSlMult, m_derivedTpMult);
}
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
//--- isInitialized is DELIBERATELY excluded from the comparison: it is a runtime lifecycle flag, not a
//--- topology/input parameter, and it is always false at the point the compare and the fresh-start save
//--- run (set true only at the end of InitNeuralNetwork). Including it used to make a reset-written .cfg
//--- (saved post-init, flag true) spuriously mismatch on the next attach and discard the just-saved
//--- weights (see ResetWeights); ignoring it here also neutralises any such stale .cfg left on disk.
//--- savedStopTrainWR is excluded for a related reason: the MinWR input it came from no longer exists
//--- (convergence is decided by the plateau ladder - see LEGACY_CONVERGE_WR_SLOT), so whatever value an
//--- older .cfg happens to hold says nothing about whether the saved WEIGHTS are compatible. Comparing
//--- it would discard a perfectly good model from anyone whose MinWR was not the shipped default.
//--- Build an explicit per-field diff so a genuine mismatch says WHICH parameter changed, instead of a
//--- bare "Configuration mismatch" that leaves the user (or us) guessing why a restart retrained from 0.
feat(nn): derive dense depth, train on all history, pin the shape in .cfg Completes the derived-topology work. Three inputs removed. AIType loses its depth suffix - AI_MLP/AI_CONV/AI_LSTM/AI_HYBRID, five entries instead of eight. Depth is now derived from the two endpoints the taper already has to connect (derived first-layer width, output-tied final width) at a 2x per-layer compression target, clamped [2..5]. Asking a user to pick a layer count while the code derives the widths those layers taper between was asking for half a decision: at 64 units tapering to 12, four layers compress by 1.4x per step and five by 1.3x, so the extra depth bought no abstraction. On the shipping H1/10y default the derivation lands on 3 layers - the depth that actually won Run 2. StudyPeriods removed. There is no case for training on less data than the broker provides at a ~6% directional base rate; the honest generalization read comes from the OOS holdout, not from withholding history. Training now starts at the earliest available bar, floored by MinTrainYear, which answers a different question (excluding dubious pre-history) and stays. That required closing the hazard the old code documented: the capacity budget now MEASURES the symbol's real bar count, and a topology derived from a measurement would widen as history downloads. Both ends are now pinned. Every derived value left the weights-filename fingerprint - keying a filename on a measured quantity means the EA looks for a file that does not exist, starts from era 0 and orphans a trained model, silently, because a missing cache is the normal first-run state. The shape lives in the .cfg instead, where LoadAndCompare now ADOPTS the four derived fields rather than diffing them; a mismatch there would discard a fully-trained model over nothing the user did. Two fields appended to the .cfg for the conv/LSTM stages, length-guarded on read because FileReadInteger past EOF returns 0 with no error. ForceHiddenLayers, a compile-time constant like DebuggingMode, pins depth for diagnostic comparisons. It joins the fingerprint only when non-zero, so forced depths get their own files - sequential comparisons only, not simultaneous from one .ex5. Derived shape, H1/10y defaults (21 features x 20 bars): first layer 64, 3 dense, 8 conv filters, 16 LSTM units. The LSTM block halves from ~58k to ~28k weights. Both builds compile 0 errors, 0 warnings. Re-keys existing models. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:05:40 -04:00
//--- ADOPT, DON'T COMPARE - the four DERIVED shape fields (2026-07-30). These describe the model that
//--- exists on disk, not a configuration the user chose, and they are derived from a MEASUREMENT (the
//--- symbol's real available bar count, see EstimatedInSampleBars) that legitimately grows as history
//--- downloads. Comparing them would mean a chart that has since fetched two more years of data
//--- computes a wider first layer, mismatches its own .cfg, and DISCARDS a fully-trained model - a
//--- silent, self-inflicted retrain triggered by nothing the user did. Taking the saved values instead
//--- is what "measure once, then pin" actually means: the shape is decided when the model is created
//--- and never revisited. Re-derived only when there is no .cfg, i.e. for a genuinely new model.
//--- The .nnw is the ultimate authority on layer shapes anyway (CNet::Load rebuilds from the file), so
//--- adopting here keeps the in-memory members honest about the net that is about to be loaded rather
//--- than leaving them describing a topology that was never built.
if(savedInitialNeurons > 0)
initialNeuronsCount = savedInitialNeurons;
if(savedHiddenLayers > 0)
hiddenLayersCount = savedHiddenLayers;
if(savedConvFilters > 0)
convFilterCount = savedConvFilters;
if(savedLstmHidden > 0)
lstmHiddenSize = savedLstmHidden;
//--- savedStudyPeriod is read for layout only and NOT compared - the StudyPeriods input it mirrored was
//--- removed 2026-07-30 (training covers all available history), so like the retired MinWR slot its
//--- value says nothing about whether the saved WEIGHTS are compatible.
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
string diff = "";
if(savedReductionFactor != neuronsReduction) diff += " reduction " + DoubleToString(savedReductionFactor, 4) + "->" + DoubleToString(neuronsReduction, 4) + ";";
if(savedMinNeurons != minNeuronsCount) diff += " minNeurons " + IntegerToString(savedMinNeurons) + "->" + IntegerToString(minNeuronsCount) + ";";
if(savedOptimizationAlgo != optimizationAlgo) diff += " optimizer " + IntegerToString(savedOptimizationAlgo) + "->" + IntegerToString(optimizationAlgo) + ";";
if(savedHistoryBars != historyBars) diff += " historyBars " + IntegerToString(savedHistoryBars) + "->" + IntegerToString(historyBars) + ";";
if(savedOutputNeuronsCount != outputNeuronsCount) diff += " outputs " + IntegerToString(savedOutputNeuronsCount) + "->" + IntegerToString(outputNeuronsCount) + ";";
if(savedNeuronsCount != neuronsCount) diff += " inputWidth " + IntegerToString(savedNeuronsCount) + "->" + IntegerToString(neuronsCount) + " (a feature toggle changed);";
if(savedMinTrainYear != minTrainYear) diff += " minTrainYear " + IntegerToString(savedMinTrainYear) + "->" + IntegerToString(minTrainYear) + ";";
//--- (savedStopTrainWR deliberately NOT compared - retired input, see the note above)
if(savedFractalPeriods != fractalPeriods) diff += " fractalPeriods " + IntegerToString(savedFractalPeriods) + "->" + IntegerToString(fractalPeriods) + ";";
if(diff != "")
{
Print("Configuration mismatch for ", configFileName, " -> retraining from era 0. Changed:", diff,
" (a saved model only reloads when these parameters match exactly; revert the changed input to resume the existing model.)");
FileDelete(configFileName, common ? FILE_COMMON : 0);
return false;
}
return true;
}
//+------------------------------------------------------------------+
//| See the declaration comment - retries a FileCopy FROM FILE_COMMON |
//| that raced a concurrent writer (typically a live chart's own |
//| atomic Save(), mid write-then-rename on the SAME source file). |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::CopyFileWithRetry(string srcFileName, string dstFileName)
{
//--- Retries are only a backstop for a genuinely transient hiccup. The failure actually observed here
//--- (2026-07-26: error 5004 on ALL 8 attempts across ~9s, surviving a full terminal restart, while
//--- FileIsExist on the very same file returned true) is NOT transient and no retry budget can fix it:
//--- FileCopy opens its SOURCE without share flags, i.e. it demands exclusive access, so it fails
//--- outright whenever any other process holds the file open - which is precisely the normal state of
//--- the deployed .nnw while a live chart runs this EA. CopySharedFile() is therefore the real fix
//--- (it opens the source with FILE_SHARE_READ|FILE_SHARE_WRITE); this loop just covers the remaining
//--- ordinary races (e.g. reading mid atomic-rename).
const int RETRY_ATTEMPTS = 5;
const int RETRY_DELAY_CAP_MS = 1000;
int delayMs = 150;
bool ok = false;
for(int attempt = 0; attempt < RETRY_ATTEMPTS && !ok; attempt++)
{
if(attempt > 0)
{
Sleep(delayMs);
delayMs = (int)MathMin(delayMs * 2, RETRY_DELAY_CAP_MS);
}
//--- quiet on every attempt but the last, so an ordinary race that resolves on retry 2 doesn't
//--- spam the journal with scary per-attempt failures.
ok = CopySharedFile(srcFileName, dstFileName, attempt < RETRY_ATTEMPTS - 1);
}
if(!ok)
Print(__FUNCTION__ + ": WARNING - failed to copy " + srcFileName + " (shared folder) -> " + dstFileName +
" after " + IntegerToString(RETRY_ATTEMPTS) + " attempts (see the per-stage reason above)." +
" This run will train from scratch instead of reusing the deployed model.");
return ok;
}
//+------------------------------------------------------------------+
//| Share-aware streamed file copy FROM the shared (FILE_COMMON) |
//| folder INTO this program's own sandbox (the tester agent's local |
//| MQL5\Files when running under the Strategy Tester). |
//| |
//| Exists because FileCopy() cannot do this: it opens the source |
//| WITHOUT FILE_SHARE_* flags, so it returns 5004 ("cannot open |
//| file") whenever another process already has the file open - and a |
//| live chart running this EA holds the deployed model open as its |
//| normal state, which made a backtest silently fall back to an |
//| untrained network. Opening the source with FILE_SHARE_READ | |
//| FILE_SHARE_WRITE explicitly permits reading a file someone else |
//| is using, which is exactly the semantics wanted here: the source |
//| is only ever READ, never written, so sharing it is always safe. |
//| |
//| Writes via a .copytmp + atomic rename (same doctrine as |
//| CNet::Save) so an interrupted copy can never leave a truncated |
//| .nnw that the next run would load as a corrupt model. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::CopySharedFile(string srcFileName, string dstFileName, bool quiet)
{
ResetLastError();
int src = FileOpen(srcFileName, FILE_COMMON | FILE_BIN | FILE_READ | FILE_SHARE_READ | FILE_SHARE_WRITE);
if(src == INVALID_HANDLE)
{
//--- Distinguish the two failure stages explicitly - a source failure here (with the share flags
//--- already set) would mean the shared folder itself is unreachable from this sandbox, which is a
//--- completely different problem from a destination/sandbox write failure below.
if(!quiet)
Print(__FUNCTION__ + ": cannot open SOURCE " + srcFileName + " in the shared folder, error " +
IntegerToString(GetLastError()) + " (share flags were set, so this is not a lock).");
return false;
}
ulong size = FileSize(src);
uchar buf[];
//--- 0-byte source would produce a 0-byte model file that CNet::Load rejects later as a stub - refuse
//--- it here instead, so the caller falls back to a fresh topology with an accurate reason logged.
if(size == 0 || ArrayResize(buf, (int)size) != (int)size)
{
FileClose(src);
if(!quiet)
Print(__FUNCTION__ + ": refusing to copy " + srcFileName + " - source is " + IntegerToString((int)size) +
" bytes (empty, or too large to buffer).");
return false;
}
uint read = FileReadArray(src, buf, 0, (int)size);
FileClose(src);
if(read != (uint)size)
{
if(!quiet)
Print(__FUNCTION__ + ": short read on " + srcFileName + " (" + IntegerToString((int)read) + " of " +
IntegerToString((int)size) + " bytes) - not copying a partial model.");
return false;
}
//--- destination is this program's OWN sandbox (no FILE_COMMON) - never the shared production folder.
string tmpName = dstFileName + ".copytmp";
ResetLastError();
int dst = FileOpen(tmpName, FILE_BIN | FILE_WRITE);
if(dst == INVALID_HANDLE)
{
if(!quiet)
Print(__FUNCTION__ + ": cannot open DESTINATION " + tmpName + " in this agent's sandbox, error " +
IntegerToString(GetLastError()) + ".");
return false;
}
uint written = FileWriteArray(dst, buf, 0, ArraySize(buf));
FileFlush(dst);
FileClose(dst);
if(written != (uint)size)
{
FileDelete(tmpName);
if(!quiet)
Print(__FUNCTION__ + ": short write to " + tmpName + " (" + IntegerToString((int)written) + " of " +
IntegerToString((int)size) + " bytes) - discarded the partial copy.");
return false;
}
//--- atomic swap into place, so a reader never sees a half-written file
if(!FileMove(tmpName, 0, dstFileName, FILE_REWRITE))
{
if(!quiet)
Print(__FUNCTION__ + ": atomic rename " + tmpName + " -> " + dstFileName + " failed, error " +
IntegerToString(GetLastError()) + ".");
ResetLastError();
return false;
}
return true;
}
//+------------------------------------------------------------------+
//| See the declaration comment - retries Net.Load() against the |
//| active file. Covers the same transient-lock class as |
//| CopyFileWithRetry (e.g. antivirus briefly holding the file just |
//| written into the tester's local sandbox) rather than assuming any |
//| single failed read means "no model"/"corrupt file". |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::LoadNetWithRetry(double &indicatorParams[])
{
//--- same exponential-backoff reasoning as CopyFileWithRetry - see its declaration comment.
const int RETRY_ATTEMPTS = 5;
const int RETRY_DELAY_CAP_MS = 2000;
int delayMs = 200;
bool loaded = false;
for(int attempt = 0; attempt < RETRY_ATTEMPTS && !loaded; attempt++)
{
if(attempt > 0)
{
Sleep(delayMs);
delayMs = (int)MathMin(delayMs * 2, RETRY_DELAY_CAP_MS);
}
loaded = Net.Load(m_activeFileName + ".nnw", dError, dUndefine, dForecast, dtStudied, m_activeFileCommon, m_eraCount, m_trainingComplete, indicatorParams);
}
return loaded;
}
#endif // WARRIOR_AIBASE_PERSISTENCE_MQH