forked from animatedread/Warrior_EA
A resumed META model hot-looped pass 1 (0->100% scan oscillation, silent for
3 minutes until the stall reporter fired) because EVERY window failed at the
first AD/Wyckoff feature: the init-time param adoption called
ReInitADIndicators unconditionally, destroying five freshly-calculating
indicator instances to recreate them with BYTE-IDENTICAL params (verified by
parsing the .nnw header - the MI tuner had kept the configured settings), at
process start, on a box with 1 GB free of 31. The replacements sat cold for
6+ minutes while full-history resweeps starved the indicator threads harder.
- AdoptIndicatorParams: installs a loaded param set into the tuner and
rebuilds handles ONLY when the set actually differs from what the live
indicators run. Both call sites (resume init + panel reload) use it.
- Resumed models get the same 3 warm-up passes as fresh ones. The skip was
the shared root cause of the cold-ATR (ba13eef), cold-AD (2026-08-11) and
this incident - custom indicators recompute from scratch every process
start regardless of what the .nnw proves.
- Cold-sweep backoff: a pass-1 sweep in which every window failed on a
TRANSIENT cause arms a 5s era-start pause instead of an immediate
full-history resweep, so the retry loop stops consuming the CPU/memory the
warming indicators need. The stall reporter names the backoff branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1516 lines
103 KiB
MQL5
1516 lines
103 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. |
|
|
//| |
|
|
//| PARTIAL IMPLEMENTATION FILE - not standalone. |
|
|
//| 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. |
|
|
//+------------------------------------------------------------------+
|
|
#ifndef WARRIOR_AIBASE_TOPOLOGY_MQH
|
|
#define WARRIOR_AIBASE_TOPOLOGY_MQH
|
|
//+------------------------------------------------------------------+
|
|
//| 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(!CExpertSignalAIBase::InitIndicators(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. HERE and only here: it must be settled before the
|
|
//--- fingerprint below (which hashes it) and must never move afterwards - see ComputeFirstLayerWidth()
|
|
//--- and the note on fingerprint-feeding members at the top of this file. 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. The conv and LSTM stages settle FIRST, because
|
|
//--- ComputeFirstLayerWidth() now budgets the dense stack against the width that actually reaches it
|
|
//--- - which, on any topology with a front end, is that stage's output and not the raw input vector.
|
|
//--- Neither of these two depends on m_initialNeuronsCount, so moving them ahead of it is safe;
|
|
//--- depth still comes last because it is derived FROM the first-layer width.
|
|
//--- Both are also sized from the data rather than configured. Unconditional - a plain MLP simply
|
|
//--- never builds the stages these describe, and branching on the topology type would make the
|
|
//--- .cfg contents depend on which subclass is asking.
|
|
//--- The input WINDOW settles first of all: every shape below multiplies by it. Derived for a
|
|
//--- genuinely new model; an existing model ADOPTS the window it was trained with from its .cfg
|
|
//--- further down, exactly like the four shape fields (the .nnw remains the ultimate authority).
|
|
//--- Deliberately after InitIndicators (which needs none of it) and before the fingerprint, whose
|
|
//--- window slot is a LEGACY literal precisely so this measurement cannot re-key a filename.
|
|
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.
|
|
//--- Depth is DERIVED now (see ComputeHiddenLayerCount), so it names nothing anyone chose - it is an
|
|
//--- internal shape detail leaking into a product surface a customer reads. Dropped: the config tag
|
|
//--- appended below ([PAI-0be2]) already disambiguates concurrent charts, and does it correctly for
|
|
//--- every input rather than just this one. The full topology is still logged once at startup by the
|
|
//--- "config -" line, which is where that detail belongs.
|
|
//--- 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. This is what lets a
|
|
//--- genetic/complete optimization that sweeps network params (neuron counts, layers, reduction,
|
|
//--- history bars, study period, feature set, focal gamma, OOS split, recall/WR targets, ...) build
|
|
//--- each combo's model exactly ONCE and then reuse it on every later pass that revisits that combo -
|
|
//--- previously each differing pass overwrote the single shared cache and retrained from scratch, so
|
|
//--- there was no cross-combination reuse at all. The topology .cfg check further below still runs as
|
|
//--- a secondary guard (and catches a rare hash collision by mismatching and retraining).
|
|
//--- Deliberately covers ONLY params that change the trained weights. Inference-only gates
|
|
//--- (Min_Vote_Open's confidence floor, SignalClusterWindow) and post-training/live settings (money
|
|
//--- management, trailing, entry, filters) are excluded, so changing those still reuses the exact
|
|
//--- same model - matching the pre-existing behavior the optimizer already relied on.
|
|
//--- 2026-08-01: SL/TP LEFT that exempt list. They used to be pure execution settings; the
|
|
//--- triple-barrier relabel makes them the barriers the TARGET is defined by (see TripleBarrierLabel),
|
|
//--- so changing either now changes every label and therefore every weight. A model trained at
|
|
//--- 1:3 must never be silently reused at 1:1. This is the rule from the fingerprint audit applied
|
|
//--- to the newest weight-affecting inputs: what shapes the labels shapes the hash.
|
|
//--- 2026-07-30: every DERIVED value left this hash - first-layer width, dense depth, conv filters,
|
|
//--- LSTM hidden size, and the retired reduction/minNeurons pair. They were legitimately here while
|
|
//--- they were functions of hashed INPUTS, which made them redundant-but-harmless. They stopped being
|
|
//--- that when the capacity budget started measuring the symbol's real bar count: a filename keyed on
|
|
//--- a measured quantity changes the moment more history downloads, so the EA would look for a file
|
|
//--- that does not exist, start from era 0, and orphan a fully-trained model - silently, since a
|
|
//--- missing cache is the normal first-run state and logs as such. The derived shape is pinned in the
|
|
//--- .cfg instead (see the adopt-don't-compare block in LoadAndCompareTopologyConfiguration), which is
|
|
//--- the correct home for it: it describes the model that EXISTS, not the config that asked for it.
|
|
//--- m_studyPeriod is gone for a simpler reason - the input it mirrored no longer exists.
|
|
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. Conditional, so the shipping value of 0 leaves the hash
|
|
//--- exactly as it reads above.
|
|
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. It is the same rule that moved the horizon out
|
|
//--- (and the derived topology values before it, see above): a filename keyed on a measured quantity
|
|
//--- changes the moment the measurement does - more history downloads, 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 fully-trained model silently. Measured values are PINNED IN THE .cfg instead, which is
|
|
//--- read back and adopted on load, so a trained model keeps the geometry it was actually trained on.
|
|
//--- Nothing replaces it here on purpose: the .cfg is the record, and re-measuring never happens for a
|
|
//--- model that already exists.
|
|
//--- MA/RSI + AD feature flags appended separately to keep each StringFormat call's arg list modest.
|
|
//--- m_useMA/m_useRSI belong here for the same reason every other feature flag does: they change the
|
|
//--- input-vector width (see InitIndicators()'s m_neuronsCount += 5/+1), so a model trained with them
|
|
//--- on must never silently reuse a cache trained with them off. m_neuronsCount alone (listed above)
|
|
//--- captured the WIDTH but not the composition, so two different feature sets summing to the same
|
|
//--- width could have collided onto one cache file - these two flags close that gap.
|
|
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. Both spellings are equally correct as a fingerprint (deterministic either way, and a
|
|
//--- model with these on can never collide with one that has them off), but appending them
|
|
//--- unconditionally would have changed the hash of EVERY existing config the moment this feature
|
|
//--- shipped - re-keying and forcing a full retrain of already-converged models that don't use MACD or
|
|
//--- Ichimoku at all. Conditional append leaves those fingerprints byte-identical. The seed periods go
|
|
//--- in for the same reason MA_Type does above: they change the feature's values. 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 panel's actual composition - which reference pairs
|
|
//--- were found in Market Watch, and therefore which currencies it can index - is a MEASURED property
|
|
//--- of the terminal, exactly like the bar count the header warns about. Keying the filename on it
|
|
//--- would orphan a fully-trained model the moment the user adds or removes a Market Watch symbol,
|
|
//--- silently, since a missing cache reads as a normal first run. 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). When the traded symbol reports the same currency on
|
|
//--- both sides (SP500 -> USD/USD) the panel's slots changed meaning: denomination + risk-proxy
|
|
//--- strength and a denominator-adjusted divergence, instead of duplicated base/quote series
|
|
//--- (see System\CrossAsset.mqh header). Same width, different SEMANTICS - so models trained
|
|
//--- under the old degenerate encoding must re-key. Appended only for base==quote symbols:
|
|
//--- FX-pair semantics are untouched and their models keep their filenames, per the
|
|
//--- conditional-append rule above. Base/quote is a SYMBOL property, not a measured one, so it
|
|
//--- is fingerprint-safe - it cannot change under a trained model the way Market Watch can.
|
|
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";
|
|
//--- 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. Appended conditionally, following
|
|
//--- the same rule as MACD/Ichimoku above: a config with batch norm off keeps the fingerprint it
|
|
//--- already had, so shipping this does not re-key and force a retrain of every existing model.
|
|
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. Conditional append, same rule as MACD/Ichimoku/BN above: a config with
|
|
//--- this OFF keeps the fingerprint it already had, so the already-converged models on
|
|
//--- disk stay untouched and remain loadable as the fallback if this regresses.
|
|
if(m_logitAdjustTau > 0.0)
|
|
fp += StringFormat("|LA:%d", (int)MathRound(m_logitAdjustTau * 100.0));
|
|
//--- 2026-07-29 audit of every input in Variables\Inputs.mqh against this hash. Five were changing the
|
|
//--- trained weights without changing the filename, so switching any of them re-adopted a model trained
|
|
//--- under the OLD value - the exact trap that the .nnw architecture incident already cost a day to
|
|
//--- (see EnforceTopologyContract): the .cfg guard would eventually mismatch and retrain, but only
|
|
//--- after a confusing failure, and a matching topology would not mismatch at all.
|
|
//--- LEGACY SLOT. The oversampling/replay inputs this encoded were removed 2026-07-31 (see the
|
|
//--- class-imbalance block in Variables\Inputs.mqh); the replay path itself is gone. The literal is
|
|
//--- the exact string the shipped defaults produced - EnableMinorityReplay=true, OversampleParity=90,
|
|
//--- ConstrainReplay=true - so every model already on disk keeps its filename and stays loadable.
|
|
//--- Dropping the segment instead would re-key EVERY model and force a from-scratch retrain of the
|
|
//--- one topology currently converged and trading, which is a steep price for cosmetics in a hash
|
|
//--- nobody reads. 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. Tick vs real volume feeds different numbers into the
|
|
//--- same input slot (Features.mqh's m_Volumes.Create), and PeriodMA/PeriodRSI seed the tuner
|
|
//--- (ADIndicatorTuner.mqh) exactly as MA_Type does - MA_Type was already hashed, these two were not.
|
|
//--- 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. These SEED feature values (see CADIndicatorTuner's constructor), so by the
|
|
//--- rule this hash exists to enforce they belong in it - a model trained at one climactic-volume
|
|
//--- threshold must never silently re-adopt weights fitted at another. 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.
|
|
//--- APPENDED ONLY ON DEVIATION, following the MACD/Ichimoku/BN/XA convention. All 18 inputs ship at
|
|
//--- byte-identical values to the literals they replaced, so at defaults this token is absent and every
|
|
//--- model already on disk keeps its filename and stays loadable. Without that guard, merely EXPOSING
|
|
//--- these parameters would have re-keyed every config and forced a from-scratch retrain of all four
|
|
//--- topologies - for a change that alters no number anywhere.
|
|
//--- All-or-nothing rather than per-input: one deviation writes the whole vector, so the token is
|
|
//--- either absent or complete and can never encode a partial picture of what the features were built
|
|
//--- from. Gated on the AD features actually being ON, since with them off none of this reaches a
|
|
//--- feature at all (m_useMA/m_useRSI already carry the two shared thresholds' other consumers).
|
|
bool anyAD = (m_useADCumulativeDelta || m_useADShorteningOfThrust || m_useADWyckoffEventStream ||
|
|
m_useADWyckoffFailedStructure || m_useADWyckoffSignificantBarInversion);
|
|
if(anyAD)
|
|
{
|
|
bool adDefaults = (Wyk_VolClimaxMult == WYK_VOL_CLIMAX_DEF &&
|
|
Wyk_VolHighMult == WYK_VOL_HIGH_DEF &&
|
|
Wyk_RangeClimaxMult == WYK_RANGE_CLIMAX_DEF &&
|
|
Wyk_RangeSignificantMult == WYK_RANGE_SIGNIF_DEF &&
|
|
Wyk_ShortTermVolRatio == WYK_ST_VOL_RATIO_DEF &&
|
|
Wyk_AtrMult == WYK_ATR_MULT_DEF &&
|
|
ADCD_Lookback == ADCD_LOOKBACK_DEF &&
|
|
SOT_ThrustLookback == SOT_THRUST_LOOKBACK_DEF &&
|
|
SOT_MinImpulses == SOT_MIN_IMPULSES_DEF &&
|
|
SOT_Threshold == SOT_THRESHOLD_DEF &&
|
|
WES_Lookback == WES_LOOKBACK_DEF &&
|
|
WES_ZigZag == WES_ZIGZAG_DEF &&
|
|
WES_TouchATR == WES_TOUCH_ATR_DEF &&
|
|
WES_ARMinATR == WES_AR_MIN_ATR_DEF &&
|
|
WES_MaxRangeBars == WES_MAX_RANGE_BARS_DEF &&
|
|
WFS_Lookback == WFS_LOOKBACK_DEF &&
|
|
WFS_ZigZagStrength == WFS_ZIGZAG_STRENGTH_DEF &&
|
|
WSBI_Lookback == WSBI_LOOKBACK_DEF);
|
|
if(!adDefaults)
|
|
fp += StringFormat("|ADP:%.4f:%.4f:%.4f:%.4f:%.4f:%.4f:%d:%d:%d:%.4f:%d:%d:%.4f:%.4f:%d:%d:%d:%d",
|
|
Wyk_VolClimaxMult, Wyk_VolHighMult, Wyk_RangeClimaxMult,
|
|
Wyk_RangeSignificantMult, Wyk_ShortTermVolRatio, Wyk_AtrMult,
|
|
(int)ADCD_Lookback, (int)SOT_ThrustLookback, (int)SOT_MinImpulses,
|
|
SOT_Threshold, (int)WES_Lookback, (int)WES_ZigZag,
|
|
WES_TouchATR, WES_ARMinATR, (int)WES_MaxRangeBars,
|
|
(int)WFS_Lookback, (int)WFS_ZigZagStrength, (int)WSBI_Lookback);
|
|
}
|
|
//--- INPUT WINDOW ORDER. Appended UNCONDITIONALLY, which is a deliberate break from the "conditional
|
|
//--- append so existing fingerprints stay byte-identical" rule every block above follows - and the
|
|
//--- reason is exactly why that rule exists in the first place. Every model on disk was trained on a
|
|
//--- window fed NEWEST-BAR-FIRST; BuildFeatureWindow() now feeds it oldest-first (see its definition
|
|
//--- comment for the LSTM measurement that forced it). The vector has the same SHAPE and the same
|
|
//--- features, so nothing downstream would fail: a stale .nnw would load cleanly, pass the .cfg guard,
|
|
//--- and run a model fitted to one input ordering against the other - silently, forever. That is the
|
|
//--- precise failure this hash exists to make impossible, so here re-keying every config is the
|
|
//--- CORRECT outcome, not collateral damage. Version it rather than toggling a flag: if the ordering
|
|
//--- is ever revisited, bump the number instead of trying to reconstruct which models predate what.
|
|
fp += "|WIN:2";
|
|
//--- WYCKOFF CATEGORICAL ENCODING VERSION. Unconditional and versioned for exactly the reason WIN is
|
|
//--- (see above): the 2026-08-09 N1 split re-encodes EventCode/EventPhase/StructuralPhase as
|
|
//--- direction+magnitude pairs, so those input slots now MEAN something different. The vector width
|
|
//--- also changes (13 -> 16 readings), which m_neuronsCount above would catch on its own - but width
|
|
//--- alone is the weaker guard, and a future re-encoding that preserved width would slip past it and
|
|
//--- run a model fitted to one encoding against another, silently and forever. Bump the number
|
|
//--- rather than adding a flag if the encoding is ever revisited.
|
|
//--- Gated on the feature actually being on, so a config without the Wyckoff events keeps its
|
|
//--- existing fingerprint and its trained model.
|
|
if(m_useADWyckoffEventStream)
|
|
fp += "|WES:2";
|
|
//--- TRAINING TARGET (meta-labeling). Conditional, so every existing direction model keeps its
|
|
//--- byte-identical fingerprint. Versioned like WIN/WES: the token covers the meta LABEL and the
|
|
//--- setup-descriptor layout (META_DESC_FEATURES) - bump the number if either ever changes, so a
|
|
//--- model trained on one meaning can never silently load under another. The 2-output head and the
|
|
//--- State\META\ folder already separate the FILES; this separates the SEMANTICS.
|
|
if(IsMetaTarget())
|
|
fp += "|TGT:META1";
|
|
//--- 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. The dense-depth tag
|
|
//--- added at the top of this function separates MLP_3L from MLP_4L, but NOT two charts that differ by
|
|
//--- anything else - the batch-norm control was 3L on both sides, which put two identical
|
|
//--- "Perceptron 3L" streams in the log the first time this was tried. Any config difference at all
|
|
//--- changes the hash, by construction, so it is the discriminator that cannot go stale as inputs are
|
|
//--- added - but it is NOT unique on its own. The fingerprint deliberately omits the topology TYPE,
|
|
//--- because the file path already separates it (State\CONV\ vs State\LSTM\ vs State\HYB\) and hashing
|
|
//--- a value that is constant within a folder would add no discriminating power while re-keying every
|
|
//--- trained model on disk into a forced retrain. The consequence is that CONV, LSTM and HYBRID at the
|
|
//--- same depth with the same inputs hash IDENTICALLY - a 2026-07-30 deploy came back with three charts
|
|
//--- all tagged [4109]. Their files were never at risk; the TAG was simply unable to do its one job.
|
|
//--- Prefixing m_id restores uniqueness on the display side without touching m_fileName, and the hex
|
|
//--- half still greps straight to the .nnw inside the folder the prefix names.
|
|
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. Printing the raw
|
|
//--- fingerprint string makes the six lines directly diffable - any accidental divergence in study
|
|
//--- period, feature set, focal gamma or anything else that feeds training shows up as a textual
|
|
//--- difference at startup instead of an unexplained result three hours later.
|
|
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. The real figure is
|
|
//--- logged once per run by ApplyLogitAdjustment().
|
|
//--- The ONE class-imbalance mechanism. There is deliberately no "| replay ON/OFF" beside it any
|
|
//--- more: that field reported a path which had already been dead for the whole shipped
|
|
//--- configuration, which is exactly the kind of line that makes a log look informative while
|
|
//--- describing nothing (see the class-imbalance audit in Variables\Inputs.mqh).
|
|
" | 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. Shows the width flowing INTO each stage as well as out of it, because the
|
|
//--- interesting failure is a stage that expands rather than compresses.
|
|
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. The model file goes on the line below rather than
|
|
//--- here for the same reason: it necessarily differs per topology (it carries the State\<id>\ folder),
|
|
//--- so folding it in would make every fingerprint line differ and destroy the diff.
|
|
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). Deliberately placed here: this is the
|
|
//--- first moment the resolved filename - i.e. the config's true identity - is known, and it is still
|
|
//--- ahead of every load, seed and save. Tester/optimizer agents are exempt: each is a separate
|
|
//--- process writing its own sandboxed _optcache, and running one config across many agents in
|
|
//--- parallel is the entire point of an optimization.
|
|
#ifdef WARRIOR_EXPORT_FEATURES
|
|
//--- RESEARCH BUILD: no lock. This binary reads history and writes one CSV - it never trains, never saves
|
|
//--- a model (OnTick returns immediately, so no era ever completes) and therefore has nothing to protect
|
|
//--- against a concurrent chart. Claiming the lock would only make the exporter REFUSE to start whenever
|
|
//--- the config it wants to read is already open on a production chart, which is exactly when it is most
|
|
//--- useful to run.
|
|
#else
|
|
if(!inTesterOrOpt && !AcquireConfigLock())
|
|
return false;
|
|
#endif
|
|
//--- Any Strategy-Tester run - a single backtest OR an optimization pass - runs pure inference on the
|
|
//--- deployed model, never trains. A user optimizing TRADING parameters (SL/TP, filters, MM, ...) wants
|
|
//--- the AI held fixed at the deployed weights so passes are fast and comparable; retraining the net
|
|
//--- per config would be slow and make every pass a different model. AI hyperparameters are tuned on a
|
|
//--- chart (the internal auto-tuner / a real training run), not via MT5 optimization. Training and the
|
|
//--- new online continual-learning step therefore run ONLY on a live chart (see OnlineLearnStep).
|
|
m_inferenceOnly = MQLInfoInteger(MQL_TESTER);
|
|
//--- Seed the agent-local optcache from the deployed production model on the first tester/opt pass.
|
|
//--- Without this, the tester's separate _optcache file starts empty and the run retrains from zero -
|
|
//--- so a buyer who loads a .set and hits "backtest" waits through a full training run instead of a
|
|
//--- backtest of the model they deployed. The optcache shares the production model's exact config
|
|
//--- fingerprint (same m_fileName base), so the copied weights are guaranteed topology-compatible.
|
|
//--- 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.
|
|
//--- Re-seeds when the cache is MISSING *or* STALE. Staleness matters because a tester run no longer
|
|
//--- writes this file at all (see PersistWeightsOnShutdown's inference-only skip), so without a
|
|
//--- freshness check the very first seeded copy would be reused forever - meaning the obvious workflow
|
|
//--- "retrain/redeploy on the chart, then backtest" would silently keep testing the OLD model. The
|
|
//--- config fingerprint in the filename can't catch this: retraining changes the WEIGHTS, not the
|
|
//--- topology inputs the fingerprint hashes, so the name stays identical.
|
|
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.
|
|
//--- Its return value used to be ignored entirely, so a failed copy still logged "seeded tester
|
|
//--- cache..." as if it had worked, and the run silently trained from scratch instead.
|
|
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.
|
|
//--- Still share-aware (CopySharedFile, not FileCopy): the live chart holds these open too, so
|
|
//--- plain FileCopy would fail on them for exactly the same reason it failed on the .nnw.
|
|
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. Spelling out the filename makes
|
|
//--- that mismatch obvious instead of looking like the deploy silently failed.
|
|
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). The stale .cfg
|
|
// was already deleted on a mismatch, but the .nnw weights themselves are shaped for the OLD
|
|
// topology - loading them into a network built to the NEW shape would corrupt state or crash.
|
|
// Drop the incompatible weights/checkpoint too so the Net.Load() below cleanly misses and
|
|
// BuildFreshTopology() takes over (i.e. this pass pays the training cost once, and the result
|
|
// gets cached below for the NEXT pass to reuse, same as a live topology change would).
|
|
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. The sibling "no .cfg yet" case does not reach here (there are no
|
|
//--- weights to delete), so it is handled separately at the fresh-topology branch below.
|
|
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);
|
|
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. Deliberately
|
|
//--- routed through netLoaded rather than a separate branch: that path already cools the calibration,
|
|
//--- resets the era/trainingComplete state and re-arms the label-cache prebuild, all of which a genuine
|
|
//--- architecture change needs too.
|
|
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`).
|
|
//--- The old rationale said a restart "already has a proven-synced history" - but the custom
|
|
//--- indicators recompute from scratch every PROCESS start regardless of what the .nnw proves,
|
|
//--- and skipping the warm-up on resume is the exact root cause that has now bitten three times:
|
|
//--- the cold ATR cached as permanent (ba13eef), the cold AD block training on zeros (the
|
|
//--- 2026-08-11 guard), and the 2026-08-13 resumed-META stall (features read milliseconds after
|
|
//--- OnInit while five AD indicators were still calculating 54k bars on a memory-starved box,
|
|
//--- pass 1 hot-looping 0->100% for 6+ minutes). 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. Previously this was set to netLoaded, which on a successful checkpoint
|
|
//--- load skipped the eager StartLabelCachePrebuild()/AdvanceLabelCachePrebuild() scan (Train()'s
|
|
//--- !m_labelCachePrebuilt gate) - every bar then fell through to the lazy per-bar fallback in the
|
|
//--- era loop, which calls ComputeLabelForBar(), a dead stub that unconditionally returns
|
|
//--- buy=false/sell=false (the real labeling logic lives ONLY in
|
|
//--- AdvanceBarrierLabelState(), reachable exclusively from the eager prebuild). The result: every
|
|
//--- restart that loaded a checkpoint silently force-labeled the entire era Neutral until something
|
|
//--- else (a topology mismatch, a fresh start) triggered a real prebuild. Always eager-prebuilding
|
|
//--- now, checkpoint or not, closes this at the root - the dead stub fallback then never matters.
|
|
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 (NOT a bare ReInitADIndicators): when the saved params equal the values the
|
|
// indicators were JUST created with - the common case, since the MI tuner usually keeps the
|
|
// configured settings - re-creating is a pure destroy/rebuild churn paid at the worst possible
|
|
// moment (milliseconds after process start, history still syncing). Observed 2026-08-13 on a
|
|
// resumed META model: the churn threw away five freshly-calculating indicator instances and the
|
|
// replacements sat cold for 6+ minutes on a memory-starved box, stalling training entirely.
|
|
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. CNet::Load now prints the precise reason (bad marker / type mismatch / 0-layer stub /
|
|
//--- partial layer load) itself. Only clear the stale code here; the "rebuilding fresh" line below is
|
|
//--- the user-facing summary.
|
|
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). Left as-is, the freshly built,
|
|
//--- untrained topology below would be treated as an already-deployed converged model: it would
|
|
//--- never train, run inference on random weights (every bar scores Neutral, so the end-of-era NMS
|
|
//--- sweep deletes every chart arrow), and "save weights" would just re-persist that empty net.
|
|
//--- Force the state back to a genuine fresh start so BuildFreshTopology() actually gets trained.
|
|
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). In-memory ONLY - deliberately does NOT touch any
|
|
//--- file. (Earlier this session I also deleted the .stats/_shadow.nnw sidecars here; that was too
|
|
//--- destructive - a load failure can be transient/spurious (a not-yet-ready compute backend, a
|
|
//--- momentary file lock, or - on this CPU-DLL machine - GetLastError() being polluted with the
|
|
//--- harmless OpenCL-not-found 5100 from the probe inside CNet::Load), and wiping a user's calibration
|
|
//--- and deployed shadow on any such hiccup is the wrong default. The sidecars self-heal anyway: the
|
|
//--- shadow re-blends toward the retrained Net and .stats is overwritten on the next save.)
|
|
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. A prior
|
|
//--- genetic tuner sweep (TuneIndicatorsAndTrain's candidate eval loop, line ~5006) left the
|
|
//--- MQL5 RNG to a fixed seed; if this load-fail path then builds a production
|
|
//--- topology without re-seeding, the deployed model's weights would be deterministic/repeatable
|
|
//--- from whatever the last candidate's seed was — silently reproducible, not genuinely random.
|
|
//--- Matches ResetWeights() and Warrior_EA.mq5's OnInit.
|
|
MathSrand(GetTickCount());
|
|
//--- 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. Neither the panel reset
|
|
//--- nor the topology-mismatch discard above covers this path: both are gated on there being a
|
|
//--- saved .nnw to delete, and here there is none (a changed config produces a new m_fileName,
|
|
//--- so the old model's files are not "discarded", they are simply not this model's files).
|
|
//--- Left alone the stale arrows do NOT just look wrong - the chart objects survive deploys and
|
|
//--- restarts on their own, and SaveChartSignals() rebuilds the sidecar by scanning the chart,
|
|
//--- so the first save of this fresh run would adopt the dead model's calls as its own history.
|
|
//--- 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)
|
|
bEventStudy = EventChartCustom(ChartID(), 1, (long)MathMax(0, MathMin(iTime(_Symbol, PERIOD_CURRENT, (int)(100 * Net.recentAverageSmoothingFactor * (m_trainingComplete ? 1 : 10))), dtStudied)), 0, "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;
|
|
#ifdef WARRIOR_EXPORT_FEATURES
|
|
//--- Research build only. 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.
|
|
ExportFeatureMatrix();
|
|
#endif
|
|
return true;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Shared training-set size estimate - see the declaration comment. |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalAIBase::EstimatedInSampleBars(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. There used to be a hard rule against reading Bars() here, and it was
|
|
//--- right for what it guarded: what is downloaded grows over a terminal's lifetime, and a topology
|
|
//--- that silently widens as history fills in would re-key its own weights file and throw away a
|
|
//--- trained model. That hazard is now closed at the other end instead - the derived shape is written
|
|
//--- into the .cfg on first build and ADOPTED, not re-derived, on every subsequent load, and none of
|
|
//--- the derived values feed the weights-filename fingerprint any more. So this is measured once per
|
|
//--- model, at the moment the model is created, and never consulted again for an existing one.
|
|
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. Fall back to a conservative fixed span rather than
|
|
//--- pinning something absurd, and say so, because the fix (reattach once history has synced) is the
|
|
//--- user's to make and is invisible otherwise.
|
|
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;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| 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). Alternation is enforced by keeping the more
|
|
//--- extreme of two same-type candidates - a higher high before any low confirms extends the leg,
|
|
//--- it does not end one. Legs are the bar distances between consecutive ALTERNATING pivots: the
|
|
//--- same "confirmed leg" population the barrier horizon medians, measured here from raw price
|
|
//--- because at init the ZigZag indicator has no data yet.
|
|
int 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;
|
|
}
|
|
ArraySort(legs);
|
|
int median = legs[ArraySize(legs) / 2];
|
|
//--- 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 %d 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.
|
|
//--- Picking depth independently of those endpoints is what made it meaningless as an input: at 64
|
|
//--- units tapering to 12, four layers compress by 1.4x per step and five by barely 1.3x, so the extra
|
|
//--- depth buys no additional abstraction and costs a vanishing-gradient risk for nothing.
|
|
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. The meaningful reference point is therefore the
|
|
//--- WINDOW width, not one bar's feature count - budgeting against a single bar was correct only while
|
|
//--- the receptive field was 1, and at RF 3 it under-sized the stage 3x (8 filters for a 63-input
|
|
//--- window, an 8x squeeze, where the rule intends 2x). Same rule as before, applied to what the layer
|
|
//--- actually reads: halve the input. Keeping it tied to the window also means the receptive field and
|
|
//--- the filter count can never drift apart the way they did on 2026-07-31.
|
|
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. That map
|
|
//--- is position-major - ConvOutputPositions() positions of m_convFilterCount filters each - so the
|
|
//--- LSTM's per-timestep width stays m_convFilterCount (AddLstmStage) and its step count is the
|
|
//--- POSITION count, which the conv chain shrinks below m_historyBars once a multi-bar window and a
|
|
//--- second conv are in play. Hardcoding historyBars here would over-state the fan-in and, worse,
|
|
//--- disagree with the width CNet actually hands the layer.
|
|
//--- Budgeting HYBRID's LSTM against the flattened 420 UNDER-sized it by a full ladder step: the
|
|
//--- quadratic in ComputeLstmHiddenSize is dominated by the inputs term, so overstating the fan-in
|
|
//--- buys a smaller H for no reason.
|
|
//--- Requires m_convFilterCount to be settled first - InitNeuralNetwork orders it that way.
|
|
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. That makes this stage far and away the
|
|
//--- largest weight block in an LSTM or HYBRID model: at the shipped default of 32 units against a
|
|
//--- 540-wide input it is ~73k weights, more than DOUBLE the entire derived dense taper it feeds.
|
|
//--- It was the one part of the network the capacity budget never covered, which is why deriving the
|
|
//--- dense stack alone did not stop LSTM/HYBRID from being over-parameterized.
|
|
//--- Same budget as ComputeFirstLayerWidth: at most one weight per in-sample bar. Solving
|
|
//--- 4H(H+inputs+1) <= isBars for H is an ordinary quadratic, H = (-b + sqrt(b^2+4c))/2 with
|
|
//--- b = inputs+1 and c = isBars/4.
|
|
//--- PER-TIMESTEP width, not the flattened fan-in. The layer is now a recurrence: one shared gate
|
|
//--- block is applied at every step, so its parameter count is 4H(H + stepWidth + 1) - the whole
|
|
//--- point of weight sharing. Budgeting against the flattened width (420, or 160 behind conv) was
|
|
//--- correct for the old single-timestep layer and is now ~20x too pessimistic, which would starve
|
|
//--- the recurrence of hidden units for no reason.
|
|
//--- Must match what the layer is actually built as: a recurrence sizes its shared gate block on the
|
|
//--- PER-TIMESTEP width, while the single-timestep layer reads the whole flattened vector at once.
|
|
//--- Budgeting one against the other over-parameterizes by ~20x in one direction and starves the
|
|
//--- recurrence in the other. See LSTM_SEQUENCE_MODE.
|
|
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. On CONV/LSTM/HYBRID a front-end stage has already reduced
|
|
//--- the vector by the time the first dense layer sees it - an LSTM hands the taper m_lstmHiddenSize
|
|
//--- values (64 on the shipped SP500 H1 config), not 1,280 - so the budget was charging the dense
|
|
//--- layer for ~20x the fan-in it has. Measured on the deployed .cfg files that day: CONV, LSTM and
|
|
//--- HYBRID had all been pinned at FIRST_LAYER_MIN_WIDTH for their whole lives, with the
|
|
//--- "cannot support a N-wide input" warning below firing on a premise that was not true of them.
|
|
//--- The front-end widths come from the same helpers AddConvStage/AddLstmStage build from, so what is
|
|
//--- budgeted and what is constructed cannot disagree - and InitNeuralNetwork settles both stages
|
|
//--- before calling this (see the ordering note there).
|
|
int frontEndOut = UsesLstmStage() ? m_lstmHiddenSize
|
|
: (UsesConvStage() ? ConvOutputWidth() : 0);
|
|
int inputWidth = (frontEndOut > 0) ? frontEndOut : (int)m_historyBars * m_neuronsCount;
|
|
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. Charging the dense layer only for its real fan-in makes
|
|
//--- the budget generous on a narrow front end - an LSTM's 64 outputs would allow 256 units - and a
|
|
//--- 64 -> 256 first layer is a linear fan-out that cannot recover information the recurrence already
|
|
//--- discarded, it can only add parameters to overfit with. 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. No effect on a plain MLP, which has no front end to be capped by.
|
|
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. Typical cause is a high timeframe
|
|
//--- (D1 over 10 years is under 2,000 bars) or too many features for the history available. Say so:
|
|
//--- the fix is fewer HistoryBars / fewer feature groups / a longer study period, none of which this
|
|
//--- function can choose on the user's behalf.
|
|
if(budget < FIRST_LAYER_MIN_WIDTH)
|
|
Print(ID + ": WARNING - " + IntegerToString((int)isBars) + " estimated in-sample bars 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 training bar - expect overfitting. Reduce HistoryBars or the feature set," +
|
|
" lengthen the study period, 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. |
|
|
//| |
|
|
//| MEMORY LAYOUT, which is what every decision here turns on: |
|
|
//| - The INPUT is bar-major: BufferTempData appends m_neuronsCount |
|
|
//| contiguous features per bar, bars in order. So a flat window of |
|
|
//| k*m_neuronsCount spans exactly k consecutive BARS, and a step |
|
|
//| of m_neuronsCount advances exactly one bar. A multi-bar |
|
|
//| receptive field therefore needs NO kernel change. |
|
|
//| - A CONV OUTPUT is position-major: FeedForwardConv (AI\Network.cl)|
|
|
//| emits matrix_o[out + window_out * i], so one position's |
|
|
//| window_out filter responses are CONTIGUOUS and consecutive |
|
|
//| positions sit window_out apart. |
|
|
//| - Both pool implementations (FeedForwardProof, CPU_FeedForwardProof)
|
|
//| slide FLAT: pos = i*step over `window` CONSECUTIVE elements. |
|
|
//| Over a position-major buffer those neighbours are the FILTERS of |
|
|
//| one position. So a pool here is a max-over-CHANNELS, never a |
|
|
//| pool across time. |
|
|
//| |
|
|
//| That is exactly the NeuroNet_DNG reference contract (see |
|
|
//| references\MQL5\Experts\EDL\Trajectory.mqh layers 2-5, kernels |
|
|
//| byte-identical to ours): conv(window=2, step=1, window_out=4) -> |
|
|
//| pool(window=4, step=4) -> conv -> pool. The pool is tied to the |
|
|
//| filter count, giving a clean non-overlapping channel reduction. |
|
|
//| |
|
|
//| WHY THE POOL WINDOW MUST STAY TIED TO window_out. Shipping |
|
|
//| window=3/step=2 against 16 filters overlapped windows across the |
|
|
//| filter axis and straddled position boundaries, collapsing |
|
|
//| unrelated detectors into whichever fired hardest below every |
|
|
//| learnable layer (CONV sat at ~40% balanced accuracy for 510 eras, |
|
|
//| Sell recall 0%). The opposite error is just as bad: dropping the |
|
|
//| pool but leaving conv at window=step=one bar is a 1x1 conv that |
|
|
//| never mixes across time at all. |
|
|
//| |
|
|
//| We stop one layer short of the reference and do NOT append the |
|
|
//| second pool. A channel pool emits one scalar per position, so a |
|
|
//| trailing pool would hand the dense stack ~18 values for a 420-wide |
|
|
//| input and force it to FAN OUT 18 -> 64 instead of funnelling. The |
|
|
//| reference affords that at window_out=4 against a far smaller |
|
|
//| input; here it is a bottleneck below every learnable layer. The |
|
|
//| full 8-filter map goes to the dense/LSTM stage. |
|
|
//+------------------------------------------------------------------+
|
|
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 GPU/DirectML 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. See CONV_RECEPTIVE_FIELD_BARS' comment for the measurement and the
|
|
//--- reference-kernel reading behind that: the conv emits position-major output and the reference pool is
|
|
//--- a flat contiguous max, so a pool here can only ever reduce ACROSS FILTERS within a position, never
|
|
//--- over time. It threw away 87.5% of this layer's output and starved every non-argmax filter of
|
|
//--- gradient. The second conv was mis-shaped in the same change - its window was counted in raw elements
|
|
//--- while its comment claimed positions, so a "2-position" window actually spanned 2 FILTERS of position
|
|
//--- 0 - and it only existed to consume the pool's output.
|
|
//--- If a deeper hierarchy is wanted later, the correct shape on THIS layout is a strided conv over
|
|
//--- positions: window = k * m_convFilterCount, step = s * m_convFilterCount (both whole numbers of
|
|
//--- positions, which IS contiguous in position-major order), never a pool. 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/WarriorDML.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. CNet passes
|
|
//--- this to CNeuronLSTMOCL::SetStepWidth(), which is what makes the layer an actual recurrence over
|
|
//--- m_historyBars steps instead of a single gated projection over the whole flattened vector. It
|
|
//--- must divide LstmFanIn() exactly, which it does by construction in both placements: on the raw
|
|
//--- input the vector is historyBars x m_neuronsCount, and behind the conv chain it is
|
|
//--- ConvOutputPositions() x m_convFilterCount (position-major, filters contiguous per position -
|
|
//--- see the layout note above AddConvStage). 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.
|
|
//--- 0 disables sequence mode in CNeuronLSTMOCL::SetStepWidth (which maps <=0 to "not a sequence"),
|
|
//--- restoring the single-timestep layer. See LSTM_SEQUENCE_MODE.
|
|
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). Placed BETWEEN layers rather
|
|
//--- than inside them because every layer here computes activation(W.x+b) in a single kernel - there
|
|
//--- is no seam between the matmul and the non-linearity to insert anything into. Normalizing the
|
|
//--- previous layer's OUTPUT is the equivalent formulation and is exactly what the NeuroNet_DNG
|
|
//--- reference's own worked example does (input -> BatchNorm -> hidden -> output).
|
|
//--- The first one also normalizes whatever the conv/pool/LSTM stage produced, which is the widest
|
|
//--- unbounded stage in the whole network and the one whose scale drift hurts most.
|
|
//--- GEOMETRIC taper from the derived first-layer width down to a final hidden width tied to the
|
|
//--- output count, spread evenly over however many layers the architecture asks for. This replaces a
|
|
//--- pair of inputs (NeuronsReduction, MinNeuronsCount) that were calibrated when the first layer was
|
|
//--- a hand-picked 500: they produced a genuine 500 -> 150 -> 45 funnel there, but against the derived
|
|
//--- width they degenerate. 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. Deriving the ratio from the endpoints keeps the funnel shape correct at any
|
|
//--- width, which is the whole point of having derived the width in the first place.
|
|
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. Seeded with the input width - which is also a lie for CONV/LSTM/HYBRID, where
|
|
//--- the custom stage in between has resized things; that is exactly why CNet does not trust it.
|
|
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). OutputLayerActivation() is the single source of truth and EnforceTopologyContract()
|
|
// re-asserts it after every Load.
|
|
// Regression (1 output): TANH - its [-1,1] range maps straight onto the -1/0/1 Sell/Neutral/Buy
|
|
// convention, with no SIGMOID offset or clipped ReLU half.
|
|
// Classification (3 outputs): SIGMOID, deliberately NOT NONE.
|
|
// The forward head must stay BOUNDED. HiddenLayerActivation() is PRELU, so this is the only bounded
|
|
// stage in the forward path, and two downstream constants are calibrated against that: CLASS_LOGIT_SCALE
|
|
// = 6.0 is a temperature gain sized to stretch [0,1] into a usable logit span (against a free logit it
|
|
// is just a 6x amplifier), and the +-3.0 cold-start bias seed reads "sigmoid(+-3) ~= 0.95/0.05". An
|
|
// unbounded head was tried 2026-07-27 and reverted the next day: it starts at exp(6*3) vs exp(6*-3),
|
|
// saturated softmax makes the gradient input-INDEPENDENT, and all four architectures degenerated to
|
|
// one- or two-class output within two eras. Do NOT unbound the head again without simultaneously
|
|
// setting CLASS_LOGIT_SCALE to 1.0 and the bias magnitude to ~0.5.
|
|
// The BACKWARD pass is not 3 independent sigmoid deltas: CNet::backProp/backPropOCL detect the
|
|
// 3-output case and compute a joint softmax + categorical-cross-entropy gradient (softmax_i - target_i),
|
|
// which is what ties the classes together - raising one probability structurally lowers the other two.
|
|
// ApplyClassificationSoftmax() reproduces exactly that normalization at read time.
|
|
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. Reset to NULL
|
|
// here; EnsureShadowNet() lazily re-bootstraps a fresh clone of the new Net on first use.
|
|
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. Restored (not reset)
|
|
//--- on a normal reload of an existing model - that path loads them via LoadModelStats() and never
|
|
//--- calls BuildFreshTopology(). Harmless during the post-tune rebuild (online learning is
|
|
//--- gated off there, and the deployed final retrain rebuilds and resets again before deployment).
|
|
m_onlineLearnedUpToTime = 0;
|
|
m_onlineRollingAcc = -1.0;
|
|
m_onlineSamples = 0;
|
|
m_onlineBarsSincePersist = 0;
|
|
m_onlineBlendFrozen = false;
|
|
return true;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| |
|
|
//+------------------------------------------------------------------+
|
|
bool CExpertSignalAIBase::InitIndicators(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. This method runs from
|
|
//--- InitNeuralNetwork() BEFORE the per-config fingerprint is appended to m_fileName, so at this point
|
|
//--- m_fileName is only "<folder>\<symbol>_<period>" - the load looked for e.g. "SP500_16385.arrows"
|
|
//--- while SaveChartSignals() (which only ever runs post-init, with the finished name) had written
|
|
//--- "SP500_16385_3.00000000_1.00000000_<hash>.arrows". The mismatch made the restore silently no-op on
|
|
//--- every restart from the moment the fingerprint was introduced. It is now called at the END of
|
|
//--- InitNeuralNetwork(), once m_fileName is final. 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
|
|
}
|
|
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
|