Отслеживать
1
0
Ответвление
У вас уже есть ответвление Warrior_EA
0
ответвлён от MrBaro75/Warrior_EA
Warrior_EA/Expert/Topology/Topology.mqh
AnimateDread 98f485b901 fix(gate): the payoff horizon ended before the pivot it was measuring
The first cut measured payoff over SwingLifespanEstimate() bars. That is
PIVOT_LABEL_TOLERANCE_BARS - a constant of the TARGET describing how many bars
share one pivot event - and it is the wrong horizon for what a call is worth.

The label fires when a pivot lands WITHIN that window. So at that horizon the
pivot may only just have committed, and a perfectly correct call can still show
a negative forward move because the turn it predicted has not had one bar to
run. Measuring only there would understate the payoff of a signal working
exactly as designed, and could inflip its sign.

Measures two horizons and reports both:

  SHORT = PIVOT_LABEL_TOLERANCE_BARS      "has the pivot arrived" - a control
  HOLD  = that + the median ZigZag leg    the pivot PLUS the leg it opens,
                                          which is how long a trade on this
                                          call would actually be held

Adds CTopology::SwingLegMedianBars(). It is deliberately NOT the same thing as
SwingLifespanEstimate() and the declaration says so: the lifespan is a constant
of the target and is what the effective-sample-size deflation divides by, while
the leg median is a measurement of the chart and is how long the move runs.
Conflating them is what produced the wrong horizon in the first place.

Non-const and lazily measured, because a model that adopted its .cfg never
walked the chart and would otherwise report HISTORY_BARS_FALLBACK as if it were
a measurement - the same lazy pattern DeriveHistoryBars() already uses.

Reporting both horizons is also the guard against picking one and calling it
the truth. A break-even conclusion here has already been overturned once purely
by getting a horizon wrong.

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

815 строки
47 КиБ
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); }
//--- BARS BETWEEN CONSECUTIVE PIVOTS. A DIFFERENT QUANTITY FROM SwingLifespanEstimate() and the
//--- two must never be swapped: the lifespan is PIVOT_LABEL_TOLERANCE_BARS, a constant of the
//--- TARGET saying how many bars share one pivot event, while this is a MEASUREMENT of the chart
//--- saying how long the leg that follows a pivot actually runs. The first is what the effective-
//--- sample-size deflation divides by; the second is how long a call on that pivot would be held.
//---
//--- Not const, and deliberately so: a model that adopted its .cfg never walked the chart, so
//--- m_swingLegMedian would still be sitting on HISTORY_BARS_FALLBACK. Measuring on demand is the
//--- same lazy pattern DeriveHistoryBars() already uses, and it runs once per chart per session.
double SwingLegMedianBars(void)
{
if(!m_swingGeometryValid)
MeasureSwingGeometry();
return MathMax(1.0, m_swingLegMedian);
}
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[] = {6, 8, 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];
//--- TWO QUESTIONS, AND THE WINDOW IS THE SMALLER ANSWER. The ladder above answers "how far back is
//--- a swing worth looking"; HISTORY_BARS_CAPACITY_CAP answers "how far back can this much data
//--- support". Taking the min stops the first from writing a cheque the second cannot cover - which
//--- is what left three charts pinned to the 16-unit first-layer floor at 49 x 12 = 588 inputs.
int geometryWindow = window;
if(window > HISTORY_BARS_CAPACITY_CAP)
window = HISTORY_BARS_CAPACITY_CAP;
if(geometryWindow != window)
PrintFormat("%s: input window CAPPED at %d bars for capacity - the swing geometry asked for %d"
" (median leg %.1f), but %d columns x %d bars = %d inputs is more than the data"
" supports and would floor the first layer. The cap is a FLEET CONSTANT, not a"
" per-chart derivation: pool rows are keyed on bars x columns, so a per-chart window"
" would give every chart its own layout and its own pool of one.",
m_view.Id(), window, geometryWindow, m_swingLegMedian,
m_view.NeuronsCount(), geometryWindow, m_view.NeuronsCount() * geometryWindow);
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
//+------------------------------------------------------------------+