Warrior_EA/Expert/AIBase/Topology.mqh
AnimateDread b855196422 refactor(ai): remove the DirectML/D3D12 GPU compute tier (S1.5)
Three backends left, as the operator specified: OpenCL, the CPU DLL,
and pure MQL5. CDirectMLMy was a two-tier wrapper (GPU via
WarriorDML.dll, CPU via WarriorCPU.dll) whose name only ever named the
tier being removed here; the CPU DLL tier - the one actually used on
the training machine (no OpenCL, no DirectML) - is untouched.

AI/NeuronDirectML.mqh -> AI/ComputeDll.mqh: dropped the DML_* #import
block and COMPUTE_TIER_GPU (checked first that nothing persists the
enum value and only one external site reads .Tier() - safe), collapsed
every tier==CPU?CPU_x():DML_x() ternary to a straight CPU_x() call.
Renamed CDirectMLMy->CComputeDll, InitDirectML()->InitComputeDll(),
member directml/DirectML->computeDll/ComputeDll across every AI/ file
that touched a neuron/net backend plus Topology.mqh/OnlineLearning.mqh.
NetBuild.mqh's InitComputeDll also lost the dead D3D12 error-code
switch and the now-impossible GPU-tier log branch.

Verified via per-file brace-balance diff against HEAD and a whole-repo
grep for every removed symbol (CDirectMLMy/InitDirectML/
COMPUTE_TIER_GPU/DML_*) - the only surviving hit is an intentional
historical-note comment in the new file's header.

DirectML\WarriorDML.cpp/.h and its build scripts are now orphaned C++
source, left in place pending an operator decision. Architecture docs
(AI_NETWORK.md, Warrior_EA_System_Overview.md, etc.) still describe the
4-backend/GPU-tier shape and are not updated in this pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 19:32:09 -04:00

1207 lines
70 KiB
MQL5

//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| Topology.mqh |
//| |
//| Network bootstrap and topology construction: the derived shape |
//| (width/taper/depth/conv filters/LSTM hidden), the conv, LSTM and |
//| batch-norm stages, BuildFreshTopology and InitIndicators. |
//+------------------------------------------------------------------+
#ifndef WARRIOR_AIBASE_TOPOLOGY_MQH
#define WARRIOR_AIBASE_TOPOLOGY_MQH
//+------------------------------------------------------------------+
//| THE MODEL FINGERPRINT - every configured value that changes what |
//| the weights mean, and nothing that does not. Its hash names the |
//| .nnw/.cfg pair, so this string alone decides when a trained |
//| model may be resumed and when it must start again from era 0. |
//+------------------------------------------------------------------+
string CExpertSignalAIBase::BuildModelFingerprint(void)
{
//--- Per-configuration fingerprint appended to the weights filename so that every distinct
//--- combination of RETRAIN-AFFECTING inputs gets its OWN persistent .nnw/.cfg, instead of all
//--- combinations sharing one file keyed only on symbol/period/output/optimizer. A model trained
//--- at 1:3 must never be silently reused at 1:1.
string fp = StringFormat("%d|%d|%d|%d|%d|%d|%.2f|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d",
//--- LEGACY_HISTORY_BARS_SLOT: the window left this hash 2026-08-11 when
//--- it became DERIVED - same rule and reason as every derived field above;
//--- keyed on a measured quantity, the filename would change the moment more
//--- history downloads. The .cfg is the record (adopt-don't-compare).
m_optimizationAlgo, LEGACY_HISTORY_BARS_SLOT, m_outputNeuronsCount,
m_neuronsCount, m_minTrainYear, LEGACY_CONVERGE_WR_SLOT, m_fractalPeriods,
//--- LEGACY SLOT (was m_focalGamma, removed 2026-07-31). It was a double fed
//--- to a %d conversion, so it always contributed the literal below rather
//--- than the configured gamma - the shipped fingerprints read ...|40|0|30|...
//--- Writing the same literal keeps every existing model's filename intact.
m_minDirectionalRecallPct, 0, m_oosSplitPct, m_swingConfirmationBars,
(int)m_useVolumes, (int)m_useTime, (int)m_useATR, (int)m_useSwingContext,
(int)m_useNews, m_newsFeatureWindowMinutes);
//--- The one derived value that DOES belong here, and only when it is not derived at all: a
//--- forced depth is a developer override (see ForceHiddenLayers), so a build that pins one must
//--- not adopt the .cfg of a build that derived it.
if(ForceHiddenLayers > 0)
fp += StringFormat("|FHL:%d", ForceHiddenLayers);
//--- THE TRIPLE-BARRIER SHAPE LEFT THIS HASH ON 2026-08-07, when SL_Mode/TP_Mode stopped being
//--- inputs and became MEASURED by ReportBarrierGeometryScan.
fp += StringFormat("|%d|%d|%d|%d|%d|%d|%d|%d",
(int)m_useMA, (int)m_useRSI,
(int)m_useADCumulativeDelta, (int)m_useADShorteningOfThrust,
(int)m_useADWyckoffEventStream, (int)m_useADWyckoffFailedStructure,
(int)m_useADWyckoffSignificantBarInversion,
//--- starting MA TYPE (MA_Type input): changes the MA feature's values, so a change
//--- must invalidate the cache. The auto-tuned type/period themselves live in the
//--- .nnw indicator-param block (Flatten/Unflatten), not here - this is the seed only.
(int)MA_Type);
//--- MACD/Ichimoku feature flags, appended ONLY WHEN ENABLED rather than unconditionally like
//--- every flag above. Conditional append leaves those fingerprints byte-identical. Anything
//--- added here in future should follow the same rule.
if(m_useMACD)
fp += StringFormat("|MACD:%d:%d:%d", (int)MACD_PeriodFast, (int)MACD_PeriodSlow, (int)MACD_PeriodSignal);
if(m_useIchimoku)
fp += StringFormat("|ICHI:%d:%d:%d", (int)Ichimoku_PeriodTenkan, (int)Ichimoku_PeriodKijun, (int)Ichimoku_PeriodSenkou);
//--- Cross-asset panel: conditional append, per the rule above, so existing fingerprints are
//--- untouched. ONLY the flag and the feature count go in. The composition is logged at build
//--- time and pinned in the .cfg instead.
if(m_useCrossAsset)
{
fp += StringFormat("|XA:%d", CROSSASSET_FEATURES);
//--- INDEX-MODE RE-ENCODE (2026-08-11). Same width, different SEMANTICS - so models trained
//--- under the old degenerate encoding must re-key.
if(SymbolInfoString(m_symbol.Name(), SYMBOL_CURRENCY_BASE) ==
SymbolInfoString(m_symbol.Name(), SYMBOL_CURRENCY_PROFIT))
fp += ":IDX2";
}
//--- Spread feature: conditional append, same rule. Nothing measured goes in - the spread series
//--- itself is market data, not configuration.
if(m_useSpreadFeature)
fp += "|SPR:2";
//--- ALT-DATA WINDOW LAYOUT (2026-08-16). The external block now enters the input window ONCE,
//--- on the newest bar, instead of being replicated on all m_historyBars bars (see
//--- BuildFeatureWindow for the measurement and the reason).
if(m_useAltData)
fp += "|ALTW:2";
//--- Batch normalization changes the LAYER COUNT, not just the weights, so a model trained with
//--- it must never load into a topology built without it (and vice versa) - the .cfg guard would
//--- catch the mismatch and retrain, but only after a confusing failure.
if(EnableBatchNorm && BatchNormWindow > 1)
fp += StringFormat("|BN:%d", BatchNormWindow);
//--- Changes the training gradient, so a model trained with it must never load into a run
//--- without it. ":BS" = the correction spans Buy/Sell only, with Neutral (the abstain outcome)
//--- never subsidised - see ApplyLogitAdjustment.
if(m_logitAdjustTau > 0.0)
fp += StringFormat("|LA:%d:BS", (int)MathRound(m_logitAdjustTau * 100.0));
//--- 2026-07-29 audit of every input in Variables\Inputs.mqh against this hash. LEGACY SLOT.
//--- Same treatment as LEGACY_CONVERGE_WR_SLOT / LEGACY_STUDY_PERIOD_SLOT.
fp += "|MR:1:90:1";
//--- Feature-value inputs, each conditional on the feature that reads it actually being on - the
//--- same rule the MACD/Ichimoku blocks above follow. All three also feed the CLASSIC MA/RSI
//--- votes, which are inference-only; gating on the AI feature flag is what keeps a classic-
//--- signal tweak from re-keying a model that never saw it.
if(m_useVolumes)
fp += StringFormat("|VOL:%d", (int)VolumeData);
if(m_useMA)
fp += StringFormat("|MAP:%d", (int)PeriodMA);
if(m_useRSI)
fp += StringFormat("|RSIP:%d", (int)PeriodRSI);
//--- AD/WYCKOFF PARAMETERS. That rule is not theoretical here: the 2026-07-29 audit found five
//--- inputs changing trained weights without changing the filename, which is the exact trap the
//--- .nnw architecture incident already cost a day to.
fp += "|WIN:2";
//--- WYCKOFF CATEGORICAL ENCODING VERSION. Bump the number rather than adding a flag if the
//--- encoding is ever revisited.
if(m_useADWyckoffEventStream)
fp += "|WES:2";
//--- TRAINING TARGET (meta-labeling). Conditional, so every existing direction model keeps its
//--- byte-identical fingerprint. The 2-output head and the State\META\ folder already separate
//--- the FILES; this separates the SEMANTICS.
if(IsMetaTarget())
fp += "|TGT:META1";
//--- Same contract for the fractal-direction target (TrainingTarget input): the token covers the
//--- label's meaning (next confirmed 5-bar fractal extreme, min-move floor, tie handling) - bump
//--- the number if any of that changes.
if(IsFractalTarget())
fp += "|TGT:FRA1";
//--- Ensemble membership separates the FILES, not the semantics: the member's topology and label
//--- are identical to its solo twin, but the two must never share weights across charts (the
//--- duplicate-chart guard exists precisely to stop concurrent writers).
if(m_ensembleMember)
fp += "|ENS1";
//--- The scheduled close-all became part of the LABEL'S MEANING on 2026-08-19:
//--- TripleBarrierLabel stops its walk at the next scheduled flat, so the same chart with a
//--- Friday-23:45 schedule and with an everyday-22:00 schedule trains two DIFFERENT targets.
if((int)targetDayOfWeek != -1 && (int)targetHour != -1 && (int)targetMinutes != -1)
fp += "|CUT:" + IntegerToString((int)targetDayOfWeek) + "@" + IntegerToString((int)targetHour) +
":" + IntegerToString((int)targetMinutes);
return fp;
}
//+------------------------------------------------------------------+
//| Common network bootstrap shared by every AI signal: sets up |
//| indicators, then loads a saved network or builds a fresh one |
//| whose only per-signal-type difference is AddCustomLayers(). |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::InitNeuralNetwork(CIndicators *indicators)
{
if(m_isInitialized)
return true;
if(indicators == NULL)
return false;
m_indicatorsPtr = indicators;
if(!CExpertSignalCustom::InitIndicators(indicators))
return false;
if(!InitFeatureIndicators(indicators))
return false;
//--- Kick the terminal's async history sync for every cross-asset reference symbol NOW, at init,
//--- so the ~minute of cross-symbol download runs while the model loads and the label cache
//--- prebuilds - instead of starting only when the first Build() call finds the symbols unselected
//--- and the first era (and the one-shot MI report) runs with the panel absent. Non-blocking.
if(m_useCrossAsset)
m_crossAsset.Warm((ENUM_TIMEFRAMES)m_period);
Net = new CNet(NULL);
if(CheckPointer(Net) == POINTER_INVALID)
return false;
//--- Size the first dense layer to the data. InitIndicators() above is what finalises
//--- m_neuronsCount, so this is the earliest point the input width is actually known. ORDER
//--- MATTERS, and it changed on 2026-08-09.
m_historyBars = DeriveHistoryBars();
m_convFilterCount = ComputeConvFilterCount();
m_lstmHiddenSize = ComputeLstmHiddenSize();
m_initialNeuronsCount = ComputeFirstLayerWidth();
//--- Depth LAST of the four: it is derived from the first-layer width above, so it cannot be settled
//--- before that one is. All four are overwritten from the .cfg further below if this configuration
//--- already has a trained model - see the adopt-don't-compare block there.
m_hiddenLayersCount = ComputeHiddenLayerCount();
//--- The name used to carry a dense-depth tag ("Perceptron 3L"), from when AIType let a user
//--- pick MLP_3L vs MLP_4L and the depth was the only thing separating two charts of the same
//--- family.
string fp = BuildModelFingerprint();
//--- FNV-1a 32-bit -> 8 hex chars: compact, deterministic, order-stable, collision-safe enough for
//--- the small optimizer grids in play (a collision would merely fail the .cfg guard and retrain).
uint fpHash = 2166136261;
int fpLen = StringLen(fp);
for(int fpi = 0; fpi < fpLen; fpi++)
{
fpHash ^= (uint)StringGetCharacter(fp, fpi);
fpHash *= 16777619;
}
m_fileName += "_" + DoubleToString(MathRound(m_outputNeuronsCount)) + "_" + DoubleToString(MathRound(m_optimizationAlgo)) + "_" + StringFormat("%08x", fpHash);
//--- Finish the display name with the model's short id and the leading 4 hex digits of that same
//--- fingerprint, so every log line and panel names the model file it belongs to. Their files
//--- were never at risk; the TAG was simply unable to do its one job.
string cfgTag = " [" + m_id + "-" + StringSubstr(StringFormat("%08x", fpHash), 0, 4) + "]";
if(StringFind(ID, cfgTag) < 0)
ID += cfgTag;
//--- One self-verifying config line per chart, deliberately NOT gated on VerboseMode. A multi-
//--- chart comparison is only valid if every chart is identical except the axis under test, and
//--- until now a drifted setting was invisible: the filename carries a HASH, so two charts that
//--- should match and do not look merely "different" with no indication of WHICH field moved.
Print(ID + ": config - " + IntegerToString(m_hiddenLayersCount) + " dense from " +
IntegerToString(m_initialNeuronsCount) + " units | batchnorm " +
((EnableBatchNorm && BatchNormWindow > 1) ? "ON(" + IntegerToString(BatchNormWindow) + ")" : "OFF") +
//--- "requested", not the bare number: the EFFECTIVE tau is capped against the head's
//--- usable logit range and cannot be known until the class priors are measured, so
//--- printing 1.00 here read as the value in force when every chart was actually running
//--- 0.35.
" | class-imbalance " + (m_logitAdjustTau > 0.0
? "logit-adjust(tau " + DoubleToString(m_logitAdjustTau, 2) + " requested)" : "OFF") +
" | input " + IntegerToString((int)m_historyBars * m_neuronsCount) +
" (" + IntegerToString((int)m_historyBars) + " bars x " + IntegerToString(m_neuronsCount) + ")" +
//--- The front-end stages are DERIVED (see ComputeConvFilterCount/ComputeLstmHiddenSize),
//--- so without them this "self-verifying" line verified only half the topology - it
//--- printed the dense taper while the conv/recurrent stages that actually dominate
//--- CONV/LSTM/HYBRID were invisible.
FrontEndConfigSummary());
//--- Kept as its own line and deliberately free of any per-chart prefix INSIDE the string, so
//--- the six startup lines diff textually against each other.
Print(ID + ": fingerprint - " + fp);
//--- The resolved path is DebuggingMode-only: the tag above already names the folder (its m_id half)
//--- and the file's hash suffix (its hex half), so this line is derivable rather than new information,
//--- and a third startup line per chart is not worth spending on a user who will never open the file.
if(DebuggingMode)
Print(ID + ": model file - " + m_fileName + ".nnw");
//--- Strategy Tester / optimizer: target a LOCAL (agent-sandboxed, non-FILE_COMMON) cache file
//--- instead of the shared production weights, so genetic/complete optimization passes on this
//--- same agent can reuse an already-trained model whenever the topology-relevant inputs
//--- (neuron counts, layers, history bars, output count, opt algo, study period, ...) are
//--- unchanged from a previous pass, instead of re-running every training era from scratch each
//--- pass. The live/manual-chart production .nnw/.cfg under FILE_COMMON are never touched by
//--- this path, so a backtest can never corrupt or overwrite the deployed live model.
bool inTesterOrOpt = MQLInfoInteger(MQL_TESTER) || MQLInfoInteger(MQL_OPTIMIZATION) || MQLInfoInteger(MQL_FORWARD);
m_activeFileName = inTesterOrOpt ? (m_fileName + "_optcache") : m_fileName;
m_activeFileCommon = !inTesterOrOpt;
//--- Claim these files before anything reads or writes them, and refuse to start if another
//--- chart in this terminal already holds them (see AcquireConfigLock). SKIPPED under
//--- m_exportFeaturesOnly: that mode reads history and writes one CSV - it never trains, never
//--- saves a model (Warrior_EA.mq5's OnTick returns immediately, so no era ever completes) and
//--- therefore has nothing to protect against a concurrent chart.
if(!m_exportFeaturesOnly && !inTesterOrOpt && !AcquireConfigLock())
return false;
//--- Any Strategy-Tester run - a single backtest OR an optimization pass - runs pure inference
//--- on the deployed model, never trains.
m_inferenceOnly = MQLInfoInteger(MQL_TESTER);
//--- Seed the agent-local optcache from the deployed production model on the first tester/opt
//--- pass. Re-seeds when the cache is MISSING *or* STALE. Copies FROM FILE_COMMON (the
//--- live/manual-chart model) INTO the agent-local sandbox only; the production files are read,
//--- never written, so a backtest still can't corrupt the deployed model.
bool cacheMissing = !FileIsExist(m_activeFileName + ".nnw");
bool cacheStale = false;
if(inTesterOrOpt && !cacheMissing && FileIsExist(m_fileName + ".nnw", FILE_COMMON))
{
datetime prodModified = (datetime)FileGetInteger(m_fileName + ".nnw", FILE_MODIFY_DATE, true);
datetime cacheModified = (datetime)FileGetInteger(m_activeFileName + ".nnw", FILE_MODIFY_DATE, false);
//--- both timestamps must be readable before trusting the comparison; a 0 means "couldn't tell",
//--- and re-seeding on an unreadable timestamp every single pass would be worse than not checking.
cacheStale = (prodModified > 0 && cacheModified > 0 && prodModified > cacheModified);
if(cacheStale)
Print(__FUNCTION__ + ": the deployed model is newer than this agent's cached copy - re-seeding so the backtest runs the CURRENT model, not the previously cached one.");
}
if(inTesterOrOpt && (cacheMissing || cacheStale))
{
if(FileIsExist(m_fileName + ".nnw", FILE_COMMON))
{
//--- The .nnw is the only copy that MUST succeed - retried (see CopyFileWithRetry's
//--- declaration comment) because a live chart's own atomic Save() can be mid-rename on
//--- this exact file.
if(CopyFileWithRetry(m_fileName + ".nnw", m_activeFileName + ".nnw"))
{
//--- Best-effort sidecars: not retried - losing one just means a cold
//--- calibration/shadow-blend start rather than a wrong/untrained model, which the .nnw
//--- copy above already guards against.
if(FileIsExist(m_fileName + ".cfg", FILE_COMMON))
CopySharedFile(m_fileName + ".cfg", m_activeFileName + ".cfg", false);
if(FileIsExist(m_fileName + "_shadow.nnw", FILE_COMMON))
CopySharedFile(m_fileName + "_shadow.nnw", m_activeFileName + "_shadow.nnw", false);
//--- carry the calibration sidecar into the agent sandbox too, so a seeded backtest calibrates its
//--- live decisions with the deployed model's priors instead of the un-adjusted cold defaults.
if(FileIsExist(m_fileName + ".stats", FILE_COMMON))
CopySharedFile(m_fileName + ".stats", m_activeFileName + ".stats", false);
Print(__FUNCTION__ + ": seeded tester cache from the deployed production model (" + m_fileName + ") - this run reuses the deployed weights instead of retraining");
}
//--- else: CopyFileWithRetry already logged why. Fall through - the Net.Load() below will
//--- correctly report "no file" and BuildFreshTopology() takes over, same as a genuine first pass.
}
else if(m_inferenceOnly)
//--- Name the exact file (symbol + timeframe + config fingerprint) it looked for: the
//--- model is keyed on the CHART TIMEFRAME, so the #1 cause of this is running the tester
//--- on a different timeframe than the model was trained on (e.g. an H4 model, tester set
//--- to H1) - which reads as "no model" when one exists under a different timeframe.
Print(__FUNCTION__ + ": WARNING - no deployed production model found at '" + m_fileName +
".nnw' (shared folder) for " + _Symbol + " " + EnumToString((ENUM_TIMEFRAMES)_Period) +
". A single backtest runs inference only and will NOT train. Most common cause: the tester" +
" timeframe differs from the one the model was trained on (the filename is keyed on timeframe)." +
" Otherwise, train this configuration on a chart first, then re-run the backtest.");
}
if(!LoadAndCompareTopologyConfiguration(m_activeFileName, m_initialNeuronsCount, m_hiddenLayersCount, m_neuronsReduction, m_minNeuronsCount, m_optimizationAlgo, m_historyBars, m_outputNeuronsCount, m_neuronsCount, m_minTrainYear, m_isInitialized, LEGACY_CONVERGE_WR_SLOT, m_fractalPeriods, m_convFilterCount, m_lstmHiddenSize, m_activeFileCommon))
{
//--- Topology/input params diverged from what produced the saved .nnw (or no .cfg exists yet;
//--- for inTesterOrOpt this is also the normal "first pass on this agent" case).
if(FileIsExist(m_activeFileName + ".nnw", m_activeFileCommon ? FILE_COMMON : 0))
{
Print(__FUNCTION__ + ": " + m_activeFileName + " - topology/input params changed since last save; discarding incompatible saved weights and starting fresh");
FileDelete(m_activeFileName + ".nnw", m_activeFileCommon ? FILE_COMMON : 0);
//--- Reaching here means a TRAINED model was just thrown away, so its drawn signals are
//--- stale for exactly the same reason ResetWeights() clears them: they would otherwise be
//--- restored moments later (LoadChartSignals runs at the end of this function) and shown
//--- as if they belonged to the model about to be trained.
ClearPersistedChartSignals("saved weights discarded - topology/input params changed");
}
if(FileIsExist(m_activeFileName + "_ckpt.tmp", m_activeFileCommon ? FILE_COMMON : 0))
FileDelete(m_activeFileName + "_ckpt.tmp", m_activeFileCommon ? FILE_COMMON : 0);
// Same reasoning applies to the EMA shadow-weight file (see m_shadowNet's declaration comment) -
// it's shaped for the OLD topology too, and EnsureShadowNet() has no independent way to detect
// that mismatch on Load() (CNet::Load() doesn't cross-validate against an expected shape). Drop
// it so EnsureShadowNet() cleanly misses and re-bootstraps from the fresh Net instead.
if(FileIsExist(m_activeFileName + "_shadow.nnw", m_activeFileCommon ? FILE_COMMON : 0))
FileDelete(m_activeFileName + "_shadow.nnw", m_activeFileCommon ? FILE_COMMON : 0);
//--- the calibration sidecar is tied to the discarded weights - drop it too so a fresh run
//--- re-measures priors from scratch instead of adjusting with a stale model's base rates.
if(FileIsExist(m_activeFileName + ".stats", m_activeFileCommon ? FILE_COMMON : 0))
FileDelete(m_activeFileName + ".stats", m_activeFileCommon ? FILE_COMMON : 0);
//--- and the pattern-database backfill marker (see StartPatternDatabaseBackfill): it records
//--- the era of the model whose OOS calls were written into the ranking tables.
if(FileIsExist(m_activeFileName + ".dbfill", m_activeFileCommon ? FILE_COMMON : 0))
FileDelete(m_activeFileName + ".dbfill", m_activeFileCommon ? FILE_COMMON : 0);
SaveTopologyConfiguration(m_activeFileName, m_initialNeuronsCount, m_hiddenLayersCount, m_neuronsReduction, m_minNeuronsCount, m_optimizationAlgo, m_historyBars, m_outputNeuronsCount, m_neuronsCount, LEGACY_STUDY_PERIOD_SLOT, m_minTrainYear, m_isInitialized, LEGACY_CONVERGE_WR_SLOT, m_fractalPeriods, m_convFilterCount, m_lstmHiddenSize, m_activeFileCommon);
}
double loadedIndicatorParams[];
//--- Inference-only backtest: if this deployed model was validated MQL5-inference-safe at deploy
//--- (marker in its .stats), load it host-only and run the pure-MQL5 forward path so the backtest
//--- never loads WarriorDML/WarriorCPU.dll - no DLL file-lock class of failure, and the exact math
//--- the Market build ships. Falls back to a compute backend just below if that load fails.
if(m_inferenceOnly && CheckPointer(Net) != POINTER_INVALID)
{
LoadModelStats(m_activeFileName, m_activeFileCommon); // reads m_mqlInferenceValidated (and priors)
if(m_mqlInferenceValidated)
{
Net.SetCpuInference(true);
PrintVerbose(__FUNCTION__ + ": " + ID + " - inference-only backtest running pure-MQL5 (DLL-free): the deployed model is validated MQL5-inference-safe");
}
}
bool netLoaded = LoadNetWithRetry(loadedIndicatorParams);
//--- Pure-MQL5 load failed unexpectedly (should not happen for a validated model) - drop back to a
//--- compute backend and retry once so the backtest still runs via the DLL rather than on a fresh net.
if(!netLoaded && CheckPointer(Net) != POINTER_INVALID && Net.CpuInference())
{
Print(__FUNCTION__ + ": " + ID + " - pure-MQL5 load failed; retrying with a compute backend (DLL)");
Net.SetCpuInference(false);
netLoaded = LoadNetWithRetry(loadedIndicatorParams);
}
//--- the file may carry a superseded architecture - correct it before anything reads the net
if(netLoaded)
EnforceTopologyContract();
//--- A superseded conv receptive field cannot be repaired in place (different weight-tensor shape),
//--- so the loaded net is discarded and the fresh-topology path below rebuilds and retrains.
if(netLoaded && m_topologySuperseded)
netLoaded = false;
//--- restore the calibration sidecar (priors + confidence scale) that pairs with these weights, so a
//--- restart - including a buyer's inference-only backtest - calibrates live decisions exactly as the
//--- saved model did instead of running with cold defaults (priors 0 => no adjustment). See LoadModelStats().
if(netLoaded)
LoadModelStats(m_activeFileName, m_activeFileCommon);
m_modelLoadedFromDisk = netLoaded;
//--- Make a successful resume visible (the counterpart to the fresh-start / mismatch messages below):
//--- on a live chart this confirms the saved model was found and loaded rather than silently retrained.
if(netLoaded && !inTesterOrOpt)
Print(ID + ": resumed saved model from era " + IntegerToString(m_eraCount) + " (trainingComplete=" + (string)m_trainingComplete + ") - continuing, not retraining from era 0.");
//--- RESUMED MODELS GET THE SAME WARM-UP AS FRESH ONES (2026-08-13; was `netLoaded ? 0 : 3`).
//--- Three no-op passes cost seconds. The label cache itself, however, is NEVER restored from
//--- the .nnw checkpoint - it lives only in the in-memory m_labelCacheBuy/Sell/HasValue arrays,
//--- which start empty every process start regardless of netLoaded.
m_warmupPassesRemaining = 3;
m_labelCachePrebuilt = false;
if(inTesterOrOpt && netLoaded)
Print(__FUNCTION__ + ": " + ID + " - reused cached weights from a previous optimization/tester pass on this agent (era " + IntegerToString(m_eraCount) + ", trainingComplete=" + (string)m_trainingComplete + ") - skipping redundant training for this unchanged config");
if(netLoaded && ArraySize(loadedIndicatorParams) == AD_TUNE_PARAM_COUNT)
{
//--- Restart deploying previously AutoTune-d indicator params even with
//--- AutoTuneIndicators=false now.
AdoptIndicatorParams(loadedIndicatorParams, indicators);
}
if(!netLoaded)
{
int error_code = GetLastError();
//--- Do NOT present error_code as the cause: on a no-GPU/CPU-DLL box it is the harmless 5100
//--- (OpenCL-not-found) left by the compute probe inside CNet::Load, NOT the reason the file
//--- was rejected.
if(error_code != 5004) // not "file not found"
ResetLastError();
//--- CRITICAL: a failed load may have ALREADY overwritten the training-state out-params from
//--- the bad file's header before it was rejected - notably a corrupt/empty 0-layer stub
//--- whose header still says trainingComplete=1 (see CNet::Load's 0-layer guard).
m_trainingComplete = false;
m_eraCount = 0;
dtStudied = 0;
dForecast = 0;
//--- Cold the in-memory calibration so the freshly-rebuilt (untrained) topology below runs
//--- with no stale prior-correction until a retrain re-measures it (priors 0 =>
//--- AdjustedSignalFromSoftmax is a no-op; scale 1.0 = the constructor default).
m_priorBuy = 0.0;
m_priorSell = 0.0;
m_priorNeutral = 0.0;
m_confidenceCalScale = 1.0;
//--- Accurate diagnostic (do NOT cite GetLastError() - inside CNet::Load the OpenCL probe leaves 5100
//--- there on a no-GPU/CPU-DLL box, which has nothing to do with the file). Distinguish an ordinary
//--- fresh start (no file yet) from a real read failure of an existing file by testing existence.
if(!inTesterOrOpt)
{
int loadFlags = m_activeFileCommon ? FILE_COMMON : 0;
if(FileIsExist(m_activeFileName + ".nnw", loadFlags))
Print(ID + ": could not read the existing model file " + m_activeFileName + ".nnw - rebuilding a fresh topology to retrain from era 0. Existing .stats/_shadow.nnw are KEPT (they refresh as training runs). If this recurs, that .nnw is likely corrupt - back it up, then use the panel's reset-weights to start clean.");
else
Print(ID + ": no saved model for this config yet - starting a fresh training run from era 0.");
}
//--- Re-seed before building a fresh topology so weight init is genuinely random. See
//--- System\Random.mqh. Matches ResetWeights() and OnInit.
WarriorRandSeed(ID);
//--- Era 0 with no weights behind it, so any arrow currently on this chart was drawn by a
//--- DIFFERENT model - the previous fingerprint's, or a corrupt .nnw's. Deliberately at
//--- this call site rather than inside BuildFreshTopology(): the genetic tuner calls that
//--- for every throwaway candidate (AutoTune.mqh) and must not touch the chart.
ClearPersistedChartSignals("fresh topology at era 0 - arrows belong to a previous model");
if(!BuildFreshTopology())
return false;
}
TempData = new CArrayDouble();
if(CheckPointer(TempData) == POINTER_INVALID)
return false;
if(netLoaded)
// Populate dPrevSignal from the just-loaded weights immediately, rather than leaving it at
// its blank constructor default until the next (asynchronous, queued) training pass happens
// to run - matters most for the tester cache-reuse path above, where training may be skipped
// entirely for this run because dtStudied already covers the whole backtest window.
RefreshLatestSignal();
//--- Status line must match what the gate below (if(!m_trainingComplete && !m_inferenceOnly)) will
//--- actually do - otherwise an inference-only single backtest logs "resuming full training now" right
//--- under the "runs inference only and will NOT train" warning, which reads as a contradiction.
string trainState = m_trainingComplete
? "already complete - staying converged, no full retrain on this restart"
: (m_inferenceOnly
? "NOT complete, but this is an inference-only backtest - NOT training (see warning above); deploy a trained model for meaningful results"
: "NOT complete (interrupted or never converged) - resuming full training now");
Print(__FUNCTION__ + ": " + m_activeFileName + " - training " + trainState);
//--- Only kick off a full Train() run here if the loaded model genuinely isn't converged yet - an
//--- already-complete model used to get one full era-loop retrain (real Net.backProp() over the
//--- whole IS window) on every single EA restart/reattach for no reason, since this "Init" event
//--- bypassed ScheduleTrainingIfNeeded()'s m_trainingComplete gate entirely. dPrevSignal is already
//--- fresh from RefreshLatestSignal() above; ScheduleTrainingIfNeeded()'s normal per-tick check
//--- will call RefreshConvergedSignal() itself once a genuinely new bar closes.
if(!m_trainingComplete && !m_inferenceOnly)
ArmStudyEvent((long)MathMax(0, MathMin(iTime(_Symbol, PERIOD_CURRENT, (int)(100 * Net.recentAverageSmoothingFactor * (m_trainingComplete ? 1 : 10))), dtStudied)), "Init");
//--- Restore arrows persisted from a previous session (see SaveChartSignals). MUST run here, not in
//--- InitIndicators(): the arrows file is keyed on the FULL m_fileName including the per-config
//--- fingerprint, which is only appended above - see the note left at InitIndicators()'s old call site.
LoadChartSignals();
//--- bootstrap (or restore) the EMA shadow net now rather than waiting for the first
//--- RefreshLatestSignal()/era-blend call to lazily trigger it - see m_shadowNet's declaration
//--- comment.
EnsureShadowNet();
m_isInitialized = true;
//--- RESEARCH ONLY, under m_exportFeaturesOnly. Runs here because this is the first point at which
//--- the indicators, the buffers and the derived barrier horizon are all settled, and it needs no
//--- model, no labels and no training.
if(m_exportFeaturesOnly)
ExportFeatureMatrix();
return true;
}
//+------------------------------------------------------------------+
//| Shared training-set size estimate - see the declaration comment. |
//+------------------------------------------------------------------+
double CExpertSignalAIBase::EstimatedInSampleBarsRaw(void) const
{
int secs = PeriodSeconds(m_period);
if(secs <= 0)
secs = PeriodSeconds(PERIOD_H1);
double barsPerYear = (SECONDS_PER_YEAR / (double)secs) * MARKET_OPEN_FRACTION;
double oosKept = (100.0 - (double)m_oosSplitPct) / 100.0;
//--- MEASURED from the symbol's real history, matching Train()'s window exactly (earliest
//--- available bar, floored by MinTrainYear) now that training covers everything available
//--- rather than a configured number of years.
datetime firstAvailableBar = (datetime)SeriesInfoInteger(_Symbol, m_period, SERIES_FIRSTDATE);
MqlDateTime floorTime;
TimeCurrent(floorTime);
floorTime.year = m_minTrainYear;
floorTime.mon = 1;
floorTime.day = 1;
floorTime.hour = 0;
floorTime.min = 0;
floorTime.sec = 0;
datetime windowStart = StructToTime(floorTime);
if(firstAvailableBar > windowStart)
windowStart = firstAvailableBar;
int available = Bars(_Symbol, m_period, windowStart, TimeCurrent());
//--- History may not have finished syncing when a chart first attaches, and a model whose
//--- capacity was pinned from a handful of bars would stay crippled for its whole life - the one
//--- failure mode that measuring instead of assuming introduces.
if(available < TOPOLOGY_BUDGET_MIN_TRUSTED_BARS)
{
Print(ID + ": WARNING - only " + IntegerToString(available) + " bars of " + _Symbol +
" history are available yet, too few to size the network from. Falling back to a " +
IntegerToString(TOPOLOGY_BUDGET_FALLBACK_YEARS) + "-year assumption. If this is a fresh" +
" install, let the terminal finish downloading history and then delete this model's weights" +
" from the panel so the topology is sized from the real data.");
return (double)TOPOLOGY_BUDGET_FALLBACK_YEARS * barsPerYear * oosKept;
}
return (double)available * oosKept;
}
//+------------------------------------------------------------------+
//| In-sample budget in INDEPENDENT observations - see declaration. |
//+------------------------------------------------------------------+
double CExpertSignalAIBase::EstimatedInSampleBars(void) const
{
//--- DEFLATED BY LABEL OVERLAP (2026-08-19). 4 - the same correction every standard error in
//--- this file already applies).
return EffectiveSampleSize(EstimatedInSampleBarsRaw());
}
//+------------------------------------------------------------------+
//| Fan-in of the first dense layer - see the declaration comment. |
//+------------------------------------------------------------------+
int CExpertSignalAIBase::FirstLayerFanIn(void) const
{
int frontEndOut = UsesLstmStage() ? m_lstmHiddenSize
: (UsesConvStage() ? ConvOutputWidth() : 0);
return (frontEndOut > 0) ? frontEndOut : (int)m_historyBars * m_neuronsCount;
}
//+------------------------------------------------------------------+
//| Derived input-window length - see the declaration comment. |
//+------------------------------------------------------------------+
int CExpertSignalAIBase::DeriveHistoryBars(void)
{
int availableBars = Bars(_Symbol, m_period);
int span = (int)MathMin(availableBars - 1, WINDOW_DERIVE_SPAN_BARS);
if(span < TOPOLOGY_BUDGET_MIN_TRUSTED_BARS)
{
Print(ID + ": WARNING - only " + IntegerToString(availableBars) + " bars of " + _Symbol +
" history are available yet, too few to measure the input window from. Falling back to " +
IntegerToString(HISTORY_BARS_FALLBACK) + " bars. If this is a fresh install, let history "
"finish downloading and delete this model's weights so the window is measured from real data.");
return HISTORY_BARS_FALLBACK;
}
//--- newest CLOSED bars only (start 1): the forming bar's extremes are still moving
double hi[], lo[];
ArraySetAsSeries(hi, true);
ArraySetAsSeries(lo, true);
if(CopyHigh(_Symbol, m_period, 1, span, hi) != span ||
CopyLow(_Symbol, m_period, 1, span, lo) != span)
{
Print(ID + ": WARNING - could not read " + IntegerToString(span) + " bars to measure the input "
"window; falling back to " + IntegerToString(HISTORY_BARS_FALLBACK) + " bars.");
return HISTORY_BARS_FALLBACK;
}
//--- Swing pivots: strict local extremum against the NEWER side, >= against the older side (the
//--- standard tie-break so a flat top counts once).
double legs[];
ArrayResize(legs, 0, 256);
int lastType = 0; // +1 swing high, -1 swing low, 0 none yet
int lastPivotBar = -1;
double lastExtreme = 0.0;
for(int b = span - 1 - WINDOW_SWING_WING; b >= WINDOW_SWING_WING; b--) // oldest -> newest
{
bool isHigh = true, isLow = true;
for(int w = 1; w <= WINDOW_SWING_WING && (isHigh || isLow); w++)
{
if(hi[b] <= hi[b - w] || hi[b] < hi[b + w])
isHigh = false;
if(lo[b] >= lo[b - w] || lo[b] > lo[b + w])
isLow = false;
}
int type = 0;
if(isHigh != isLow)
type = isHigh ? 1 : -1; // a bar that is both is degenerate; skip it
if(type == 0)
continue;
if(type == lastType)
{
double x = (type == 1) ? hi[b] : lo[b];
if((type == 1 && x > lastExtreme) || (type == -1 && x < lastExtreme))
{
lastPivotBar = b;
lastExtreme = x;
}
continue;
}
if(lastType != 0)
{
int n = ArraySize(legs);
ArrayResize(legs, n + 1, 256);
legs[n] = lastPivotBar - b; // series indices: newer bar = smaller index
}
lastType = type;
lastPivotBar = b;
lastExtreme = (type == 1) ? hi[b] : lo[b];
}
if(ArraySize(legs) < WINDOW_DERIVE_MIN_LEGS)
{
Print(ID + ": WARNING - only " + IntegerToString(ArraySize(legs)) + " confirmed swing legs in " +
IntegerToString(span) + " bars, too few to trust a median. Falling back to " +
IntegerToString(HISTORY_BARS_FALLBACK) + " bars.");
return HISTORY_BARS_FALLBACK;
}
double median = MathMedian(legs);
//--- snap DOWN to the ladder (see LEGACY_HISTORY_BARS_SLOT's comment for floor/cap rationale)
int ladder[] = {12, 16, 20, 24, 32};
int window = HISTORY_BARS_FLOOR;
for(int i = 0; i < ArraySize(ladder); i++)
if(ladder[i] <= median)
window = ladder[i];
PrintFormat("%s: derived input window - %d bars (median confirmed swing leg %.1f over %d legs in %d "
"bars, snapped down to the ladder%s). Measured once at model creation and pinned in the "
".cfg; an existing model adopts its own trained window instead.",
ID, window, median, ArraySize(legs), span,
median > 32 ? ", CAPPED at 32 - era time scales with the window" : "");
return window;
}
//+------------------------------------------------------------------+
//| Dense-taper depth - see the declaration comment. |
//+------------------------------------------------------------------+
int CExpertSignalAIBase::ComputeHiddenLayerCount(void) const
{
//--- Diagnostic escape hatch (compile-time, see ForceHiddenLayers). Deliberately not an input: this
//--- exists to run depth comparisons while working on the EA, and a user who picks a depth is
//--- contradicting the width and taper the code derived around it.
if(ForceHiddenLayers > 0)
return (int)MathMax(1, MathMin(MAX_HIDDEN_LAYERS, ForceHiddenLayers));
//--- Depth follows from the two ENDPOINTS the taper already has to connect - the derived first-
//--- layer width and the output-tied final hidden width (see BuildFreshTopology's taper block) -
//--- by asking how many steps it takes to get from one to the other at a sane per-layer
//--- compression ratio.
int lastHidden = (int)MathMax(HIDDEN_TAPER_OUTPUT_MULTIPLE * m_outputNeuronsCount, HIDDEN_TAPER_MIN_WIDTH);
lastHidden = (int)MathMin(lastHidden, m_initialNeuronsCount);
if(lastHidden <= 0 || m_initialNeuronsCount <= lastHidden)
return MIN_HIDDEN_LAYERS;
double steps = MathLog((double)m_initialNeuronsCount / (double)lastHidden) / MathLog(HIDDEN_TAPER_TARGET_RATIO);
int layers = (int)MathRound(steps) + 1; // +1: the first layer IS the starting endpoint, not a step
return (int)MathMax(MIN_HIDDEN_LAYERS, MathMin(MAX_HIDDEN_LAYERS, layers));
}
//+------------------------------------------------------------------+
//| Conv output-filter count - see the declaration comment. |
//+------------------------------------------------------------------+
int CExpertSignalAIBase::ComputeConvFilterCount(void) const
{
//--- AddConvStage sets window = ConvReceptiveFieldBars() * m_neuronsCount and step =
//--- m_neuronsCount, so each sliding position covers that many BARS of features and the layer is
//--- a learned projection from the whole window down to this many filters.
int chosen = (ConvReceptiveFieldBars() * m_neuronsCount) / CONV_COMPRESSION_DIVISOR;
//--- Snap DOWN to a power-of-two ladder for the same reason the first-layer width does: the target is
//--- approximate, and a value that moves with every feature toggle would re-key the weights file more
//--- often than the change in capacity justifies.
int ladder[] = {4, 8, 16, 32};
int snapped = CONV_FILTERS_MIN;
for(int i = 0; i < ArraySize(ladder); i++)
if(ladder[i] <= chosen)
snapped = ladder[i];
return (int)MathMax(CONV_FILTERS_MIN, MathMin(CONV_FILTERS_MAX, snapped));
}
//+------------------------------------------------------------------+
//| Derived front-end stages, for the startup config line. |
//+------------------------------------------------------------------+
string CExpertSignalAIBase::FrontEndConfigSummary(void) const
{
string s = "";
//--- conv slides a ConvReceptiveFieldBars()-bar window one bar at a time, emitting m_convFilterCount
//--- filters per position; the optional channel pool + second conv follow. Reported from the shape
//--- helpers rather than re-derived, so this line always describes what AddConvStage actually built.
if(UsesConvStage())
{
s += " | conv " + IntegerToString(ConvReceptiveFieldBars()) + " bars x" +
IntegerToString(m_neuronsCount) + "->" + IntegerToString(m_convFilterCount) +
" (" + IntegerToString(ConvFirstStagePositions()) + " pos)";
if(HasSecondConvStage())
s += " | pool /" + IntegerToString(m_convFilterCount) +
" | conv2 ->" + IntegerToString(ConvOutputPositions()) + " pos x" +
IntegerToString(m_convFilterCount) + " = " + IntegerToString(ConvOutputWidth());
else
s += " = " + IntegerToString(ConvOutputWidth());
}
if(UsesLstmStage())
s += " | lstm " + IntegerToString(LstmFanIn()) + "->" + IntegerToString(m_lstmHiddenSize);
//--- The dense stack is budgeted against the RAW input, so on any topology with a front-end it can be
//--- WIDER than the vector reaching it - a linear fan-out that cannot recover information the
//--- bottleneck already discarded, only add parameters. Flag it rather than silently reshaping a
//--- trained topology; see ComputeFirstLayerWidth.
int frontEndOut = UsesLstmStage() ? m_lstmHiddenSize
: (UsesConvStage() ? ConvOutputWidth() : 0);
if(frontEndOut > 0 && m_initialNeuronsCount > frontEndOut)
s += " | NOTE dense fans out " + IntegerToString(frontEndOut) + "->" +
IntegerToString(m_initialNeuronsCount);
return s;
}
//+------------------------------------------------------------------+
//| Input width the LSTM block actually receives. |
//+------------------------------------------------------------------+
int CExpertSignalAIBase::LstmFanIn(void) const
{
//--- LSTM-only: the layer sits directly on the input, so it sees the whole flattened vector.
//--- HYBRID: AddConvStage runs first, so the LSTM sees the CONV FEATURE MAP, not the input.
if(HasConvBeforeLstm())
return ConvOutputWidth();
return (int)m_historyBars * m_neuronsCount;
}
//+------------------------------------------------------------------+
//| LSTM recurrent hidden width - see the declaration comment. |
//+------------------------------------------------------------------+
int CExpertSignalAIBase::ComputeLstmHiddenSize(void) const
{
//--- The LSTM block's parameter count is EXACTLY 4 * H * (H + inputs + 1) - see
//--- CNeuronLSTMOCL::SetInputs in AI\Network.mqh - and AddLstmStage feeds it the whole flattened
//--- input vector, so `inputs` is historyBars x neuronsCount.
int inputs = (LSTM_SEQUENCE_MODE ? (HasConvBeforeLstm() ? m_convFilterCount : m_neuronsCount)
: LstmFanIn());
double isBars = EstimatedInSampleBars();
if(inputs <= 0 || isBars <= 0.0)
return LSTM_HIDDEN_MIN;
double b = (double)(inputs + 1);
double budget = (-b + MathSqrt(b * b + 4.0 * (isBars / 4.0))) / 2.0;
int ladder[] = {8, 16, 32, 64, 128};
int snapped = LSTM_HIDDEN_MIN;
for(int i = 0; i < ArraySize(ladder); i++)
if((double)ladder[i] <= budget)
snapped = ladder[i];
return (int)MathMax(LSTM_HIDDEN_MIN, MathMin(LSTM_HIDDEN_MAX, snapped));
}
//+------------------------------------------------------------------+
//| Capacity budget for the first dense layer - see the declaration. |
//+------------------------------------------------------------------+
int CExpertSignalAIBase::ComputeFirstLayerWidth(void) const
{
//--- THE WIDTH THAT ACTUALLY REACHES THE DENSE STACK, not the raw input vector. Until 2026-08-09
//--- this budgeted against m_historyBars * m_neuronsCount on every topology, which is only the
//--- truth for a plain MLP.
int frontEndOut = UsesLstmStage() ? m_lstmHiddenSize
: (UsesConvStage() ? ConvOutputWidth() : 0);
//--- Same expression, one owner (see FirstLayerFanIn): the report in ReportDetectability has to
//--- charge for exactly what this decision charged for, or the two describe different networks.
int inputWidth = FirstLayerFanIn();
if(inputWidth <= 0)
return FIRST_LAYER_MIN_WIDTH;
double isBars = EstimatedInSampleBars();
//--- One first-layer weight per in-sample bar. That layer is (inputWidth+1) x width and dominates the
//--- model, so this is effectively a whole-model capacity budget. One parameter per sample is already
//--- generous for a signal this weak; it is a ceiling, not a target.
int budget = (int)(isBars / (double)(inputWidth + 1));
//--- Snap DOWN to the ladder: the estimate above is approximate, and a value that moves with every
//--- small change would re-key the weights file for no benefit. Rungs are far enough apart that the
//--- estimate would have to be wrong by ~2x to land on a different one.
int ladder[] = {16, 32, 64, 128, 256, 512, 1024};
int chosen = FIRST_LAYER_MIN_WIDTH;
for(int i = 0; i < ArraySize(ladder); i++)
if(ladder[i] <= budget)
chosen = ladder[i];
//--- NEVER WIDER THAN THE STAGE FEEDING IT. FrontEndConfigSummary() already calls that shape out
//--- as a defect when it happens; this stops it happening. The taper below this layer then
//--- funnels as intended.
if(frontEndOut > 0)
chosen = (int)MathMin(chosen, frontEndOut);
//--- Budget below the floor means this configuration cannot support even the narrowest usable
//--- layer - the model will be over-parameterized no matter what is chosen here, and no amount
//--- of regularization fixes having more weights than examples.
double lifespan = MeanLabelLifespan();
string basis = (lifespan > 1.0001)
? StringFormat("%.0f independent in-sample observations (%.0f bars / mean label"
" lifespan %.1f)", isBars, EstimatedInSampleBarsRaw(), lifespan)
: StringFormat("%.0f estimated in-sample bars (label overlap NOT YET MEASURED, so"
" this is an UPPER BOUND - see the CAPACITY line once labels exist)",
isBars);
if(budget < FIRST_LAYER_MIN_WIDTH)
Print(ID + ": WARNING - " + basis + " cannot support a " +
IntegerToString(inputWidth) + "-wide " +
(frontEndOut > 0 ? "vector into the dense stack" : "input") + ". The first layer is being floored at " +
IntegerToString(FIRST_LAYER_MIN_WIDTH) + " units, which is still roughly " +
DoubleToString((double)(inputWidth + 1) * FIRST_LAYER_MIN_WIDTH / MathMax(1.0, isBars), 1) +
" weights per independent observation - expect overfitting. Reduce HistoryBars or the" +
" feature set, lengthen the study period, pool instruments, or train on a lower timeframe.");
return MathMax(FIRST_LAYER_MIN_WIDTH, chosen);
}
//+------------------------------------------------------------------+
//| Batch-normalization layer - see the declaration comment. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::AddBatchNormStage(CArrayObj *topology, int units)
{
if(CheckPointer(topology) == POINTER_INVALID)
return false;
//--- Not an error: the input is off, so the topology simply has no normalization layers. Returning
//--- true keeps every call site a plain `if(!Add...) return false;` with no extra branching.
if(!EnableBatchNorm)
return true;
//--- A window of 1 makes the layer a no-op passthrough (mean==x, variance==0), which is a silently
//--- useless layer rather than an obviously absent one. Refuse to build it instead.
if(BatchNormWindow <= 1)
return true;
CLayerDescription *desc = new CLayerDescription();
if(CheckPointer(desc) == POINTER_INVALID)
return false;
desc.count = units;
desc.type = defNeuronBatchNorm;
desc.batch = BatchNormWindow;
//--- Identity forward transform. The non-linearity belongs to the dense layer stacked on top of this
//--- one; normalizing and then squashing in the same step would undo the normalization.
desc.activation = NONE;
desc.optimization = (ENUM_OPTIMIZATION)m_optimizationAlgo;
if(!topology.Add(desc))
{
delete desc;
return false;
}
return true;
}
//+------------------------------------------------------------------+
//| Convolution front-end: conv -> channel pool -> conv. Shared by |
//| CSignalCONV and CSignalHYBRID - see the declaration comment. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::AddConvStage(CArrayObj *topology)
{
if(CheckPointer(topology) == POINTER_INVALID)
return false;
//--- Stage 1: convolution across CONV_RECEPTIVE_FIELD_BARS bars, advancing one bar at a time.
CLayerDescription *desc = new CLayerDescription();
if(CheckPointer(desc) == POINTER_INVALID)
return false;
//--- desc.count here is the conv layer's own output-filter count (CNeuronConvOCL::Init's window_out
//--- param, AI\Network.mqh) - was m_hiddenLayersCount (an unrelated dense-taper-depth setting,
//--- defaulting to 4), bottlenecking every sliding position to just 4 filters regardless of how wide
//--- the rest of the network was. See ConvFilterCount's declaration comment (Variables\Inputs.mqh).
desc.count = m_convFilterCount;
desc.type = defNeuronConv;
// PRELU, not TANH: matches what CNeuronConv's CPU path (Network.mqh) has always hardcoded
// regardless of this setting (its activationFunction() override ignores `activation` entirely) -
// this used to silently diverge from the accelerated (OpenCL/CPU-DLL) tier, which DOES honor this
// field and was therefore actually running tanh instead of the intended PReLU whenever hardware
// accel was active.
desc.activation = PRELU;
desc.optimization = (ENUM_OPTIMIZATION)m_optimizationAlgo;
//--- The whole point: a window spanning several bars. Guarded because m_historyBars can be small
//--- enough that a multi-bar window would not fit at all, in which case this degrades to the old
//--- per-bar projection rather than building a negative-width layer.
desc.window = ConvReceptiveFieldBars() * m_neuronsCount;
desc.step = m_neuronsCount;
if(!topology.Add(desc))
{
delete desc;
return false;
}
//--- NO POOL, and no second conv. It threw away 87.5% of this layer's output and starved every non-
//--- argmax filter of gradient. Springenberg et al. ICLR 2015.
return true;
}
//+------------------------------------------------------------------+
//| Conv chain shape. SINGLE SOURCE OF TRUTH - AddConvStage builds |
//| from these and LstmFanIn/FrontEndConfigSummary report from them, |
//| so what is constructed and what is logged cannot drift apart. |
//+------------------------------------------------------------------+
int CExpertSignalAIBase::ConvReceptiveFieldBars(void) const
{
//--- Degrade to a per-bar projection rather than build an impossible layer when history is too short
//--- for a multi-bar window. MathMin against m_historyBars keeps window <= input width.
int bars = (int)MathMin((int)CONV_RECEPTIVE_FIELD_BARS, (int)m_historyBars);
return (bars > 0 ? bars : 1);
}
//+------------------------------------------------------------------+
int CExpertSignalAIBase::ConvFirstStagePositions(void) const
{
//--- Sliding positions of stage 1: window ConvReceptiveFieldBars() bars, step 1 bar.
int p = (int)m_historyBars - (ConvReceptiveFieldBars() - 1);
return (p > 0 ? p : 1);
}
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::HasSecondConvStage(void) const
{
//--- Permanently false: the conv chain is ONE true convolution. Kept (rather than deleted along with the
//--- pool + second conv it used to gate) so ConvOutputPositions/ConvOutputWidth stay the single source of
//--- truth for the chain's shape and a future strided second stage has one place to switch itself on.
return false;
}
//+------------------------------------------------------------------+
int CExpertSignalAIBase::ConvOutputPositions(void) const
{
int p = ConvFirstStagePositions();
return (HasSecondConvStage() ? p - (ConvReceptiveFieldBars() - 1) : p);
}
//+------------------------------------------------------------------+
int CExpertSignalAIBase::ConvOutputWidth(void) const
{
//--- Total element count reaching whatever is stacked above the conv chain: the conv output is
//--- position-major, window_out filters per position.
return ConvOutputPositions() * m_convFilterCount;
}
//+------------------------------------------------------------------+
//| LSTM sequence stage. Shared by CSignalLSTM and CSignalHYBRID - |
//| see the declaration comment. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::AddLstmStage(CArrayObj *topology)
{
if(CheckPointer(topology) == POINTER_INVALID)
return false;
CLayerDescription *desc = new CLayerDescription();
if(CheckPointer(desc) == POINTER_INVALID)
return false;
desc.count = m_lstmHiddenSize;
desc.type = defNeuronLSTM;
desc.activation = TANH;
//--- CNeuronLSTMOCL now has an accelerated SGD+momentum kernel (LSTM_UpdateWeightsMomentum,
//--- AI\Network.mqh/Network.cl/DirectML\WarriorCPU.cpp) alongside the original
//--- Adam one, so this layer honors the same TrainingOptimizer input as PAI/CONV - see
//--- m_optimizationAlgo's declaration comment.
desc.optimization = (ENUM_OPTIMIZATION)m_optimizationAlgo;
//--- PER-TIMESTEP input width - the feature count for ONE bar as it reaches this layer. Note the
//--- step COUNT is the position count, which the conv chain shrinks below historyBars once a
//--- multi-bar window and a second conv are in play.
desc.window = (LSTM_SEQUENCE_MODE ? (HasConvBeforeLstm() ? m_convFilterCount : m_neuronsCount) : 0);
//--- MathMax(1,...) guard taken from the HYBRID copy: the CSignalLSTM copy divided unguarded, so a
//--- historyBars of 1 produced step 0 there and step 1 here for what is meant to be the same layer.
desc.step = MathMax(1, (int)m_historyBars / 2);
if(!topology.Add(desc))
{
delete desc;
return false;
}
return true;
}
//+------------------------------------------------------------------+
//| Builds a fresh, untrained topology into Net - the exact layer |
//| construction InitNeuralNetwork() used to inline for the |
//| "no saved .nnw" case; factored out so TuneIndicatorsAndTrain() can|
//| get a clean-slate Net per trial without touching indicator init. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::BuildFreshTopology()
{
CArrayObj *Topology = new CArrayObj();
if(CheckPointer(Topology) == POINTER_INVALID)
return false;
//--- Input Layer
CLayerDescription *desc = new CLayerDescription();
if(CheckPointer(desc) == POINTER_INVALID)
{
delete Topology;
return false;
}
//--- NetInputWidth = the bar window plus (meta target only) the per-candidate setup descriptor
//--- appended after it - see AppendCandidateFeatures. Zero-delta for every direction model.
desc.count = NetInputWidth();
desc.type = defNeuron;
desc.activation = NONE;
desc.optimization = (ENUM_OPTIMIZATION)m_optimizationAlgo;
if(!Topology.Add(desc))
{
delete Topology;
return false;
}
//--- neuron-type-specific layers (Conv+Pool, LSTM, or none for a plain perceptron)
if(!AddCustomLayers(Topology))
{
delete Topology;
return false;
}
//--- Hidden Layers, tapering from m_initialNeuronsCount down to m_minNeuronsCount, each preceded
//--- by a batch-normalization layer (no-op when EnableBatchNorm is off). At 64 units, "keep 30%
//--- with a floor of 20" gives 64 -> 20 -> 20 - the reduction stops mattering after one step and
//--- the "minimum" silently becomes the width of every layer but the first.
int lastHidden = MathMax(HIDDEN_TAPER_OUTPUT_MULTIPLE * m_outputNeuronsCount, HIDDEN_TAPER_MIN_WIDTH);
//--- Never wider than where the taper starts: a narrow first layer (see the D1 case in
//--- ComputeFirstLayerWidth) must still funnel DOWN, not fan back out.
lastHidden = MathMin(lastHidden, m_initialNeuronsCount);
double taperRatio = (m_hiddenLayersCount > 1)
? MathPow((double)lastHidden / (double)m_initialNeuronsCount, 1.0 / (double)(m_hiddenLayersCount - 1))
: 1.0;
//--- Width of the layer immediately below the next batch-norm layer. Only advisory (CNet sizes
//--- each batch-norm layer from whatever it actually sits on), but kept honest so the descriptor
//--- list reads correctly.
int prevWidth = (int)(m_historyBars * m_neuronsCount);
bool result = true;
for(int i = 0; (i < m_hiddenLayersCount && result); i++)
{
int n = (i == 0)
? m_initialNeuronsCount
: MathMax(lastHidden, (int)MathRound(m_initialNeuronsCount * MathPow(taperRatio, (double)i)));
result = (AddBatchNormStage(Topology, prevWidth) && result);
if(!result)
break;
prevWidth = n;
desc = new CLayerDescription();
if(CheckPointer(desc) == POINTER_INVALID)
{
delete Topology;
return false;
}
desc.count = n;
desc.type = defNeuron;
desc.activation = HiddenLayerActivation();
desc.optimization = (ENUM_OPTIMIZATION)m_optimizationAlgo;
result = (Topology.Add(desc) && result);
}
if(!result)
{
delete Topology;
return false;
}
//--- Batch norm immediately before the head. This is the one placement that matters most: it is what
//--- keeps the logit spread from decaying as the weights below it shrink, and it is the precondition
//--- for ever running an UNBOUNDED head here (see the 2026-07-28 note on desc.activation below).
if(!AddBatchNormStage(Topology, prevWidth))
{
delete Topology;
return false;
}
//--- Output Layer
desc = new CLayerDescription();
if(CheckPointer(desc) == POINTER_INVALID)
{
delete Topology;
return false;
}
desc.count = m_outputNeuronsCount;
desc.type = defNeuron;
//--- Never write the activation as a literal here: this line only ever reaches a BRAND-NEW
//--- topology, so a change made here never touches an existing .nnw (CNeuronBaseOCL::Save
//--- persists the activation and Load restores it).
desc.activation = OutputLayerActivation();
desc.optimization = (ENUM_OPTIMIZATION)m_optimizationAlgo;
if(!Topology.Add(desc))
{
delete Topology;
return false;
}
if(CheckPointer(Net) != POINTER_INVALID)
delete Net;
Net = new CNet(Topology);
delete Topology;
if(CheckPointer(Net) == POINTER_INVALID)
return false;
//--- A fresh topology invalidates any existing shadow (see m_shadowNet's declaration comment) -
//--- its weights, if any, are shaped for the OLD Net and would either mismatch dimensionally or,
//--- worse, silently blend unrelated weight spaces if the shape happens to coincide.
if(CheckPointer(m_shadowNet) != POINTER_INVALID)
{
delete m_shadowNet;
m_shadowNet = NULL;
}
//--- Let EnsureShadowNet() re-attempt the clone bootstrap once for this new topology (see the latch's
//--- declaration comment) - the old shadow, and any prior failed-bootstrap verdict, no longer apply.
m_shadowBootstrapAttempted = false;
//--- A brand-new untrained net has NO online continual-learning history (see OnlineLearnStep):
//--- reset the watermark/guardrail/counters so a fresh start or a ResetWeights()-then-retrain
//--- never resumes from a superseded model's learned-up-to point or its stale rolling accuracy.
m_onlineLearnedUpToTime = 0;
m_onlineRollingAcc = -1.0;
m_onlineSamples = 0;
m_onlineBarsSincePersist = 0;
m_onlineBlendFrozen = false;
return true;
}
//+------------------------------------------------------------------+
//| Creates the OHLC + ZigZag indicators the feature builder reads. |
//| Called by InitNeuralNetwork(), not by the framework - the public |
//| InitIndicators() override is the framework entry point. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::InitFeatureIndicators(CIndicators *indicators)
{
//--- Reset only the status label on (re-)init; deliberately do NOT PurgeChart() here so previously drawn
//--- signal arrows survive an EA re-init (recompile / param change / timeframe switch) instead of
//--- vanishing every time - see SIG_ARROW_PREFIX. Full cleanup still happens in the destructor.
ClearStatusLabel();
//--- NOTE: LoadChartSignals() is deliberately NOT called here any more. The mismatch made the
//--- restore silently no-op on every restart from the moment the fingerprint was introduced. Same
//--- family as the fingerprint trap documented at BuildConfigFingerprint: anything keyed on
//--- m_fileName must run AFTER it is fully built.
if(!InitOpen(indicators))
return false;
if(!InitClose(indicators))
return false;
if(!InitLow(indicators))
return false;
if(!InitHigh(indicators))
return false;
//--- label source, always created unconditionally, same as the OHLC indicators above - see
//--- m_ADZigZag's declaration comment. Optionally ALSO read as an input feature (m_useSwingContext,
//--- below) using the same already-running indicator instance - no separate init needed for that.
if(!InitADZigZag(indicators))
return false;
m_neuronsCount = 4; // (close-open)/atr, (high-open)/atr, (low-open)/atr, bullish/bearish flag
if(m_useVolumes)
{
// change ratio, level vs 50-bar baseline, absorption (range per unit volume), volume x range -
// see BufferTempDataCompute()'s matching block, and research/test_volume.py for the measurement
// that justified widening this from 1. m_neuronsCount is already in the config fingerprint, so
// this re-keys existing caches on its own: correct, the input vector genuinely changed shape.
m_neuronsCount += 4;
if(!InitVolumes(indicators))
return false;
}
// Unconditional, same reasoning as m_ATR/m_ADZigZag below: m_Time.GetData() is read
// unconditionally elsewhere (label-eligibility gate, cache anchor, online-learning watermark,
// arrow timestamps) regardless of whether the cyclical time-of-day/day-of-week values are also
// opted into as an explicit feature via m_useTime - so the indicator itself must always exist.
if(!InitTime(indicators))
return false;
if(m_useTime)
{
m_neuronsCount += 6;
}
if(m_useATR)
{
//already init in the base class
m_neuronsCount++;
}
if(m_useMA)
{
if(!InitMA(indicators))
return false;
m_neuronsCount += 5; // (open-MA)/atr, (high-MA)/atr, (low-MA)/atr, (close-MA)/atr, (MA-MA[1])/atr
}
if(m_useRSI)
{
if(!InitRSI(indicators))
return false;
m_neuronsCount++; // RSI/100
}
if(m_useMACD)
{
if(!InitMACDFeature(indicators))
return false;
m_neuronsCount += 3; // main/atr, signal/atr, histogram/atr
}
if(m_useIchimoku)
{
if(!InitIchimoku(indicators))
return false;
// (close-Tenkan)/atr, (close-Kijun)/atr, (Tenkan-Kijun)/atr, (close-SpanA)/atr, (close-SpanB)/atr,
// signed cloud thickness at this bar, signed PROJECTED cloud thickness, Chikou displacement
m_neuronsCount += 8;
}
if(m_useSwingContext)
m_neuronsCount += 9; // 5 confirmed-pivot features (direction, distance-since-pivot, prior-leg magnitude, retracement ratio, bars-since-pivot) + 4 recent-context features (Donchian pos 20/50, 20-bar return, 20-bar SMA extension) - see BufferTempDataCompute()'s matching block
if(m_useNews)
m_neuronsCount += 2; // NewsRecency, NewsProximity - see BufferTempDataCompute()'s matching block
if(m_useSpreadFeature)
m_neuronsCount += 2; // spread/ATR (volatility-regime reading), spread change ratio
// - see BufferTempDataCompute()'s matching block
if(m_useCrossAsset)
m_neuronsCount += CROSSASSET_FEATURES; // FX: base/quote strength + divergence; index: denom/risk-proxy strength
// divergence, cross-sectional dispersion - System\CrossAsset.mqh
if(m_useADCumulativeDelta)
{
if(!InitADCumulativeDelta(indicators))
return false;
m_neuronsCount += 6; // Pressure, CumulativeDelta, BullishPressure, BearishPressure, Absorption, Initiative
}
if(m_useADShorteningOfThrust)
{
if(!InitADShorteningOfThrust(indicators))
return false;
m_neuronsCount += 4; // SOT, SOTEffortRegime, SOTConfirmation, SOTPushRegime
}
if(m_useADWyckoffEventStream)
{
if(!InitADWyckoffEventStream(indicators))
return false;
// 16 = 13 buffers - EventPrice (buffer 4, excluded; see BufferTempDataCompute()'s comment) with
// THREE of them split into a direction/magnitude pair each: EventCode, EventPhase and
// StructuralPhase are signed categoricals, so 13 readings now occupy 16 inputs (2026-08-09 audit,
// N1 - see the split in BufferTempDataCompute for why). No reading was added or dropped.
m_neuronsCount += 16; // eventDir, eventStage, livePhaseDir, livePhaseMag, ZoneTop, ZoneBottom, structDir, structMag, CHoCHTrendToRange, CHoCHRangeToTrend, SlopeAccumulationBullish, SlopeAccumulationBearish, SlopeDistributionBullish, SlopeDistributionBearish, Reaccumulation, Redistribution
}
if(m_useADWyckoffFailedStructure)
{
if(!InitADWyckoffFailedStructure(indicators))
return false;
m_neuronsCount += 5; // Value, BullishStructuralFailure, BearishStructuralFailure, FailedAccumulation, FailedDistribution
}
if(m_useADWyckoffSignificantBarInversion)
{
if(!InitADWyckoffSignificantBarInversion(indicators))
return false;
m_neuronsCount += 5; // SignificantBarQuality, BullishSignificantBar, BearishSignificantBar, BullishControlFlip, BearishControlFlip
}
//--- ALT DATA (2026-08-16). Externally collected, publication-stamped features (COT positioning,
//--- VIX complex, macro) exported by research/altdata/export.py into
//--- Common\Files\Warrior_EA\AltData\{SYMBOL}_{TF}.csv - see System\AltData.mqh for the
//--- lookahead/degradation contracts.
if(m_altDataEnabled)
{
string altPin = ReadAltDataPinFromCfg();
if(altPin != "")
m_altData.SetPinnedNames(altPin);
m_altData.Load(m_symbol.Name(), (ENUM_TIMEFRAMES)m_period); // logs its own outcome; absence is normal
m_altDataNamesPinned = (altPin != "") ? altPin : m_altData.NamesCsv(); // fresh model: stamped by the first .cfg save
m_useAltData = (m_altData.FeatureCount() > 0);
if(m_useAltData)
m_neuronsCount += m_altData.FeatureCount();
}
else
{
//--- Operator opt-out (EnableAltData=false): zero width, nothing pinned. On a model trained
//--- WITH alt features this shrinks neuronsCount, mismatches the .cfg compare and correctly
//--- starts fresh - stated in the input's comment rather than silently absorbed.
m_useAltData = false;
m_altDataNamesPinned = "";
}
if(!FolderCreate(m_folderPath, FILE_COMMON))
{
if(GetLastError() != 5010) // If the error is not because the folder already exists
{
Print("Failed to create folder: " + m_folderPath);
}
else
{
ResetLastError(); // Reset the error code
}
}
return true;
}
#endif