//+------------------------------------------------------------------+ //| Warrior_EA | //| AnimateDread | //| | //| The fingerprint, the derived shape (width/taper/depth/conv | //| filters/LSTM hidden), the conv/LSTM/batch-norm stages and | //| BuildFreshTopology. Owns exactly one piece of state - the | //| measured swing geometry (see MeasureSwingGeometry); every other | //| field these methods touch is shared elsewhere in the signal. | //| InitNeuralNetwork()/InitFeatureIndicators() are NOT | //| here: they are the network BOOT SEQUENCE (file locking, tester- | //| cache seeding, chart/persistence/online-learning orchestration),| //| a different and far more entangled job than deriving a shape - | //| they stay in Expert\AIBase\Topology.mqh, called through the same | //| forwards this file's methods are now reached through. | //+------------------------------------------------------------------+ #ifndef WARRIOR_TOPOLOGY_TOPOLOGY_MQH #define WARRIOR_TOPOLOGY_TOPOLOGY_MQH class CTopology { private: CTopologyView *m_view; // BORROWED - the signal owns the adapter, not the reverse //--- THE MEASURED SWING GEOMETRY. The input window and the capacity budget are two questions about //--- the SAME thing - how long this instrument's swing legs are - so one ZigZag walk answers both, //--- and neither can be measured against a definition the other does not share. bool m_swingGeometryValid; double m_swingLegMedian; // bars between consecutive pivots, median //--- LABEL-OVERLAP SPAN: how many labelled bars share one underlying event, which is what every //--- consumer divides by. Under the pivot-event target that is the tolerance window, NOT the old //--- "bars until the pivot PAIR commits" - see MeasureSwingGeometry() for why the distinction //--- moved the capacity budget by a factor of ~15. double m_swingLifespan; int m_swingLegCount; void MeasureSwingGeometry(void); public: //--- The leg-median default is HISTORY_BARS_FALLBACK. The overlap default is the tolerance window //--- itself, which is the TRUE value rather than a conservative stand-in: unlike the old target, //--- this label's overlap is a property of the label definition and not of the instrument's //--- geometry, so there is nothing to measure and nothing to be wrong about before measuring. CTopology(void) : m_view(NULL), m_swingGeometryValid(false), m_swingLegMedian(HISTORY_BARS_FALLBACK), m_swingLifespan(PIVOT_LABEL_TOLERANCE_BARS), m_swingLegCount(0) { } void Bind(CTopologyView *view) { m_view = view; } //--- Forces the next DeriveHistoryBars() to walk the chart again. The panel's weights reset is the //--- only caller: it is what makes the "let history finish downloading, then reset" advice in the //--- warnings below actually re-size the model instead of re-pinning the starved estimate. void RemeasureSwingGeometry(void) { m_swingGeometryValid = false; } double SwingLifespanEstimate(void) const { return MathMax(1.0, m_swingLifespan); } string BuildModelFingerprint(void); double EstimatedInSampleBarsRaw(void) const; double EstimatedInSampleBars(void) const; int FirstLayerFanIn(void) const; int DeriveHistoryBars(void); int ComputeHiddenLayerCount(void) const; int ComputeConvFilterCount(void) const; string FrontEndConfigSummary(void) const; int LstmFanIn(void) const; int ComputeLstmHiddenSize(void) const; int ComputeFirstLayerWidth(void) const; bool AddBatchNormStage(CArrayObj *topology, int units); bool AddConvStage(CArrayObj *topology); int ConvReceptiveFieldBars(void) const; int ConvFirstStagePositions(void) const; bool HasSecondConvStage(void) const; int ConvOutputPositions(void) const; int ConvOutputWidth(void) const; bool AddLstmStage(CArrayObj *topology); bool BuildFreshTopology(void); }; //+------------------------------------------------------------------+ //| THE MODEL FINGERPRINT - every configured value that changes what | //| the weights mean, and nothing that does not. Its hash names the | //| .nnw/.cfg pair, so this string alone decides when a trained | //| model may be resumed and when it must start again from era 0. | //+------------------------------------------------------------------+ string CTopology::BuildModelFingerprint(void) { //--- Per-configuration fingerprint appended to the weights filename so that every distinct //--- combination of RETRAIN-AFFECTING inputs gets its OWN persistent .nnw/.cfg, instead of all //--- combinations sharing one file keyed only on symbol/period/output/optimizer. A model trained //--- at 1:3 must never be silently reused at 1:1. string fp = StringFormat("%d|%d|%d|%d|%d|%d|%.2f|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d", //--- LEGACY_HISTORY_BARS_SLOT: the window left this hash 2026-08-11 when //--- it became DERIVED - same rule and reason as every derived field above; //--- keyed on a measured quantity, the filename would change the moment more //--- history downloads. The .cfg is the record (adopt-don't-compare). m_view.OptimizationAlgo(), LEGACY_HISTORY_BARS_SLOT, m_view.OutputNeuronsCount(), m_view.NeuronsCount(), m_view.MinTrainYear(), LEGACY_CONVERGE_WR_SLOT, m_view.FractalPeriods(), //--- LEGACY SLOTS (were MinDirectionalRecallPct - the removed recall floor, //--- shipped 40 - and m_focalGamma, removed 2026-07-31, which always //--- contributed 0). Writing the same literals keeps every existing model's //--- filename intact. 40, 0, m_view.OosSplitPct(), m_view.SwingConfirmationBars(), (int)m_view.UseVolumes(), (int)m_view.UseTime(), (int)m_view.UseATR(), (int)m_view.UseSwingContext(), (int)m_view.UseNews(), m_view.NewsFeatureWindowMinutes()); //--- The one derived value that DOES belong here, and only when it is not derived at all: a //--- forced depth is a developer override (see ForceHiddenLayers), so a build that pins one must //--- not adopt the .cfg of a build that derived it. if(ForceHiddenLayers > 0) fp += StringFormat("|FHL:%d", ForceHiddenLayers); //--- LEGACY FEATURE SLOTS (were UseRSI and the five AD/Wyckoff flags, removed 2026-08-24). All //--- six were hashed UNCONDITIONALLY and every one shipped false, so the literal zeros below are //--- exactly what every .nnw on disk is already named after. Same rule as the m_focalGamma slot //--- above: write the literal, keep the filename. fp += StringFormat("|%d|%d|%d|%d|%d|%d|%d|%d", (int)m_view.UseMA(), 0, 0, 0, 0, 0, 0, //--- 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); //--- Cross-asset panel: conditional append, per the rule above, so existing fingerprints are //--- untouched. ONLY the flag and the feature count go in. The composition is logged at build //--- time and pinned in the .cfg instead. if(m_view.UseCrossAsset()) { fp += StringFormat("|XA:%d", CROSSASSET_FEATURES); //--- INDEX-MODE RE-ENCODE (2026-08-11). Same width, different SEMANTICS - so models trained //--- under the old degenerate encoding must re-key. if(SymbolInfoString(m_view.SymbolName(), SYMBOL_CURRENCY_BASE) == SymbolInfoString(m_view.SymbolName(), 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_view.UseSpreadFeature()) fp += "|SPR:2"; //--- ALT-DATA WINDOW LAYOUT (2026-08-16). The external block now enters the input window ONCE, //--- on the newest bar, instead of being replicated on all m_historyBars bars (see //--- BuildFeatureWindow for the measurement and the reason). if(m_view.UseAltData()) fp += "|ALTW:2"; //--- Batch normalization changes the LAYER COUNT, not just the weights, so a model trained with //--- it must never load into a topology built without it (and vice versa) - the .cfg guard would //--- catch the mismatch and retrain, but only after a confusing failure. if(EnableBatchNorm && BatchNormWindow > 1) fp += StringFormat("|BN:%d", BatchNormWindow); //--- Changes the training gradient, so a model trained with it must never load into a run //--- without it. ":BS" = the correction spans Buy/Sell only, with Neutral (the abstain outcome) //--- never subsidised - see ApplyLogitAdjustment. LEGACY SLOT: tau is fixed at 1.0 since the //--- LogitAdjustTau input was removed; "100" keeps every model trained at the shipped default //--- byte-identical. fp += "|LA:100:BS"; //--- 2026-07-29 audit of every input in Variables\Inputs.mqh against this hash. LEGACY SLOT. //--- Same treatment as LEGACY_CONVERGE_WR_SLOT / LEGACY_STUDY_PERIOD_SLOT. fp += "|MR:1:90:1"; //--- Feature-value inputs, each conditional on the feature that reads it actually being on - the //--- same rule the MACD/Ichimoku blocks above follow. All three also feed the CLASSIC MA/RSI //--- votes, which are inference-only; gating on the AI feature flag is what keeps a classic- //--- signal tweak from re-keying a model that never saw it. if(m_view.UseVolumes()) fp += StringFormat("|VOL:%d", (int)VolumeData); if(m_view.UseMA()) fp += StringFormat("|MAP:%d", (int)PeriodMA); //--- AD/WYCKOFF PARAMETERS. That rule is not theoretical here: the 2026-07-29 audit found five //--- inputs changing trained weights without changing the filename, which is the exact trap the //--- .nnw architecture incident already cost a day to. fp += "|WIN:2"; //--- TRAINING TARGET. The token covers the label's meaning - bump the number if any of it //--- changes. Emitted unconditionally: this used to branch to "|TGT:META2" for the meta head, //--- and every direction model already took this arm, so collapsing it is byte-identical and //--- re-keys nothing on disk. //--- //--- SWG1 -> PVT1 (2026-08-25): the target changed from "which way is the next pivot" to "is a //--- pivot about to commit, and which way does it turn" (SwingPivotDirectionLabel). Every .nnw on //--- disk was fitted to the old meaning, so this MUST re-key or a resume would silently continue //--- training weights against a target they were never fitted to - the class balance alone moves //--- from ~56/44/0 to ~12/12/75. The tolerance window is in the token because it IS part of the //--- label: widening it relabels every bar near a turn. fp += StringFormat("|TGT:PVT1:%d", (int)PIVOT_LABEL_TOLERANCE_BARS); //--- Ensemble membership separates the FILES, not the semantics: the member's topology and label //--- are identical to its solo twin, but the two must never share weights across charts (the //--- duplicate-chart guard exists precisely to stop concurrent writers). if(m_view.IsEnsembleMember()) fp += "|ENS1"; //--- No close-all token: the swing label aims at a pivot and owes nothing to the trade schedule. return fp; } //+------------------------------------------------------------------+ //| Shared training-set size estimate - see the declaration comment. | //+------------------------------------------------------------------+ double CTopology::EstimatedInSampleBarsRaw(void) const { int secs = PeriodSeconds(m_view.Timeframe()); if(secs <= 0) secs = PeriodSeconds(PERIOD_H1); double barsPerYear = (SECONDS_PER_YEAR / (double)secs) * MARKET_OPEN_FRACTION; double oosKept = (100.0 - (double)m_view.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. string sym = m_view.SymbolName(); datetime firstAvailableBar = (datetime)SeriesInfoInteger(sym, m_view.Timeframe(), SERIES_FIRSTDATE); MqlDateTime floorTime; TimeCurrent(floorTime); floorTime.year = m_view.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(sym, m_view.Timeframe(), windowStart, TimeCurrent()); //--- History may not have finished syncing when a chart first attaches, and a model whose //--- capacity was pinned from a handful of bars would stay crippled for its whole life - the one //--- failure mode that measuring instead of assuming introduces. if(available < TOPOLOGY_BUDGET_MIN_TRUSTED_BARS) { Print(m_view.Id() + ": WARNING - only " + IntegerToString(available) + " bars of " + sym + " history are available yet, too few to size the network from. Falling back to a " + IntegerToString(TOPOLOGY_BUDGET_FALLBACK_YEARS) + "-year assumption. If this is a fresh" + " install, let the terminal finish downloading history and then delete this model's weights" + " from the panel so the topology is sized from the real data."); return (double)TOPOLOGY_BUDGET_FALLBACK_YEARS * barsPerYear * oosKept; } return (double)available * oosKept; } //+------------------------------------------------------------------+ //| In-sample budget in INDEPENDENT observations - see declaration. | //+------------------------------------------------------------------+ double CTopology::EstimatedInSampleBars(void) const { //--- DEFLATED BY LABEL OVERLAP (Lopez de Prado ch. 4): overlapping labels are not independent //--- observations, and every budget below is stated per INDEPENDENT observation. //--- //--- FROM THE MEASURED SWING GEOMETRY, not from m_labelOverlap. This runs inside //--- InitNeuralNetwork, where the label cache does not exist yet (that same function sets //--- m_labelCachePrebuilt = false a few lines later) - so the measured lifespan was still at its //--- "nothing observed" default of 1.0 at every call, and the deflation this line performs could //--- never once have fired. Every fresh model was therefore sized as though its labels did not //--- overlap at all, over-budgeting the first dense layer by exactly this factor. //--- //--- PLUS the pool: Use_Training_Pool feeds the trainer peer-chart rows this budget used to be //--- completely blind to (see CExpertSignalAIBase::TopologyPooledIndependentBars() for the two //--- conservative discounts applied). Recomputed every call, same as the own-chart term above - //--- capacity that only exists for a BRAND-NEW build anyway (an already-trained model's actual //--- layer widths come from its .nnw via LoadNetWithRetry(), never from this function). return EstimatedInSampleBarsRaw() / SwingLifespanEstimate() + m_view.PooledIndependentBars(); } //+------------------------------------------------------------------+ //| Fan-in of the first dense layer - see the declaration comment. | //+------------------------------------------------------------------+ int CTopology::FirstLayerFanIn(void) const { int frontEndOut = m_view.UsesLstmStage() ? m_view.LstmHiddenSize() : (m_view.UsesConvStage() ? ConvOutputWidth() : 0); return (frontEndOut > 0) ? frontEndOut : (int)m_view.HistoryBars() * m_view.NeuronsCount(); } //+------------------------------------------------------------------+ //| THE SWING GEOMETRY OF THIS CONFIGURATION, measured once. | //| | //| Walks m_zigZag - THE LABEL'S OWN PIVOT SOURCE, not a lookalike. | //| The window used to be measured with a private +/-12-bar fractal | //| while SwingPivotDirectionLabel aimed at ZigZag(12,5,3) pivots, so | //| the window was sized against a leg distribution the label never | //| used, and the lifespan below could not have been derived from it | //| at all. | //| | //| Reads the buffer through CopyBuffer on the indicator HANDLE | //| rather than the CiCustom, for the same reason the OHLC reads here | //| always used CopyHigh/CopyLow: this runs at InitNeuralNetwork, | //| before ResizeBuffers() has sized anything to this depth. | //+------------------------------------------------------------------+ void CTopology::MeasureSwingGeometry(void) { m_swingGeometryValid = false; m_swingLegMedian = HISTORY_BARS_FALLBACK; m_swingLifespan = PIVOT_LABEL_TOLERANCE_BARS; m_swingLegCount = 0; string sym = m_view.SymbolName(); int availableBars = Bars(sym, m_view.Timeframe()); int span = (int)MathMin(availableBars - 1, WINDOW_DERIVE_SPAN_BARS); if(span < TOPOLOGY_BUDGET_MIN_TRUSTED_BARS) { Print(m_view.Id() + ": WARNING - only " + IntegerToString(availableBars) + " bars of " + sym + " history are available yet, too few to measure the swing geometry from. Falling back to a " + IntegerToString(HISTORY_BARS_FALLBACK) + "-bar window and the same figure for label overlap. " "If this is a fresh install, let history finish downloading and then reset this model's " "weights from the panel, which re-measures both."); return; } //--- newest CLOSED bars only (start 1): ZigZag's most recent leg is still being revised, and a //--- repainting leg would enter the median as a length that never existed. double zz[]; ArraySetAsSeries(zz, true); if(CopyBuffer(m_view.ZigZagHandle(), 0, 1, span, zz) != span) { Print(m_view.Id() + ": WARNING - could not read " + IntegerToString(span) + " ZigZag values to measure " "the swing geometry (the indicator is probably still calculating); falling back to a " + IntegerToString(HISTORY_BARS_FALLBACK) + "-bar window and the same figure for label overlap. " "Reset this model's weights from the panel once the chart has settled to re-measure both."); return; } //--- Bar distances between consecutive pivots, oldest -> newest. Pivots alternate by construction, //--- so a non-zero buffer entry IS the next pivot and needs no high/low test. double legs[]; ArrayResize(legs, 0, 256); int prevPivot = -1; for(int b = span - 1; b >= 0; b--) { if(zz[b] == 0.0 || !MathIsValidNumber(zz[b])) continue; if(prevPivot >= 0) { int n = ArraySize(legs); ArrayResize(legs, n + 1, 256); legs[n] = (double)(prevPivot - b); // series indices: newer bar = smaller index } prevPivot = b; } m_swingLegCount = ArraySize(legs); if(m_swingLegCount < WINDOW_DERIVE_MIN_LEGS) { Print(m_view.Id() + ": WARNING - only " + IntegerToString(m_swingLegCount) + " ZigZag legs in " + IntegerToString(span) + " bars, too few to trust a median. Falling back to a " + IntegerToString(HISTORY_BARS_FALLBACK) + "-bar window and the same figure for label overlap."); return; } m_swingLegMedian = MathMedian(legs); //--- LABEL OVERLAP IS NO LONGER A PROPERTY OF THE SWING GEOMETRY (2026-08-25). It used to be: //--- the old direction-to-next-pivot target gave every bar of a leg the same answer, so a bar //--- d bars before pivot P waited d + (the leg leaving P) bars to resolve, and the mean of that //--- over the measured legs - about 31 bars here - was the right deflator. //--- //--- The pivot-event target overlaps only over its TOLERANCE WINDOW: one turn can be called by //--- the PIVOT_LABEL_TOLERANCE_BARS bars in front of it and by no others, whatever the legs //--- around it look like. Consecutive windows share all but one bar, so mean uniqueness is 1/T //--- and the deflator is T - a constant of the label, not a measurement of the instrument. //--- //--- THIS IS A ~15x CHANGE IN THE CAPACITY BUDGET and it is the intended consequence, not a //--- side effect: EstimatedInSampleBars() divides by this, so every model was being sized for //--- ~368-1086 independent observations when the new label supplies ~5,700-17,600. That is what //--- lifts the first dense layer off its FIRST_LAYER_MIN_WIDTH floor, and with it the derived //--- hidden-layer count off MIN_HIDDEN_LAYERS. The legs are still walked above: m_swingLegMedian //--- still sets the input window, and the leg count still gates the too-few-legs warning. m_swingLifespan = MathMax(1.0, (double)PIVOT_LABEL_TOLERANCE_BARS); m_swingGeometryValid = true; } //+------------------------------------------------------------------+ //| Derived input-window length - see the declaration comment. | //+------------------------------------------------------------------+ int CTopology::DeriveHistoryBars(void) { if(!m_swingGeometryValid) MeasureSwingGeometry(); //--- 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] <= m_swingLegMedian) window = ladder[i]; PrintFormat("%s: derived swing geometry - input window %d bars (median ZigZag leg %.1f over %d legs," " snapped down to the ladder%s), label overlap %.1f bars. Measured once at model" " creation and pinned in the .cfg; an existing model adopts its own trained window" " instead. The overlap is what the capacity budget divides by - see" " ComputeFirstLayerWidth. It is now the pivot label's tolerance window, a constant of" " the target, not a measurement of these legs - see MeasureSwingGeometry.", m_view.Id(), window, m_swingLegMedian, m_swingLegCount, m_swingLegMedian > 32 ? ", CAPPED at 32 - era time scales with the window" : "", SwingLifespanEstimate()); return window; } //+------------------------------------------------------------------+ //| Dense-taper depth - see the declaration comment. | //+------------------------------------------------------------------+ int CTopology::ComputeHiddenLayerCount(void) const { //--- Diagnostic escape hatch (compile-time, see ForceHiddenLayers). Deliberately not an input: this //--- exists to run depth comparisons while working on the EA, and a user who picks a depth is //--- contradicting the width and taper the code derived around it. if(ForceHiddenLayers > 0) return (int)MathMax(1, MathMin(MAX_HIDDEN_LAYERS, ForceHiddenLayers)); //--- Depth follows from the two ENDPOINTS the taper already has to connect - the derived first- //--- layer width and the output-tied final hidden width (see BuildFreshTopology's taper block) - //--- by asking how many steps it takes to get from one to the other at a sane per-layer //--- compression ratio. int lastHidden = (int)MathMax(HIDDEN_TAPER_OUTPUT_MULTIPLE * m_view.OutputNeuronsCount(), HIDDEN_TAPER_MIN_WIDTH); lastHidden = (int)MathMin(lastHidden, m_view.InitialNeuronsCount()); if(lastHidden <= 0 || m_view.InitialNeuronsCount() <= lastHidden) return MIN_HIDDEN_LAYERS; double steps = MathLog((double)m_view.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 CTopology::ComputeConvFilterCount(void) const { //--- AddConvStage sets window = ConvReceptiveFieldBars() * m_neuronsCount and step = //--- m_neuronsCount, so each sliding position covers that many BARS of features and the layer is //--- a learned projection from the whole window down to this many filters. int chosen = (ConvReceptiveFieldBars() * m_view.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 CTopology::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(m_view.UsesConvStage()) { s += " | conv " + IntegerToString(ConvReceptiveFieldBars()) + " bars x" + IntegerToString(m_view.NeuronsCount()) + "->" + IntegerToString(m_view.ConvFilterCount()) + " (" + IntegerToString(ConvFirstStagePositions()) + " pos)"; if(HasSecondConvStage()) s += " | pool /" + IntegerToString(m_view.ConvFilterCount()) + " | conv2 ->" + IntegerToString(ConvOutputPositions()) + " pos x" + IntegerToString(m_view.ConvFilterCount()) + " = " + IntegerToString(ConvOutputWidth()); else s += " = " + IntegerToString(ConvOutputWidth()); } if(m_view.UsesLstmStage()) s += " | lstm " + IntegerToString(LstmFanIn()) + "->" + IntegerToString(m_view.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 = m_view.UsesLstmStage() ? m_view.LstmHiddenSize() : (m_view.UsesConvStage() ? ConvOutputWidth() : 0); if(frontEndOut > 0 && m_view.InitialNeuronsCount() > frontEndOut) s += " | NOTE dense fans out " + IntegerToString(frontEndOut) + "->" + IntegerToString(m_view.InitialNeuronsCount()); return s; } //+------------------------------------------------------------------+ //| Input width the LSTM block actually receives. | //+------------------------------------------------------------------+ int CTopology::LstmFanIn(void) const { //--- LSTM-only: the layer sits directly on the input, so it sees the whole flattened vector. //--- HYBRID: AddConvStage runs first, so the LSTM sees the CONV FEATURE MAP, not the input. if(m_view.HasConvBeforeLstm()) return ConvOutputWidth(); return (int)m_view.HistoryBars() * m_view.NeuronsCount(); } //+------------------------------------------------------------------+ //| LSTM recurrent hidden width - see the declaration comment. | //+------------------------------------------------------------------+ int CTopology::ComputeLstmHiddenSize(void) const { //--- The LSTM block's parameter count is EXACTLY 4 * H * (H + inputs + 1) - see //--- CNeuronLSTMOCL::SetInputs in AI\Network.mqh - and AddLstmStage feeds it the whole flattened //--- input vector, so `inputs` is historyBars x neuronsCount. int inputs = (LSTM_SEQUENCE_MODE ? (m_view.HasConvBeforeLstm() ? m_view.ConvFilterCount() : m_view.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 CTopology::ComputeFirstLayerWidth(void) const { //--- THE WIDTH THAT ACTUALLY REACHES THE DENSE STACK, not the raw input vector. Until 2026-08-09 //--- this budgeted against m_historyBars * m_neuronsCount on every topology, which is only the //--- truth for a plain MLP. int frontEndOut = m_view.UsesLstmStage() ? m_view.LstmHiddenSize() : (m_view.UsesConvStage() ? ConvOutputWidth() : 0); //--- Same expression, one owner (see FirstLayerFanIn): the report in ReportDetectability has to //--- charge for exactly what this decision charged for, or the two describe different networks. int inputWidth = FirstLayerFanIn(); if(inputWidth <= 0) return FIRST_LAYER_MIN_WIDTH; double isBars = EstimatedInSampleBars(); //--- One first-layer weight per in-sample bar. That layer is (inputWidth+1) x width and dominates the //--- model, so this is effectively a whole-model capacity budget. One parameter per sample is already //--- generous for a signal this weak; it is a ceiling, not a target. int budget = (int)(isBars / (double)(inputWidth + 1)); //--- Snap DOWN to the ladder: the estimate above is approximate, and a value that moves with every //--- small change would re-key the weights file for no benefit. Rungs are far enough apart that the //--- estimate would have to be wrong by ~2x to land on a different one. int ladder[] = {16, 32, 64, 128, 256, 512, 1024}; int chosen = FIRST_LAYER_MIN_WIDTH; for(int i = 0; i < ArraySize(ladder); i++) if(ladder[i] <= budget) chosen = ladder[i]; //--- NEVER WIDER THAN THE STAGE FEEDING IT. FrontEndConfigSummary() already calls that shape out //--- as a defect when it happens; this stops it happening. The taper below this layer then //--- funnels as intended. if(frontEndOut > 0) chosen = (int)MathMin(chosen, frontEndOut); //--- Budget below the floor means this configuration cannot support even the narrowest usable //--- layer - the model will be over-parameterized no matter what is chosen here, and no amount //--- of regularization fixes having more weights than examples. string basis = StringFormat("%.0f independent in-sample observations (%.0f bars / %.1f-bar label" " overlap)", isBars, EstimatedInSampleBarsRaw(), SwingLifespanEstimate()); if(budget < FIRST_LAYER_MIN_WIDTH) Print(m_view.Id() + ": WARNING - " + basis + " cannot support a " + IntegerToString(inputWidth) + "-wide " + (frontEndOut > 0 ? "vector into the dense stack" : "input") + ". The first layer is being floored at " + IntegerToString(FIRST_LAYER_MIN_WIDTH) + " units, which is still roughly " + DoubleToString((double)(inputWidth + 1) * FIRST_LAYER_MIN_WIDTH / MathMax(1.0, isBars), 1) + " weights per independent observation - expect overfitting. Reduce HistoryBars or the" + " feature set, lengthen the study period, pool instruments, or train on a lower timeframe."); return MathMax(FIRST_LAYER_MIN_WIDTH, chosen); } //+------------------------------------------------------------------+ //| Batch-normalization layer - see the declaration comment. | //+------------------------------------------------------------------+ bool CTopology::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_view.OptimizationAlgo(); if(!topology.Add(desc)) { delete desc; return false; } return true; } //+------------------------------------------------------------------+ //| Convolution front-end: conv -> channel pool -> conv. Shared by | //| CSignalCONV and CSignalHYBRID - see the declaration comment. | //+------------------------------------------------------------------+ bool CTopology::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_view.ConvFilterCount(); desc.type = defNeuronConv; // PRELU, not TANH: matches what CNeuronConv's CPU path (Network.mqh) has always hardcoded // regardless of this setting (its activationFunction() override ignores `activation` entirely) - // this used to silently diverge from the accelerated (OpenCL/CPU-DLL) tier, which DOES honor this // field and was therefore actually running tanh instead of the intended PReLU whenever hardware // accel was active. desc.activation = PRELU; desc.optimization = (ENUM_OPTIMIZATION)m_view.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_view.NeuronsCount(); desc.step = m_view.NeuronsCount(); if(!topology.Add(desc)) { delete desc; return false; } //--- NO POOL, and no second conv. It threw away 87.5% of this layer's output and starved every non- //--- argmax filter of gradient. Springenberg et al. ICLR 2015. return true; } //+------------------------------------------------------------------+ //| Conv chain shape. SINGLE SOURCE OF TRUTH - AddConvStage builds | //| from these and LstmFanIn/FrontEndConfigSummary report from them, | //| so what is constructed and what is logged cannot drift apart. | //+------------------------------------------------------------------+ int CTopology::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_view.HistoryBars()); return (bars > 0 ? bars : 1); } //+------------------------------------------------------------------+ int CTopology::ConvFirstStagePositions(void) const { //--- Sliding positions of stage 1: window ConvReceptiveFieldBars() bars, step 1 bar. int p = (int)m_view.HistoryBars() - (ConvReceptiveFieldBars() - 1); return (p > 0 ? p : 1); } //+------------------------------------------------------------------+ bool CTopology::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 CTopology::ConvOutputPositions(void) const { int p = ConvFirstStagePositions(); return (HasSecondConvStage() ? p - (ConvReceptiveFieldBars() - 1) : p); } //+------------------------------------------------------------------+ int CTopology::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_view.ConvFilterCount(); } //+------------------------------------------------------------------+ //| LSTM sequence stage. Shared by CSignalLSTM and CSignalHYBRID - | //| see the declaration comment. | //+------------------------------------------------------------------+ bool CTopology::AddLstmStage(CArrayObj *topology) { if(CheckPointer(topology) == POINTER_INVALID) return false; CLayerDescription *desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return false; desc.count = m_view.LstmHiddenSize(); desc.type = defNeuronLSTM; desc.activation = TANH; //--- CNeuronLSTMOCL now has an accelerated SGD+momentum kernel (LSTM_UpdateWeightsMomentum, //--- AI\Network.mqh/Network.cl/DirectML\WarriorCPU.cpp) alongside the original //--- Adam one, so this layer honors the same TrainingOptimizer input as PAI/CONV - see //--- m_optimizationAlgo's declaration comment. desc.optimization = (ENUM_OPTIMIZATION)m_view.OptimizationAlgo(); //--- PER-TIMESTEP input width - the feature count for ONE bar as it reaches this layer. Note the //--- step COUNT is the position count, which the conv chain shrinks below historyBars once a //--- multi-bar window and a second conv are in play. desc.window = (LSTM_SEQUENCE_MODE ? (m_view.HasConvBeforeLstm() ? m_view.ConvFilterCount() : m_view.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_view.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 CTopology::BuildFreshTopology(void) { 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; } desc.count = m_view.NetInputWidth(); desc.type = defNeuron; desc.activation = NONE; desc.optimization = (ENUM_OPTIMIZATION)m_view.OptimizationAlgo(); if(!Topology.Add(desc)) { delete Topology; return false; } //--- neuron-type-specific layers (Conv+Pool, LSTM, or none for a plain perceptron) if(!m_view.AddCustomLayers(Topology)) { delete Topology; return false; } //--- Hidden Layers, tapering from m_initialNeuronsCount down to m_minNeuronsCount, each preceded //--- by a batch-normalization layer (no-op when EnableBatchNorm is off). At 64 units, "keep 30% //--- with a floor of 20" gives 64 -> 20 -> 20 - the reduction stops mattering after one step and //--- the "minimum" silently becomes the width of every layer but the first. int lastHidden = MathMax(HIDDEN_TAPER_OUTPUT_MULTIPLE * m_view.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_view.InitialNeuronsCount()); double taperRatio = (m_view.HiddenLayersCount() > 1) ? MathPow((double)lastHidden / (double)m_view.InitialNeuronsCount(), 1.0 / (double)(m_view.HiddenLayersCount() - 1)) : 1.0; //--- Width of the layer immediately below the next batch-norm layer. Only advisory (CNet sizes //--- each batch-norm layer from whatever it actually sits on), but kept honest so the descriptor //--- list reads correctly. int prevWidth = (int)(m_view.HistoryBars() * m_view.NeuronsCount()); bool result = true; for(int i = 0; (i < m_view.HiddenLayersCount() && result); i++) { int n = (i == 0) ? m_view.InitialNeuronsCount() : MathMax(lastHidden, (int)MathRound(m_view.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 = m_view.HiddenLayerActivation(); desc.optimization = (ENUM_OPTIMIZATION)m_view.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_view.OutputNeuronsCount(); desc.type = defNeuron; //--- Never write the activation as a literal here: this line only ever reaches a BRAND-NEW //--- topology, so a change made here never touches an existing .nnw (CNeuronBaseOCL::Save //--- persists the activation and Load restores it). desc.activation = m_view.OutputLayerActivation(); desc.optimization = (ENUM_OPTIMIZATION)m_view.OptimizationAlgo(); if(!Topology.Add(desc)) { delete Topology; return false; } //--- The whole point of consolidating this into one view call: deleting the OLD net, constructing //--- the new one and reporting validity is irreducible pointer/object work, not signal state - see //--- ITopologyView.mqh's comment. bool netOk = m_view.ReplaceNetFromTopology(Topology); delete Topology; if(!netOk) return false; //--- A fresh topology invalidates any existing shadow and the whole online-learning history (a //--- brand-new untrained net has none) - see COnlineLearning::ResetForFreshTopology()'s comment. m_view.ResetOnlineLearningForFreshTopology(); return true; } #endif // WARRIOR_TOPOLOGY_TOPOLOGY_MQH //+------------------------------------------------------------------+