Отслеживать
1
0
Ответвление
У вас уже есть ответвление Warrior_EA
0
ответвлён от animatedread/Warrior_EA
Warrior_EA/Expert/Topology/Topology.mqh
AnimateDread 994fe3899c feat(label): pivot-EVENT target replaces direction-to-next-pivot
The old target asked "which way is the next pivot", which every bar of a
~13-20 bar leg answers identically - so the net could not tell a fresh turn
from mid-trend and learned the prevailing direction instead. Its own
zero-skill reference showed it: chance sat at 56/44, i.e. the label WAS the
drift, and the gate's standing warning ("a model that only reproduces it has
found the drift, not an edge") applied to the target itself.

Buy now means a swing LOW commits within PIVOT_LABEL_TOLERANCE_BARS bars,
Sell a swing HIGH, Neutral no turn that close. Pivot type is read from
ZigZagBuffer[p] == Low[p], exact by construction in ZigZag.mq5. The existing
P1-final-once-P2-commits rule is kept and now also settles the NEGATIVE
verdict, so the Neutral majority is permanent rather than provisional.

Measured on a full fresh run, all 6 charts:
  class balance   56/44/~0     -> 13.7/13.7/72.6 (imbalance 5.3:1)
  label overlap   ~31 bars     -> 5 bars
  independent obs 368-1086     -> 2331-7032
  weights/obs     9.2-26.2     -> 1.1-4.2
  coverage        100% of bars -> 17-48%
  23 of 24 models fire all three classes at precision 18-32% vs 13-15%
  chance; SP500's ensemble reaches DEPLOYABLE (32.3% vs a 24.0% bar).

Two bindings had to move with the label:

- The capacity deflator. m_swingLifespan fed EstimatedInSampleBars() as
  raw/31, measured from the legs. Overlap is now a property of the LABEL -
  one turn is callable by exactly the tolerance window - so it is the
  window, not a leg measurement. Missing this would have kept every model
  sized for a sixth of its real evidence.

- A dormant cold-start seed. Labels.mqh seeds the output bias toward the
  dominant class above COLD_START_SEED_MIN_DOMINANCE (0.70); at 56/44 it
  never armed, at 72.6% Neutral it does - writing a fixed +-3.0 against a
  true prior spread of ~1.75, which would start every net predicting Neutral
  ~95% of the time. Now seeds the measured log-prior, zero-centred and
  capped by the same guard rail the logit adjustment uses (Lin et al. 2017).

TGT:SWG1 -> TGT:PVT1:<tolerance>, with the window in the token because it is
part of the label: every .nnw is invalidated and the fleet retrains.

Depth is still gated, and now for a precise reason: the first dense layer
stays at FIRST_LAYER_MIN_WIDTH because budget = effN/(inputWidth+1) is 11.2
at input 624. Reaching the next rung needs inputWidth <= ~218, i.e. feature
pruning - not architecture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 00:16:16 -04:00

785 строки
45 КиБ
MQL5

//+------------------------------------------------------------------+
//| 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
//+------------------------------------------------------------------+