//+------------------------------------------------------------------+ //| Warrior_EA | //| AnimateDread | //| | //| Filter-based indicator auto-tuner (mutual information scoring). | //| | //| PARTIAL IMPLEMENTATION FILE - not standalone. | //| This holds CExpertSignalAIBase method BODIES only. The class | //| declaration lives in Expert\ExpertSignalAIBase.mqh, which | //| #includes this file at the bottom, after the declaration. Do not | //| include it anywhere else and do not compile it on its own. | //| | //| Split out purely to make the 8216-line original navigable; the | //| code inside was moved verbatim, not rewritten. | //+------------------------------------------------------------------+ #ifndef WARRIOR_AIBASE_AUTOTUNE_MQH #define WARRIOR_AIBASE_AUTOTUNE_MQH #ifdef WARRIOR_EXPORT_FEATURES //+------------------------------------------------------------------+ //| RESEARCH BUILD ONLY - see the declaration comment. | //| | //| Every research question so far has cost a compile, a deploy, an | //| attach and a log read - minutes each, and the answer arrives one | //| hypothesis at a time. That loop, not the modelling, is what has | //| made this slow. Exporting the feature matrix ONCE moves the whole | //| question offline, where a hypothesis costs seconds and real tools | //| (joint mutual information, gradient boosting, proper walk-forward | //| cross-validation) are available - none of which can be written in | //| MQL5 in reasonable time. | //| | //| Exports the RAW BARS next to the features deliberately: with OHLC | //| and ATR offline, every barrier geometry, every horizon and every | //| in-trade target can be recomputed without touching MetaTrader | //| again. The bar TIME goes out too, which makes session, hour and | //| day-of-week features derivable for free - and those are the only | //| inputs in play that are NOT a transform of the same OHLCV series. | //+------------------------------------------------------------------+ void CExpertSignalAIBase::ExportFeatureMatrix(void) { if(MQLInfoInteger(MQL_OPTIMIZATION)) return; int barsNow = Bars(m_symbol.Name(), PERIOD_CURRENT); if(barsNow <= m_historyBars + 2) { Print(ID + ": EXPORT - only " + IntegerToString(barsNow) + " bars available, nothing to write"); return; } if(!ResizeBuffers(barsNow) || !RefreshData()) { Print(ID + ": EXPORT - buffers not ready (" + IntegerToString(barsNow) + " bars), aborting"); return; } EnsureBarCachesCapacity(barsNow); EnsureBarrierHorizon(barsNow); string dir = eaName + "\\Research\\"; string fn = dir + m_symbol.Name() + "_" + IntegerToString(_Period) + "_features.csv"; int h = FileOpen(fn, FILE_COMMON | FILE_WRITE | FILE_CSV | FILE_ANSI, ','); if(h == INVALID_HANDLE) { Print(ID + ": EXPORT - cannot open " + fn + ", error " + IntegerToString(GetLastError())); return; } string header = "idx,time,open,high,low,close,atr"; for(int f = 0; f < m_neuronsCount; f++) header += ",f" + IntegerToString(f); FileWrite(h, header); //--- Oldest first. The loop walks DOWN the series index, which is forward in time (higher index = //--- older), so the file reads chronologically and Python can treat row order as time order. int written = 0, skipped = 0; uint t0 = GetTickCount(); for(int i = barsNow - 1; i >= 0; i--) { TempData.Clear(); if(!BufferTempData(i) || TempData.Total() < m_neuronsCount) { skipped++; continue; } double atr = m_ATR.Main(i); string row = IntegerToString(i) + "," + IntegerToString((long)m_Time.GetData(i)) + "," + DoubleToString(m_Open.GetData(i), _Digits) + "," + DoubleToString(m_High.GetData(i), _Digits) + "," + DoubleToString(m_Low.GetData(i), _Digits) + "," + DoubleToString(m_Close.GetData(i), _Digits) + "," + DoubleToString(MathIsValidNumber(atr) ? atr : 0.0, _Digits); for(int f = 0; f < m_neuronsCount; f++) row += "," + DoubleToString(TempData.At(f), 8); FileWrite(h, row); written++; } TempData.Clear(); FileClose(h); Print(ID + StringFormat(": EXPORT COMPLETE - %d rows x %d features -> Common\\Files\\%s " "(%d bars skipped for missing features, %.1fs, horizon %d, spread %d points)", written, m_neuronsCount, fn, skipped, (GetTickCount() - t0) / 1000.0, m_barrierHorizonBars, (int)m_symbol.Spread())); ExportRawRates(); } //+------------------------------------------------------------------+ //| RESEARCH BUILD ONLY. Raw OHLCV for a GRID of symbols/timeframes, | //| not just this chart's. | //| | //| The 26 engineered features can only be produced for the chart the | //| EA is attached to - the indicator handles are bound to | //| PERIOD_CURRENT. Raw rates are not: CopyRates serves any symbol | //| and any timeframe from a single chart. So one attach yields the | //| whole research grid, and every question that does not require the | //| EXISTING feature set - a different horizon, a different barrier, | //| session/time-of-day effects, features this EA does not have yet - | //| can then be answered offline without MetaTrader in the loop at | //| all. That is what turns a per-hypothesis cost of minutes into | //| seconds, which has been the real bottleneck all along. | //+------------------------------------------------------------------+ void CExpertSignalAIBase::ExportRawRates(void) { string symbols[] = { "SP500", "USDJPY", "XAUUSD", "EURUSD", "GBPUSD", "US30", "NAS100", "BTCUSD" }; ENUM_TIMEFRAMES tfs[] = { PERIOD_M5, PERIOD_M15, PERIOD_H1, PERIOD_H4, PERIOD_D1 }; string dir = eaName + "\\Research\\"; int cells = 0, rowsTotal = 0; for(int s = 0; s < ArraySize(symbols); s++) { //--- Skip silently rather than warn: the grid is deliberately broader than any one broker's symbol //--- list, so an absent instrument is expected, not an error. if(!SymbolSelect(symbols[s], true)) continue; for(int p = 0; p < ArraySize(tfs); p++) { MqlRates r[]; ArraySetAsSeries(r, false); // oldest first, so file order is time order int got = CopyRates(symbols[s], tfs[p], 0, 200000, r); if(got <= 100) continue; string fn = dir + symbols[s] + "_" + IntegerToString((int)tfs[p]) + "_rates.csv"; int h = FileOpen(fn, FILE_COMMON | FILE_WRITE | FILE_CSV | FILE_ANSI, ','); if(h == INVALID_HANDLE) continue; int dg = (int)SymbolInfoInteger(symbols[s], SYMBOL_DIGITS); FileWrite(h, "time,open,high,low,close,tickvol,spread"); for(int i = 0; i < got; i++) FileWrite(h, IntegerToString((long)r[i].time) + "," + DoubleToString(r[i].open, dg) + "," + DoubleToString(r[i].high, dg) + "," + DoubleToString(r[i].low, dg) + "," + DoubleToString(r[i].close, dg) + "," + IntegerToString((long)r[i].tick_volume) + "," + IntegerToString(r[i].spread)); FileClose(h); cells++; rowsTotal += got; Print(ID + StringFormat(": EXPORT rates - %s %s: %d bars", symbols[s], EnumToString(tfs[p]), got)); } } Print(ID + StringFormat(": EXPORT RATES COMPLETE - %d cells, %d bars total, under Common\\Files\\%s", cells, rowsTotal, dir)); } #endif //--- The genetic + successive-halving helpers that used to live here (GaRungEras, GaExtract, GaStore, //--- GaMutate, GaRandomCandidate, GaBlockCrossover, GaSortAliveByScoreDesc, GaBreedNextGeneration) were //--- deleted on 2026-08-01 together with the search they served. See TuneIndicatorsByFilter() below for //--- the measured cost that retired them and what replaced it. //+------------------------------------------------------------------+ //| MUTUAL INFORMATION between one cached feature column and the | //| triple-barrier label, in nats, over a sample of in-sample bars. | //| | //| I(X;Y) = sum p(x,y) log( p(x,y) / (p(x) p(y)) ), with the feature | //| discretised into MI_BINS EQUAL-FREQUENCY bins. Equal-frequency | //| rather than equal-width because these features are ATR-normalised | //| and heavy-tailed: fixed-width bins put nearly everything in one | //| bucket and report ~0 information for a genuinely useful feature. | //| | //| Rank-based binning gives equal frequency for free - sort a copy of | //| the column, then a value's bin is its rank scaled into MI_BINS. | //+------------------------------------------------------------------+ double CExpertSignalAIBase::FeatureColumnMI(const double &vals[], const int &labels[], int n) { if(n < MI_MIN_SAMPLES) return 0.0; double sorted[]; ArrayResize(sorted, n); ArrayCopy(sorted, vals, 0, 0, n); ArraySort(sorted); //--- A column that never varies carries no information; short-circuit so the log below is never //--- reached with a degenerate single-bin histogram. if(sorted[0] == sorted[n - 1]) return 0.0; int joint[]; ArrayResize(joint, MI_BINS * 3); ArrayInitialize(joint, 0); int px[]; ArrayResize(px, MI_BINS); ArrayInitialize(px, 0); int py[]; ArrayResize(py, 3); ArrayInitialize(py, 0); for(int i = 0; i < n; i++) { //--- rank via binary search on the sorted copy; ties land in the same bin, which is correct int lo = 0, hi = n - 1, rank = 0; while(lo <= hi) { int mid = (lo + hi) / 2; if(sorted[mid] < vals[i]) { rank = mid + 1; lo = mid + 1; } else hi = mid - 1; } int bx = (int)((double)rank * MI_BINS / n); if(bx >= MI_BINS) bx = MI_BINS - 1; int by = labels[i]; if(by < 0 || by > 2) continue; joint[bx * 3 + by]++; px[bx]++; py[by]++; } double mi = 0.0; for(int b = 0; b < MI_BINS; b++) { if(px[b] <= 0) continue; for(int c = 0; c < 3; c++) { int j = joint[b * 3 + c]; if(j <= 0 || py[c] <= 0) continue; double pxy = (double)j / n; mi += pxy * MathLog(pxy / (((double)px[b] / n) * ((double)py[c] / n))); } } return (mi > 0.0) ? mi : 0.0; } //+------------------------------------------------------------------+ //| Scores the CURRENT indicator parameters by how much the resulting | //| feature vector tells us about the label - the mean per-column | //| mutual information over a stratified sample of in-sample bars. | //| | //| Deliberately scores EVERY column, not just the ones belonging to | //| the parameter being swept. Columns the sweep did not touch | //| contribute the SAME amount to every candidate, so they shift the | //| mean by a constant and cannot change which candidate wins - while | //| avoiding any need for this code to know the feature-vector layout, | //| which is exactly the kind of coupling that rots. | //+------------------------------------------------------------------+ int CExpertSignalAIBase::BuildMiSample(double &cols[], int &labels[], int labelBarOffset = 0, int featureBarOffset = 0, int target = MI_TARGET_BARRIER) { //--- Continuous targets are collected raw here and discretised after the loop, because equal-frequency //--- binning needs the whole sample's distribution before any one row can be assigned a bin. double raw[]; bool continuousTarget = (target != MI_TARGET_BARRIER); int bars = m_labelCacheBars; if(bars <= 0 || m_neuronsCount <= 0) return -1; //--- Sample the IS region only. The OOS window must not influence which indicator settings ship, or //--- the holdout has been used for selection and stops being a holdout at all. int oosCutoff = (int)(MathMax(0, MathMin(100, m_oosSplitPct)) / 100.0 * MathMax(bars - MathMax(m_historyBars, 0), 0)); int lo = MathMax(oosCutoff, MathMax(m_barrierHorizonBars, 1) + 1); int hi = bars - MathMax(m_historyBars, 0) - 1; //--- Keep the OFFSET label lookup inside the same bounds as the features, so a shifted scan measures a //--- shift and not an edge effect. Widened symmetrically rather than clamping per bar, which would pile //--- several sample rows onto the same clamped label and manufacture association out of nothing. //--- THE PAD IS FIXED, NOT |labelBarOffset|. Two builds are only comparable row by row if they enumerate //--- the same bars with the same stride, and both `lo` and `stride` below are derived from this range - //--- so padding by the requested offset would move every row of the offset build. That is exactly what //--- broke the positive control: it paired row k of an unshifted build with row k of a build starting //--- `offset` bars later, whose label was then shifted a further `offset`, giving a pair 2*offset apart. //--- The measured consequence was a control that reported the MI of labels 48 bars apart while claiming //--- 24, failed its 5x gate, and voided every MI figure the EA printed. int shiftPad = MiShiftPad(); if(MathAbs(labelBarOffset) > shiftPad || MathAbs(featureBarOffset) > shiftPad) return -1; // caller asked for a shift the pad does not cover lo += shiftPad; hi -= shiftPad; if(hi - lo < MI_MIN_SAMPLES) return -1; int stride = (int)MathMax(1, (hi - lo) / MI_SAMPLE_BARS); //--- Published so the positive control can say how many BARS apart two sample rows are without //--- recomputing this arithmetic at the call site, where it would silently drift out of agreement. m_miStrideBars = stride; int cap = (hi - lo) / stride + 1; ArrayResize(cols, cap * m_neuronsCount); ArrayResize(labels, cap); if(continuousTarget) ArrayResize(raw, cap); int n = 0; for(int i = lo; i < hi && n < cap; i += stride) { //--- Features come from bar i; the LABEL may be taken from a neighbouring bar (labelBarOffset != 0) //--- so the caller can scan for a feature/label misalignment - see the alignment scan in //--- ReportFeatureLabelInformation(). Both bars must carry a valid label for the row to count. int li = i + labelBarOffset; if(i >= ArraySize(m_labelCacheHasValue) || !m_labelCacheHasValue[i]) continue; //--- The geometry scan asks "what WOULD this label be under a different barrier?", which by //--- definition is not in the cache. Compute it on the spot instead - the cache belongs to the //--- configured geometry and a scan must never write to it. if(!m_barrierScanLiveLabels && (li < 0 || li >= ArraySize(m_labelCacheHasValue) || !m_labelCacheHasValue[li])) continue; if(m_barrierScanLiveLabels && (li < MathMax(m_barrierHorizonBars, 1) || li >= bars)) continue; //--- BufferTempData(), NOT BufferTempDataCompute(). The Compute variant APPENDS the bar's features //--- to TempData and never touches m_featureCache - only the caching wrapper writes that array. The //--- first version of this function called Compute and then read m_featureCache, which //--- ReInitADIndicators had just invalidated, so every column read back constant, FeatureColumnMI //--- returned 0 for all of them, and all 17 candidates scored exactly 0.0000 nats. The tuner ran for //--- 139 s per chart and always reported "no improvement" - a silent no-op that looked like a //--- measurement. Read the values back out of TempData, which is where they actually land. //--- FEATURE-side shift, distinct from labelBarOffset and not interchangeable with it. Shifting the //--- LABEL changes which trade is being predicted, so at any non-zero offset the features sit INSIDE //--- the labelled window and the score is lookahead - which is exactly what the alignment scan //--- measures and correctly reports (4.7x more knowable 5 bars into a 128-bar window). Shifting the //--- FEATURES instead keeps the label pinned to the entry bar and asks the honest question: does the //--- state k bars BEFORE the entry still carry information about that entry's outcome? Positive k is //--- strictly older (higher series index), so every row stays causal. TempData.Clear(); if(!BufferTempData(i + featureBarOffset) || TempData.Total() < m_neuronsCount) continue; for(int f = 0; f < m_neuronsCount; f++) cols[n * m_neuronsCount + f] = TempData.At(f); if(continuousTarget) { //--- Excursions come from the cache only. The geometry scan's live-relabel path deliberately //--- does not feed them: excursions do not depend on SL/TP at all (see the accumulators in //--- TripleBarrierLabel), so re-deriving them per candidate geometry would compute the same //--- number repeatedly and invite the impression that it varies with the barrier. if(li >= ArraySize(m_excUpCache)) continue; double up = m_excUpCache[li]; double dn = m_excDownCache[li]; if(!MathIsValidNumber(up) || !MathIsValidNumber(dn)) continue; //--- A bar that TripleBarrierLabel() could not resolve (no valid ATR or close, typically the //--- oldest bars) is still flagged as having a label, but its excursions were cleared to zero //--- rather than measured. Price cannot genuinely travel zero in BOTH directions over a whole //--- horizon, so this is an unambiguous "not measured" marker. Dropping those rows matters more //--- than it looks: under EQUAL-FREQUENCY binning a block of identical zeros drags the lowest //--- cut point onto zero, and a third of the sample then lands in one bin carrying no //--- information - which would show up as a depressed score and read as "not predictable". if(up <= 0.0 && dn <= 0.0) continue; if(target == MI_TARGET_EXC_UP) raw[n] = up; else if(target == MI_TARGET_EXC_DOWN) raw[n] = dn; else if(target == MI_TARGET_EXC_RANGE) raw[n] = up + dn; else if(target == MI_TARGET_EXC_ASYM) raw[n] = up - dn; else { //--- Scale-free asymmetry. The denominator is > 0 here because rows with both //--- excursions zero were dropped above, so no guard is needed beyond that. raw[n] = (up - dn) / (up + dn); // MI_TARGET_EXC_ASYM_NORM } labels[n] = 0; // assigned below, once the distribution is known } else if(m_barrierScanLiveLabels) { ENUM_SIGNAL v = TripleBarrierLabel(li); if(v == Neutral && m_lastBarrierTimedOut) m_barrierScanTimeouts++; labels[n] = (v == Buy) ? 0 : ((v == Sell) ? 1 : 2); } else labels[n] = m_labelCacheBuy[li] ? 0 : (m_labelCacheSell[li] ? 1 : 2); n++; } TempData.Clear(); //--- EQUAL-FREQUENCY DISCRETISATION into the same 3 classes FeatureColumnMI's joint table expects, so //--- every downstream piece - the block permutation, the null, the p-value, the lag profile - works on //--- a continuous target with no change at all. Equal-frequency rather than equal-width because these //--- distributions are fat-tailed (MFE especially): fixed-width bins would put almost every row in the //--- first bin and measure nothing. It also fixes H(Y) at ln(3) = 1.099 nats for all four excursion //--- targets, which makes their scores directly comparable to each other AND to the barrier label's //--- ~1.02 - a comparison that would otherwise be confounded by class balance. if(continuousTarget && n > 0) { double sorted[]; ArrayResize(sorted, n); ArrayCopy(sorted, raw, 0, 0, n); ArraySort(sorted); double cut1 = sorted[n / 3]; double cut2 = sorted[(2 * n) / 3]; //--- A degenerate target (every value identical, e.g. a cache that never filled) would land every //--- row in one class and score a flat zero. Say so rather than reporting the zero as a finding. if(cut1 == cut2 && sorted[0] == sorted[n - 1]) { Print(ID + ": MI excursion target " + IntegerToString(target) + " is CONSTANT across all " + IntegerToString(n) + " sampled bars - the excursion cache did not fill. Treating as " "unusable rather than reporting its zero score as a measurement."); return -1; } for(int q = 0; q < n; q++) labels[q] = (raw[q] <= cut1) ? 0 : ((raw[q] <= cut2) ? 1 : 2); } return n; } //+------------------------------------------------------------------+ //| Score an already-extracted sample. Split out from the extraction | //| above so the permutation test can reuse ONE sample across every | //| draw: feature extraction dominates the cost, and re-running it | //| per shuffle is what would have made a few hundred permutations | //| unaffordable. The shuffle is in place and destructive, which is | //| harmless - composing permutations still yields a uniform | //| permutation, so successive draws stay independent - but it does | //| mean the OBSERVED (unshuffled) statistic must be taken first. | //+------------------------------------------------------------------+ double CExpertSignalAIBase::ScoreMiSample(const double &cols[], int &labels[], int n, bool shuffleLabels) { if(n < MI_MIN_SAMPLES) return -1.0; //--- PERMUTATION BASELINE. Mutual information estimated from finite samples is biased UPWARD - with //--- MI_BINS bins and 3 classes the bias is roughly (bins-1)(classes-1)/(2n) nats, which at these //--- sample sizes is the same order as any real edge in this domain. So a raw MI figure is //--- uninterpretable on its own: 0.004 nats could be a genuine weak signal or could be pure noise. //--- Shuffling the labels destroys every real association while leaving the sample size, the binning //--- and the class proportions untouched, so the score it produces IS this dataset's noise floor, //--- measured rather than approximated. Reporting the two together turns "0.0042 nats" into either //--- "0.0042 against a 0.0041 floor" (nothing) or "0.0042 against a 0.0009 floor" (something). //--- BLOCK permutation, not a free one, and the difference is the whole validity of the test. //--- Triple-barrier labels OVERLAP: two sample rows less than m_barrierHorizonBars apart share most of //--- their outcome window, so their labels are strongly dependent. A free Fisher-Yates shuffle destroys //--- that dependence as well as the feature/label association, which makes the null distribution far //--- NARROWER than the truth and hands out significance that isn't there. The 2026-08-01 symbol sweep //--- showed it in the raw: excess tracked the sampling STRIDE almost monotonically, and the three D1 //--- cells - where the stride had collapsed to 1-5 bars against a 128-bar horizon, i.e. ~99% window //--- overlap - returned 5-9x the "signal" of every H1 cell at p=0.005. That was label autocorrelation //--- leaking through an independence assumption, not an edge. It is Lopez de Prado ch. 4's non-IID //--- problem arriving through the back door of the significance test. //--- Permuting whole CONTIGUOUS BLOCKS at least one horizon long preserves the autocorrelation inside a //--- block while destroying any feature/label association across blocks - so the null keeps the //--- dependence structure and the p-value means what it says. It also degrades honestly: when overlap is //--- severe there are few blocks, the null is correspondingly wide, and nothing reaches significance, //--- which is the correct answer rather than a flattering one. if(shuffleLabels) { int blockRows = (m_miStrideBars > 0) ? (int)MathCeil((double)MathMax(m_barrierHorizonBars, 1) / m_miStrideBars) : 1; if(blockRows < 1) blockRows = 1; if(blockRows > n) blockRows = n; int blocks = (n + blockRows - 1) / blockRows; m_miNullBlocks = blocks; //--- Fisher-Yates over BLOCK ORDER; within-block order is left untouched, which is what preserves //--- the local dependence. Copied out rather than swapped in place because blocks are not //--- interchangeable in size - the last one is short whenever blockRows does not divide n. int order[]; ArrayResize(order, blocks); for(int b = 0; b < blocks; b++) order[b] = b; for(int b = blocks - 1; b > 0; b--) { int j = MathRand() % (b + 1); int t = order[b]; order[b] = order[j]; order[j] = t; } int shuffled[]; ArrayResize(shuffled, n); int w = 0; for(int b = 0; b < blocks && w < n; b++) { int src = order[b] * blockRows; for(int q = 0; q < blockRows && w < n; q++) { int s = src + q; shuffled[w++] = (s < n) ? labels[s] : labels[n - 1]; } } for(int i = 0; i < n; i++) labels[i] = shuffled[i]; } //--- H(Y) over the sampled labels, so the caller can express MI as a fraction of the information the //--- label actually contains. Computed AFTER any shuffle, which leaves it unchanged by construction //--- (a permutation preserves the class counts) - that invariance is itself a check on the shuffle. int classCount[3] = {0, 0, 0}; for(int k = 0; k < n; k++) classCount[labels[k]]++; m_miLabelEntropy = 0.0; for(int c = 0; c < 3; c++) { if(classCount[c] <= 0) continue; double pc = (double)classCount[c] / n; m_miLabelEntropy -= pc * MathLog(pc); } double colVals[]; ArrayResize(colVals, n); double total = 0.0; m_miBestColumn = 0.0; for(int f = 0; f < m_neuronsCount; f++) { for(int k = 0; k < n; k++) colVals[k] = cols[k * m_neuronsCount + f]; double mi = FeatureColumnMI(colVals, labels, n); total += mi; if(mi > m_miBestColumn) m_miBestColumn = mi; } return total / m_neuronsCount; } //+------------------------------------------------------------------+ //| Extract + score in one call - the form the coordinate sweep uses, | //| where each candidate genuinely needs a fresh extraction because | //| the indicator settings (and therefore the features) just changed. | //+------------------------------------------------------------------+ double CExpertSignalAIBase::ScoreCurrentParamsByMI(bool shuffleLabels = false) { double cols[]; int labels[]; int n = BuildMiSample(cols, labels); if(n < MI_MIN_SAMPLES) return -1.0; return ScoreMiSample(cols, labels, n, shuffleLabels); } //+------------------------------------------------------------------+ //| FILTER-BASED indicator tuning. Replaced the genetic + successive- | //| halving search on 2026-08-01. | //| | //| WHY THE GA HAD TO GO - measured, not assumed. Its cost was | //| population x generations x rungs x seeds x eras-per-rung: | //| rung 0: 8 cand x 3 seeds x 3 eras = 72 eras | //| rung 1: 4 cand x 3 seeds x 8 eras = 96 | //| rung 2: 2 cand x 3 seeds x 20 eras = 120 | //| = 288 eras per generation x 4 generations = 1152 eras | //| BEFORE the winner's real training started. Measured on SP500 H1: | //| 9.3 h for the perceptron, 13.2 h for conv, ~48 h for LSTM and | //| hybrid. Two days to tune is not a first-run experience. | //| | //| And it bought nothing. The space here is 90 points (10 MA periods | //| x 9 MA types), so 1152 evaluations revisited each point ~13 times; | //| meanwhile rungs of 3 and 8 eras cannot separate two MA periods at | //| all - the 2026-08-01 run's finalists all scored 25.0-25.9% | //| balanced accuracy, i.e. indistinguishable noise, and it then | //| deployed the "winner" of that. | //| | //| THE REAL ERROR was using a full training run as the scoring | //| function for a feature's period. The reference book does not: ch. | //| 3.3 selects inputs by measuring each candidate indicator's | //| CORRELATION with the target and dropping the ones with none, with | //| no network involved. Mutual information is the same idea without | //| the linearity assumption, which matters here because the label is | //| 3-class categorical and the features are not monotonically related | //| to it. Scoring is then arithmetic over cached features: seconds, | //| not hours, and it scales with the number of enabled features | //| rather than with topology cost - so LSTM tunes as fast as the MLP. | //| | //| COORDINATE SWEEP, not a product sweep: each parameter is optimised | //| against the others' current values, one at a time. Cost is the SUM | //| of the per-parameter candidate counts, not their product, so | //| enabling every indicator stays affordable. Two passes, because the | //| second can exploit what the first learned about the others; it | //| stops early the moment a pass changes nothing. | //| | //| HONEST LIMIT, stated because it is the price of the trade: MI is a | //| MARGINAL measure. It scores each feature column on its own, so a | //| parameter that only pays off in combination with another can be | //| missed. That is the standard filter-vs-wrapper tradeoff (Guyon & | //| Elisseeff 2003). Given the wrapper here was ranking pure noise at | //| 48 h a run, a fast marginal score is strictly the better deal. | //+------------------------------------------------------------------+ void CExpertSignalAIBase::TuneIndicatorsByFilter(void) { double best[]; m_indicatorTuner.Flatten(best); double bestScore = ScoreCurrentParamsByMI(); if(bestScore < 0.0) { Print(ID + ": auto-tune skipped - not enough labelled in-sample bars to score indicator settings"); return; } double startScore = bestScore; int evaluated = 0; uint t0 = GetTickCount(); //--- SPREAD OF THE CANDIDATE SCORES. Without it "no improvement" is ambiguous between two readings //--- that want opposite responses: INERT (trial scores identical to the incumbent because the //--- parameter change never reaches the scored features, so `sc > bestScore` can never fire) versus //--- LIVE and genuinely finding nothing. A spread of exactly zero says the first; a spread near the //--- estimator's own noise says the second - and then the winner needs the family-wise gate the //--- geometry scan and lag profile now carry, because installing a winner CHANGES THE FEATURE VECTOR //--- and forces a fresh topology, a far heavier consequence than a printed row. //--- //--- This measures the distinction directly, which is the point: the run-to-run evidence cannot settle //--- it. "No improvement" on four consecutive runs (2026-08-05/06, 17 candidates) looks damning if the //--- runs are treated as independent trials, but they are NOT - the scorer is deterministic and the //--- runs cover nearly the same bars, so an incumbent that is the maximum on this data is the maximum //--- on every run. That is one ~1-in-18 observation with three correlated repeats, not four of them. //--- Note also that the INERT failure has already happened once here in a different form and was //--- fixed (see the BufferTempData note in BuildMiSample: every candidate scored exactly 0.0000). //--- Non-zero scores now mean that particular fault is gone. double candMin = DBL_MAX, candMax = -DBL_MAX; int readyMin = INT_MAX; //--- The configured settings, kept so a winner that fails the gate below can be handed back. best[] is //--- mutated in place by the descent, so it cannot serve as the restore point. double configured[]; ArrayCopy(configured, best); for(int pass = 0; pass < MI_TUNE_PASSES; pass++) { bool improvedThisPass = false; for(int p = 0; p < AD_TUNE_PARAM_COUNT; p++) { //--- skip parameters whose indicator is switched off - they cannot affect the feature vector int owner = m_indicatorTuner.ParamOwner(p); bool on = (owner == 0 && m_useADCumulativeDelta) || (owner == 1 && m_useADShorteningOfThrust) || (owner == 2 && m_useADWyckoffEventStream) || (owner == 3 && m_useADWyckoffFailedStructure) || (owner == 4 && m_useADWyckoffSignificantBarInversion) || (owner == 5 && m_useMA) || (owner == 6 && m_useRSI) || (owner == 7 && m_useMACD) || (owner == 8 && m_useIchimoku); if(!on) continue; double cands[]; int nc = m_indicatorTuner.ParamCandidates(p, cands); double keep = best[p]; for(int c = 0; c < nc; c++) { if(cands[c] == keep) continue; // already scored as the incumbent double trial[]; ArrayCopy(trial, best); trial[p] = cands[c]; m_indicatorTuner.Unflatten(trial); ReInitADIndicators(m_indicatorsPtr); // also invalidates the feature cache (params changed) //--- REFRESH, or the re-init changes nothing that the scorer can see. ReInitADIndicators //--- creates a NEW handle carrying the new parameters and flags the feature cache stale, so //--- features are genuinely recomputed - but BufferTempDataCompute() reads the CIndicatorBuffer //--- objects, and only Refresh() copies data out of a handle into those. Without this the //--- buffers still hold values copied from the PREVIOUS handle, so every candidate is scored on //--- identical features. Measured on SP500 H1 2026-08-07: all 17 candidates returned exactly //--- 0.00359 nats, a candidate-score span of 0.00000. RefreshData(); int ready = TunableBarsCalculated(); if(ready >= 0) readyMin = (int)MathMin(readyMin, ready); double sc = ScoreCurrentParamsByMI(); evaluated++; if(sc >= 0.0) { candMin = MathMin(candMin, sc); candMax = MathMax(candMax, sc); } if(sc > bestScore) { bestScore = sc; keep = cands[c]; improvedThisPass = true; } } best[p] = keep; } if(!improvedThisPass) break; // coordinate descent has converged - further passes cannot move anything } //--- SELECTION GATE. bestScore is a MAXIMUM over every candidate scored, so it carries the same defect //--- the barrier-geometry winner test and the lag profile were fixed for: the maximum of N draws from a //--- null sits well above any single draw, and installing on "it beat the incumbent" alone crowns noise. //--- The stakes here are higher than either of those, because this one ACTS - it replaces the user's //--- deliberate indicator settings and forces BuildFreshTopology(), so the network then trains on //--- whatever the noise picked. //--- //--- Test: draw the winner's own permutation null once (the sample is extracted once and every draw //--- reshuffles it - see ScoreMiSample), take the per-candidate p, then correct it for having CHOSEN //--- this candidate out of N with Sidak: p_family = 1 - (1 - p)^N. Sidak rather than an explicit //--- max-of-N resample because each candidate here has a DIFFERENT feature set, so their draws cannot //--- be pooled the way the geometry scan's can; Sidak needs only the one null and is exact under //--- independence, mildly anti-conservative under positive dependence - stated rather than hidden. //--- //--- WHAT THIS DOES NOT ESTABLISH: that the winner beats the INCUMBENT by a significant margin. It //--- bounds the "best of N noise draws" failure, which is the one that was actually live here. Requiring //--- bestScore > startScore as well means a change needs both an improvement and a defensible signal. bool install = (bestScore > startScore); double pFamily = 1.0; int distinct = (int)MathMax(evaluated + 1, 1); // candidates scored, plus the incumbent if(install) { double wc[]; int wl[]; int wn = BuildMiSample(wc, wl); if(wn >= MI_MIN_SAMPLES) { double obs = ScoreMiSample(wc, wl, wn, false); int atLeast = 0, draws = 0; for(int s = 0; s < MI_NOISE_PERMUTATIONS; s++) { double d = ScoreMiSample(wc, wl, wn, true); if(d < 0.0) continue; if(d >= obs) atLeast++; draws++; } if(draws > 0) { double pSingle = (double)(1 + atLeast) / (draws + 1); pFamily = 1.0 - MathPow(1.0 - pSingle, (double)distinct); } } install = (pFamily <= MI_TUNE_ALPHA); } if(!install) { ArrayCopy(best, configured); bestScore = startScore; } //--- install the winner and leave the indicators/feature cache consistent with it m_indicatorTuner.Unflatten(best); ReInitADIndicators(m_indicatorsPtr); RefreshData(); double candSpread = (evaluated > 0 && candMax >= candMin) ? (candMax - candMin) : 0.0; Print(ID + StringFormat(": auto-tune complete - %d candidate settings scored in %.1fs, " "feature/label mutual information %.5f -> %.5f nats%s | candidate scores span " "%.5f (%.5f..%.5f)%s", evaluated, (GetTickCount() - t0) / 1000.0, startScore, bestScore, (bestScore <= startScore ? " (no improvement - keeping the configured settings)" : ""), candSpread, (evaluated > 0 ? candMin : 0.0), (evaluated > 0 ? candMax : 0.0), (evaluated > 0 && candSpread <= 0.0 ? StringFormat(" <-- ZERO SPREAD: every candidate scored identically, so the " "parameter change is STILL not reaching the scored features even " "with the post-re-init RefreshData(). Least-ready tunable handle " "had %d bars calculated - if that is 0 or far below the study " "window, the handles are simply not done calculating yet and the " "tuner needs to yield between candidates rather than score them " "back to back.", (readyMin == INT_MAX ? -1 : readyMin)) : StringFormat(" | winner %s (selection p=%.4f after correcting for %d " "candidates, need <=%.2f)", (install ? "INSTALLED" : "REJECTED - keeping the configured " "settings, since the best of N noise draws beats its incumbent " "almost every time"), pFamily, distinct, MI_TUNE_ALPHA)))); //--- An EXACTLY zero score is not a weak feature set, it is a broken measurement. Mutual information //--- estimated from finite samples is biased UPWARD - roughly (bins-1)(classes-1)/(2N) nats, ~0.0035 //--- here - so even columns of pure noise score above zero. Landing on 0.0000 means every column read //--- back constant, which is what a feature-extraction fault looks like. Said out loud because the //--- first version of this function did exactly that and reported it as "no improvement". if(bestScore <= 0.0) Print(ID + ": WARNING - every candidate scored 0.0000 nats. Finite-sample bias alone should put " "noise above zero, so this indicates the feature values are not being read, not that the " "features are uninformative. Indicator settings left at their configured values."); ReportFeatureLabelInformation(); } //+------------------------------------------------------------------+ //| "Do these features predict this label at all?" - answered without | //| training, topology or convergence, so unlike every accuracy | //| number in this codebase it cannot be confounded by an optimizer | //| or an objective. | //| | //| DELIBERATELY SEPARATE FROM THE TUNER, and not gated on era 0 with | //| it. The sweep must only run on a fresh model - re-tuning would | //| change the input vector out from under weights already fitted to | //| the old one - but this reads the same cached features and writes | //| nothing, so tying it to that gate meant the only way to see the | //| answer was to bin a model mid-run (45 trained eras, on 2026-08-01) | //| purely to re-ask a read-only question. Runs once per attach. | //+------------------------------------------------------------------+ void CExpertSignalAIBase::ReportFeatureLabelInformation(void) { m_miReportDone = true; //--- PERMUTATION TEST, done properly. Build the current settings' sample ONCE, take the observed //--- statistics from it, then reuse that same sample for every null draw - extraction is the expensive //--- part, so this makes a few hundred permutations cost about what five used to. //--- //--- Five was not enough, and the 2026-08-01 log is the proof: all four charts scored the IDENTICAL //--- 0.00401 nats on identical features and identical labels, yet reported z of +1.3, +2.0, +4.0 and //--- +4.7 - two "at the noise floor", two "real". The whole swing came from estimating the null's spread //--- from five draws, where the standard deviation of the standard-deviation estimate is ~35%. The //--- denominator was noisier than the effect. //--- //--- So: no z-score and no normality assumption. An EMPIRICAL p-value, counting how many null draws //--- reached the observed value, with the +1/(B+1) correction (Phipson & Smyth 2010) that keeps p from //--- ever being reported as exactly zero - the test can only ever bound p below by 1/(B+1). double cols[]; int labels[]; int nSample = BuildMiSample(cols, labels); double observed = (nSample >= MI_MIN_SAMPLES) ? ScoreMiSample(cols, labels, nSample, false) : -1.0; double signalBestCol = m_miBestColumn; double labelEntropy = m_miLabelEntropy; double floorSum = 0.0, floorSumSq = 0.0, floorBestColSum = 0.0; int draws = 0, atLeastMean = 0, atLeastBestCol = 0; uint tPerm = GetTickCount(); for(int s = 0; observed >= 0.0 && s < MI_NOISE_PERMUTATIONS; s++) { double sc = ScoreMiSample(cols, labels, nSample, true); if(sc < 0.0) continue; floorSum += sc; floorSumSq += sc * sc; floorBestColSum += m_miBestColumn; if(sc >= observed) atLeastMean++; //--- The MAX over columns is compared against the null distribution OF THE MAX, which corrects for //--- testing 26 features at once by construction - no Bonferroni needed, and far less conservative. if(m_miBestColumn >= signalBestCol) atLeastBestCol++; draws++; } double floorMean = (draws > 0) ? floorSum / draws : -1.0; double floorVar = (draws > 1) ? MathMax(0.0, floorSumSq / draws - floorMean * floorMean) : 0.0; double floorSd = MathSqrt(floorVar * (draws > 1 ? (double)draws / (draws - 1) : 1.0)); double floorBestCol = (draws > 0) ? floorBestColSum / draws : -1.0; double pMean = (draws > 0) ? (double)(1 + atLeastMean) / (draws + 1) : 1.0; double pBestCol = (draws > 0) ? (double)(1 + atLeastBestCol) / (draws + 1) : 1.0; //--- Two SEPARATE questions, because at these sample sizes a small p can accompany a worthless effect. //--- (1) Is it real - the p-values. (2) Is it big enough to trade - the excess as a share of H(Y), i.e. //--- of everything there is to know about the label. Both are printed; neither is collapsed into a verdict //--- that hides the other. double excessShare = (labelEntropy > 1e-9 && floorMean >= 0.0) ? 100.0 * (observed - floorMean) / labelEntropy : 0.0; string verdict = (draws > 0 && pMean <= 0.05) ? "above the noise floor - a real association" : "AT THE NOISE FLOOR - indistinguishable from shuffled labels"; //--- Name the feature vector this was measured on. These numbers are only about the model if the two //--- match, and on 2026-08-02 they did not: the report ran before the cross-asset panel existed and //--- silently described a narrower vector than training used. Stating the width and the panel's //--- presence makes that mismatch visible in the log instead of requiring a timestamp comparison. string vecNote = StringFormat("%d features/bar, cross-asset %s", m_neuronsCount, m_crossAsset.IsReady() ? "PRESENT" : "ABSENT (reference symbols unsynchronised - these numbers describe " "a NARROWER vector than training will use)"); Print(ID + StringFormat(": feature/label information - %.5f nats/feature vs a shuffled-label null of " "%.5f +/- %.5f over %d permutations, p=%.4f; strongest single feature %.5f vs " "%.5f (null max, p=%.4f); excess is %.2f%% of the label's %.3f nats of entropy " "(%d samples %d bars apart = %d independent blocks over a %d-bar horizon, " "%.1fs) [%s]. %s.", observed, floorMean, floorSd, draws, pMean, signalBestCol, floorBestCol, pBestCol, excessShare, labelEntropy, nSample, m_miStrideBars, m_miNullBlocks, m_barrierHorizonBars, (GetTickCount() - tPerm) / 1000.0, vecNote, verdict)); //--- POWER, stated up front. The block permutation above makes the p-value HONEST under overlapping //--- labels, but it cannot manufacture information that overlap destroyed: when the sampling stride is //--- far shorter than the horizon there are few genuinely independent blocks, and a handful of blocks //--- cannot resolve an effect this small however many rows they contain. Saying so prevents the opposite //--- error to the one this replaced - reading "not significant" as "no signal" when it means "not enough //--- independent data to tell". if(m_miNullBlocks > 0 && m_miNullBlocks < 30) Print(ID + StringFormat(": NOTE - only %d independent label blocks in this sample (%d-bar horizon, " "%d-bar sampling stride). The rows overlap heavily, so this test has little " "power: treat a non-significant result here as 'not enough independent " "history to answer', not as 'no signal'. More history, or a shorter horizon, " "is what would settle it.", m_miNullBlocks, m_barrierHorizonBars, m_miStrideBars)); //--- Stated every time, not only on a bad result: this measure is MARGINAL and PER-BAR, while the network //--- reads m_historyBars bars at once. It can therefore only ever prove that signal EXISTS, never that it //--- does not - an interaction across features or across time is invisible to it by construction. Said //--- out loud so a floor-level reading is not over-read into "this instrument is unpredictable". if(!(draws > 0 && pMean <= 0.05)) Print(ID + ": NOTE - that measure is marginal (one feature at a time) and per-bar, whereas the " "network sees " + IntegerToString((int)m_historyBars) + " bars jointly. A floor-level reading " "rules out a simple per-feature edge; it cannot rule out one that only exists in combination " "or across time. It does mean no per-feature indicator retuning will help."); if(observed < 0.0) return; //--- POSITIVE CONTROL. Three separate "measurements" in this codebase have turned out to be silent //--- no-ops that produced plausible numbers (the MI scorer reading an array nobody filled; the //--- eval-mode guard that switched off the imbalance correction; the alternation gate whose premise was //--- never true). A floor reading is therefore worthless until the instrument is shown to respond to a //--- signal that is KNOWN to be there. This one is free: the label of a NEIGHBOURING sample row. Rows are //--- `stride` bars apart, far inside the barrier horizon, so their outcome windows overlap heavily and //--- the two labels must be strongly associated. Fed through the identical binning and estimator as every //--- other column. If THIS lands near the floor, the estimator is broken and no MI number above means //--- anything; if it lands far above, a floor reading on the real features can be believed. //--- The control pairs each row's label with the label of a bar a FIXED, KNOWN distance away, so the two //--- outcome windows overlap heavily and must be strongly associated. //--- THIS CONTROL HAS NOW CRIED WOLF TWICE, AND BOTH TIMES THE ESTIMATOR WAS INNOCENT. //--- 2026-08-01 it paired with the NEXT SAMPLE ROW, whose distance is the sampling stride - and stride //--- varies with how much history a symbol has, so the control's strength varied with the //--- cell rather than with the estimator. All three M5 cells (stride 160-717 bars against a //--- 128-bar horizon, i.e. windows that do not overlap AT ALL) voided their own results. //--- 2026-08-02 the range was padded by |offset|, which moved the offset build's FIRST BAR as well as //--- its label, so row k of one build sat `offset` bars from row k of the other and the //--- label was shifted a further `offset`: the pair was 2x as far apart as reported. On //--- SP500 H1 it printed 0.00307 nats for "24 bars apart" - which is the true value for 48 //--- bars - failed its 5x gate, and stamped "every mutual-information figure above is void" //--- on measurements that were fine. Confirmed by computing the same quantity independently //--- in research/test_mi_control.py: 0.01655 at 24 bars, 0.00298 at 48. //--- The lesson both share: A CONTROL THAT DEPENDS ON THE THING IT CERTIFIES CANNOT CERTIFY IT. Pin the //--- control's distance to something the data cannot move, and make it a distance where the association //--- is overwhelming rather than marginal - hence the adjacent bar below. //--- TWO distances, and the GATE is the adjacent bar. Its barrier window overlaps the reference one by //--- (h-1)/h, so "these must be associated" is unarguable, and unlike a horizon-relative offset it does //--- not vary with the horizon, the stride or the symbol. The quarter-horizon figure is kept as a //--- DIAGNOSTIC because it says something the gate cannot: how fast a triple-barrier label decorrelates. //--- Measured independently on SP500 H1 (research/test_mi_control.py, 74k bars): 0.542 nats at 1 bar, //--- 0.017 at 24, 0.003 at 48, against a ~0.002 floor. Note what that means - a quarter-horizon control //--- clears a 5x gate by under 2x even when everything is working, which is far too little headroom for //--- the one measurement whose job is to certify all the others. double controlMi = -1.0, decorrMi = -1.0; int controlBars = 1; int decorrBars = MathMax(1, MathMax(m_barrierHorizonBars, 1) / 4); { //--- Rebuilt rather than reused because the permutation loop above destroyed the honest label //--- ordering, and controlling against a shuffled array would measure the floor twice. double c0[], cK[]; int l0[], lK[]; int n0 = BuildMiSample(c0, l0); if(n0 >= MI_MIN_SAMPLES) { int offs[2]; offs[0] = controlBars; offs[1] = decorrBars; for(int oi = 0; oi < 2; oi++) { int nK = BuildMiSample(cK, lK, offs[oi]); //--- Both builds are padded by the SAME fixed amount, so they enumerate the same bars with //--- the same stride and row k of one is row k of the other. Sized from what actually came //--- back, never from the caller's count. int nc = MathMin(n0, nK); if(nc < MI_MIN_SAMPLES) continue; double neighbourLabel[]; int selfLabels[]; ArrayResize(neighbourLabel, nc); ArrayResize(selfLabels, nc); for(int k = 0; k < nc; k++) { selfLabels[k] = l0[k]; neighbourLabel[k] = (double)lK[k]; } double v = FeatureColumnMI(neighbourLabel, selfLabels, nc); if(oi == 0) controlMi = v; else decorrMi = v; } } } Print(ID + StringFormat(": MI positive control - the ADJACENT bar's label (windows overlap %d of %d bars) " "scores %.5f nats against the ~%.5f noise floor; by a quarter horizon (%d bars) " "it is already down to %.5f, which is how fast this target decorrelates. %s", MathMax(m_barrierHorizonBars, 1) - 1, MathMax(m_barrierHorizonBars, 1), controlMi, floorMean, decorrBars, decorrMi, (controlMi > floorMean * 5.0) ? "The estimator detects a known association on this exact data, so a " "floor-level reading above is a real finding and not a broken measurement." : "WARNING - the estimator FAILED to detect an association that must be there. " "Every mutual-information figure above is void; fix this before drawing any " "conclusion from them.")); //--- ALIGNMENT SCAN. A floor reading has two very different causes: the features genuinely do not predict //--- this target, or they DO and something upstream has knocked the two out of step (an off-by-one in the //--- label index, a horizon applied to the wrong bar, a feature window that lags what it claims). Both //--- destroy the information before any topology sees it, and both look identical in every accuracy number //--- this EA prints - which is exactly why four different architectures all landed on the same precision. //--- Re-scoring against the label taken from bar i+k separates them: a peak at some k != 0 IS a //--- misalignment (and names its size), a flat profile says the features simply do not carry this target. //--- THE TWO DIRECTIONS ARE NOT SYMMETRIC, and the first version of this scan treated them as if they //--- were - it read the k>0 rise as a misalignment and cried "fix this before concluding anything", //--- which was a false alarm produced by the diagnostic's own design. //--- //--- Bar indices here are MQL5 SERIES indices: HIGHER index = OLDER bar (TripleBarrierLabel walks its //--- window with `for(t = idx-1; t >= idx-horizon; t--)`, i.e. decreasing index = forward in time). //--- So: //--- k < 0 the label belongs to a NEWER bar, whose barrier window opens AFTER the features exist. //--- Nothing at bar i can legitimately know it. A peak here is real LOOKAHEAD and is a bug. //--- k > 0 the label belongs to an OLDER bar, whose window is already k bars into its life by the //--- time bar i happens - so the features at bar i legitimately contain the realised first k //--- bars of that outcome. MI MUST rise with k. That is arithmetic, not a defect. //--- Only the k<0 side can indict the pipeline. The k>0 side is a second positive control, and its //--- GRADIENT is the useful number: it says how fast a barrier outcome becomes knowable once the window //--- is running, against how little is knowable at entry (k=0). int offsets[] = { -5, -3, -2, -1, 0, 1, 2, 3, 5 }; string profile = ""; double atZero = -1.0, worstFuture = -1.0, farPast = -1.0; int worstFutureK = 0; for(int oi = 0; oi < ArraySize(offsets); oi++) { double oc[]; int ol[]; int on = BuildMiSample(oc, ol, offsets[oi]); double os = (on >= MI_MIN_SAMPLES) ? ScoreMiSample(oc, ol, on, false) : -1.0; profile += StringFormat("%s%+d:%.5f", (oi > 0 ? " " : ""), offsets[oi], os); if(offsets[oi] == 0) atZero = os; else if(offsets[oi] < 0 && os > worstFuture) { worstFuture = os; worstFutureK = offsets[oi]; } else if(offsets[oi] > 0) farPast = os; // offsets ascend, so this ends on the largest k } //--- A MARGIN, not a bare comparison. Every one of these offsets is an estimate with the same noise as //--- the headline statistic, so "k=-3 came out above k=0" is meaningless when the gap is smaller than the //--- null's own spread. Shipped without this, the 2026-08-01 sweep flagged LOOKAHEAD on 7 of 12 cells on //--- gaps of 0.00008-0.00040 nats against a measured null sd of ~0.00030 - all noise, every one. Three //--- SDs is the same discipline the deploy floor already applies to precision: an anomaly has to clear //--- the measurement error before it gets a name. (Third time this session that comparing two point //--- estimates without their spread produced a confident wrong answer - see MI_NOISE_PERMUTATIONS.) double lookaheadMargin = 3.0 * floorSd; string alignVerdict; if(worstFuture > atZero + lookaheadMargin) alignVerdict = StringFormat(" | LOOKAHEAD - k=%d (a label whose barrier window opens AFTER these " "features exist) scores %.5f against %.5f at k=0, clearing the %.5f " "margin (3 sd of the null). The features can only score there by " "containing future information. Fix that before trusting any accuracy " "number this EA prints.", worstFutureK, worstFuture, atZero, lookaheadMargin); else alignVerdict = StringFormat(" | clean: no future label (k<0) beats k=0, so there is no lookahead. " "The rise on the k>0 side is expected - those windows are already open, " "so the features hold part of the answer - and its size is the finding: " "%.5f at k=+5 against %.5f at k=0, i.e. ~%.1fx more is knowable %d bars " "into a %d-bar window than at the entry the model actually trades.", farPast, atZero, (atZero > 1e-9 ? farPast / atZero : 0.0), 5, m_barrierHorizonBars); Print(ID + ": MI label-alignment scan (label from bar i+k; higher index = OLDER bar, so k<0 is the " "future) - " + profile + alignVerdict); ReportFeatureLagProfile(); //--- Runs after the lag profile and before the geometry scan on purpose: the geometry scan chooses //--- among SL/TP pairings, and this asks whether predicting SL/TP is a well-posed problem at all. //--- Reading them in that order stops a geometry winner from being interpreted as evidence that the //--- exit is learnable. ReportExcursionInformation(); ReportBarrierGeometryScan(); } //+------------------------------------------------------------------+ //| WHICH BARRIER GEOMETRY IS ACTUALLY PREDICTABLE AT ENTRY. | //| | //| The alignment scan established the shape of the problem: 4.7x more | //| is knowable 5 bars into a 128-bar window than at the entry the | //| model trades on. A 6xATR target reached over 128 bars is decided | //| overwhelmingly by what happens DURING the window, so whatever the | //| entry state knows is buried under 128 bars of subsequent noise. | //| That is a property of the TARGET, and no topology can undo it - | //| which is why four different architectures all landed on precision | //| exactly equal to the base rate. | //| | //| So measure the target instead of guessing at it. For each SL/TP | //| pairing the user can actually select, relabel the same sampled | //| bars and score how much the SAME features say about THAT outcome. | //| Seconds, no training, no topology. | //| | //| RANKED ON EXCESS OVER ITS OWN NULL, IN NATS. The first version | //| divided that by the geometry's own H(Y), reasoning that each label | //| has a different amount of information available to find. That was | //| backwards and it produced a wrong answer on the first run: it | //| named 3:10, whose horizon is CLAMPED (it wants ~320 bars and gets | //| BARRIER_HORIZON_MAX), so most trades never resolve, Neutral | //| dominates, H(Y) collapses - and dividing by a collapsing | //| denominator made the most degenerate label look like the most | //| predictable one. Subtracting each geometry's own measured null | //| already removes the class-balance bias, which is the only thing | //| the normalisation was needed for. | //| | //| A clamped geometry is DISQUALIFIED outright, not merely ranked | //| down. The deployed EA holds until SL or TP with no bar limit, so a | //| truncated label trains the model on a question the strategy never | //| asks. Directional share is printed for the same reason: a label | //| nobody can trade is not a candidate however well it scores. | //| | //| What it cannot tell you: chance precision equals the break-even | //| win rate at every geometry (both are m/(m+k) under a driftless | //| walk), so a tighter target does NOT buy expectancy on its own. It | //| buys PREDICTABILITY - a shorter window has less noise piled on top | //| of what the entry state knows. The ranking finds where the signal | //| is largest; it is still on the model to convert it. | //+------------------------------------------------------------------+ //+------------------------------------------------------------------+ //| HOW FAR BACK THE FEATURES STILL SAY ANYTHING - see the declaration.| //| | //| Returns the deepest lag whose score clears the null, or 0 when | //| none does. Read-only; the caller decides what to do with it. | //| | //| The null is redrawn PER LAG rather than measured once and reused. | //| Finite-sample MI bias depends on the realised class counts and the | //| bin occupancy, and both move with the lag because different rows | //| survive the validity checks - so a single shared floor would be | //| the right number for lag 0 and the wrong one everywhere else. | //| Cost is the reason it is a REDUCED draw count: a full | //| MI_NOISE_PERMUTATIONS sweep at every lag is 200 x historyBars | //| scorings. The gate below is deliberately crude for the same | //| reason - this profile decides a LOOKBACK, not a trade. | //+------------------------------------------------------------------+ //+------------------------------------------------------------------+ //| IS "OPTIMAL SL/TP" LEARNABLE? Scores the same features against | //| four excursion targets instead of the barrier class. | //| | //| The question this exists to settle: predicting an optimal stop and | //| target decomposes into HOW FAR price travels and WHICH WAY it goes | //| first, and those two behave nothing alike. Excursion SIZE is a | //| volatility question, and volatility clustering is one of the most | //| robust regularities in markets - RANGE is included precisely as a | //| positive control that SHOULD clear, and a run where it does not is | //| evidence the measurement is broken rather than that the market is | //| unpredictable. ASYMMETRY is direction wearing different clothes, | //| and it is the only one of the four that can produce expectancy. | //| | //| So the informative outcome is the CONTRAST, not any single number: | //| RANGE/UP/DOWN clearing while ASYM sits at the floor says size is | //| predictable and order is not - i.e. the payoff of a predicted | //| SL/TP is position sizing and drawdown control, not edge. That is | //| worth having under prop-firm limits, and it is not a signal. | //| Exit management on RANDOM entries already moved the payoff ratio | //| 0.92 -> 5.72 with expectancy FLAT, so this would agree with a test | //| that has already been run a different way. | //| | //| Why this is not answered by the existing verdicts: every MI figure | //| this project has produced scored the TRIPLE-BARRIER label, which | //| is one specific question ("does the target come before the stop at | //| this fixed geometry"). A noise-floor result there says nothing | //| about whether excursion MAGNITUDE is learnable - different target, | //| different answer, and worth measuring before rebuilding a head. | //+------------------------------------------------------------------+ void CExpertSignalAIBase::ReportExcursionInformation(void) { int targets[] = { MI_TARGET_EXC_RANGE, MI_TARGET_EXC_UP, MI_TARGET_EXC_DOWN, MI_TARGET_EXC_ASYM, MI_TARGET_EXC_ASYM_NORM }; string names[] = { "RANGE up+dn (volatility control)", "UP (MFE)", "DOWN (MAE)", "ASYMMETRY up-dn (RAW - confounded by volatility, read the NORM line instead)", "ASYMMETRY NORMALISED (up-dn)/(up+dn) (THE ONE THAT MATTERS)" }; bool asymCleared = false, sizeCleared = false, rawAsymCleared = false; for(int k = 0; k < ArraySize(targets); k++) { double cols[]; int labels[]; int n = BuildMiSample(cols, labels, 0, 0, targets[k]); if(n < MI_MIN_SAMPLES) { Print(ID + ": MI excursion - " + names[k] + ": not enough usable bars to score"); continue; } double observed = ScoreMiSample(cols, labels, n, false); if(observed < 0.0) continue; double floorSum = 0.0; int draws = 0, atLeast = 0; for(int s = 0; s < MI_NOISE_PERMUTATIONS; s++) { double sc = ScoreMiSample(cols, labels, n, true); if(sc < 0.0) continue; floorSum += sc; if(sc >= observed) atLeast++; draws++; } if(draws <= 0) continue; double floorMean = floorSum / draws; double p = (double)(1 + atLeast) / (draws + 1); bool clears = (p <= MI_LAG_ALPHA); //--- H(Y) is ln(3) by construction (equal-frequency bins), so excess-as-a-share-of-entropy is //--- comparable across all four targets and against the barrier label's own figure. Print(ID + StringFormat(": MI excursion - %s: %.5f nats/feature vs a block-permuted null of %.5f, " "p=%.4f over %d draws%s | %.2f%% of the target's %.3f nats (%d samples)", names[k], observed, floorMean, p, draws, (clears ? " <-- CLEARS" : ""), 100.0 * (observed - floorMean) / MathLog(3.0), MathLog(3.0), n)); if(targets[k] == MI_TARGET_EXC_ASYM_NORM) asymCleared = clears; // the ONLY one a directional claim may rest on else if(targets[k] == MI_TARGET_EXC_ASYM) rawAsymCleared = clears; else if(clears) sizeCleared = true; } //--- The verdict is the CONTRAST. Spelled out rather than left to be read off five numbers, because //--- the wrong reading of "UP clears" is "we can predict profitable trades", and that is precisely //--- the inference this report exists to prevent. //--- //--- ORDER MATTERS, and the first version had it wrong: the generic size-not-direction branch was //--- tested first, and it is true whenever size clears - i.e. always - so the CONFOUND branch was //--- unreachable. Measured 2026-08-07 across three symbols: raw asymmetry cleared on all three while //--- normalised collapsed on all three, and the one message that explains why never printed. if(asymCleared) Print(ID + ": MI excursion VERDICT - NORMALISED ASYMMETRY CLEARS. Scale-free directional " "information survives dividing the volatility out, which no barrier-label test has ever " "found and which the raw asymmetry could not have established on its own. Before acting: " "replicate on instruments NOT used to find it, and check the effect is not concentrated in " "one volatility regime. If it holds, this is the first real signal here."); else if(rawAsymCleared) Print(ID + ": MI excursion VERDICT - raw asymmetry cleared but the NORMALISED one did not. That " "is the signature of the VOLATILITY CONFOUND, not of direction: up-dn scales with sigma, " "so a predictable sigma pushes the value into both outer bins and scores while carrying no " "directional content at all - and it does so on every instrument, so replication does not " "argue against it. Read the raw line as a restatement of RANGE. Excursion SIZE is " "predictable and worth using for position sizing and drawdown control; DIRECTION is not, " "so no SL/TP head can create expectancy. Agrees with the random-entry exit test (payoff " "ratio 0.92->5.72, expectancy flat)."); else if(sizeCleared) Print(ID + ": MI excursion VERDICT - excursion SIZE is predictable, DIRECTION is not. A model " "trained to output SL/TP will therefore learn volatility, which is real and useful for " "position sizing and drawdown control, but it CANNOT create expectancy: knowing the " "next leg spans 3 ATR is worth nothing without knowing which side it spans first. " "Agrees with the random-entry exit test (payoff ratio 0.92->5.72, expectancy flat). " "Build the head for risk control and stop looking for edge in the exit."); else Print(ID + ": MI excursion VERDICT - NOTHING clears, INCLUDING the range control. Volatility " "clustering is about the most robust regularity in markets, so a range target at the " "noise floor points at the measurement, not the market - check the excursion cache " "filled and that the sample is not dominated by one volatility regime."); } //+------------------------------------------------------------------+ int CExpertSignalAIBase::ReportFeatureLagProfile(void) { int maxLag = (int)MathMin(MathMax(m_historyBars, 0), MI_LAG_MAX_PROFILE - 1); if(maxLag <= 0) return 0; //--- Per-lag draws retained for the SAME reason the geometry scan retains its own: this report reads a //--- profile of ~20 lags, so "does lag k clear ITS OWN null" is the wrong question at every k. See the //--- family-wise block below. double lagDraws[MI_LAG_MAX_PROFILE][MI_LAG_PERMUTATIONS]; double lagExcess[MI_LAG_MAX_PROFILE]; int lagCount[MI_LAG_MAX_PROFILE]; bool lagValid[MI_LAG_MAX_PROFILE]; double atZero = 0.0; for(int k = 0; k <= maxLag; k++) { lagValid[k] = false; lagExcess[k] = 0.0; lagCount[k] = 0; double cols[]; int labels[]; int n = BuildMiSample(cols, labels, 0, k); if(n < MI_MIN_SAMPLES) continue; double observed = ScoreMiSample(cols, labels, n, false); if(observed < 0.0) continue; //--- ScoreMiSample shuffles IN PLACE, so the observed statistic must be taken first (above) and the //--- draws then reuse the same extracted sample - which is what makes this affordable at all. double floorSum = 0.0; int draws = 0; for(int s = 0; s < MI_LAG_PERMUTATIONS; s++) { double sc = ScoreMiSample(cols, labels, n, true); if(sc < 0.0) continue; floorSum += sc; lagDraws[k][draws] = sc; draws++; } if(draws < 2) continue; lagExcess[k] = observed - (floorSum / draws); lagCount[k] = draws; lagValid[k] = true; if(k == 0) atZero = lagExcess[k]; } //--- FAMILY-WISE CORRECTION ACROSS LAGS. The first version of this report tested each lag against its //--- own null at alpha=0.05 across ~21 lags, which is one expected false positive per run before any //--- signal exists - and correlated features make them arrive in CLUSTERS that read like a hump. It //--- did exactly that on SP500 H1: 2026-08-06 13:55 starred nothing, 16:22 starred k6/k10/k12/k16 and //--- concluded "information survives to lag 16" - same instrument, same 31 features, same 2009 samples, //--- while the headline MI moved the other way (p 0.4478 -> 0.8756, observed BELOW its null mean). //--- Non-replication on identical data is the signature of an uncorrected multiple comparison. //--- //--- So the bar is the null OF THE MAXIMUM over lags, exactly as the barrier-geometry winner test does //--- over candidates: one draw from every lag, keep the largest, repeat. A lag clears only by beating //--- that. Draws are centred leave-one-out so each is centred by a mean excluding itself, matching how //--- the observed excess is centred. Independence across lags overstates the spread of the maximum //--- (neighbouring lags share nearly all their feature window), so this errs toward rejecting. int fwDraws = MI_LAG_PERMUTATIONS; int validLags = 0; for(int k = 0; k <= maxLag; k++) if(lagValid[k]) { fwDraws = (int)MathMin(fwDraws, lagCount[k]); validLags++; } double fwMax[MI_LAG_PERMUTATIONS]; if(validLags <= 0) fwDraws = 0; for(int s = 0; s < fwDraws; s++) { double worst = -DBL_MAX; for(int k = 0; k <= maxLag; k++) { if(!lagValid[k]) continue; double sum = 0.0; for(int q = 0; q < lagCount[k]; q++) sum += lagDraws[k][q]; double loo = (sum - lagDraws[k][s]) / (lagCount[k] - 1); double e = lagDraws[k][s] - loo; if(e > worst) worst = e; } fwMax[s] = worst; } string profile = ""; int deepest = 0; for(int k = 0; k <= maxLag; k++) { if(!lagValid[k]) { profile += StringFormat(" k%d=n/a", k); continue; } int atLeast = 0; for(int s = 0; s < fwDraws; s++) if(fwMax[s] >= lagExcess[k]) atLeast++; double pFw = (fwDraws > 0) ? (double)(1 + atLeast) / (fwDraws + 1) : 1.0; bool clears = (fwDraws > 0 && pFw <= MI_LAG_ALPHA); if(clears) deepest = k; profile += StringFormat(" k%d=%+.5f%s", k, lagExcess[k], (clears ? "*" : "")); } Print(ID + StringFormat(": MI feature-lag profile (features from bar i+k, LABEL PINNED to the entry " "bar i, so every k is causal; value is excess over that lag's own " "block-permutation null; '*' = p<=%.2f against the null of the MAXIMUM over " "%d lags, not against the lag's own null - %d lags tested one at a time would " "star one per run on noise alone) -%s", MI_LAG_ALPHA, validLags, validLags, profile)); if(deepest <= 0) Print(ID + StringFormat(": MI feature-lag profile - NOTHING clears the family-wise null at ANY lag " "out to %d bars (entry bar itself %+.5f). The %d-bar lookback is not costing " "us information; there is none to lose. This is the blind spot the earlier " "reports had: they scored the entry bar alone, so they could not have " "distinguished 'no signal anywhere' from 'signal only in the older bars'.", maxLag, atZero, maxLag)); else Print(ID + StringFormat(": MI feature-lag profile - information survives to lag %d of %d, clearing " "the null of the maximum over %d lags. A lookback shorter than %d would " "discard measurable information; a longer one adds input width for none. " "BEFORE ACTING ON THIS: re-run it. An uncorrected version of this report " "gave opposite answers on two runs over identical data, so one run is not " "a result - the shape has to reappear, and ideally on a second instrument.", deepest, maxLag, validLags, deepest + 1)); return deepest; } //+------------------------------------------------------------------+ void CExpertSignalAIBase::ReportBarrierGeometryScan(void) { //--- SL x1 is deliberately absent: MIN_SL_ATR_MULTIPLIER floors it anyway, and it was rejected on this //--- instrument as too tight to survive normal noise. TP grid is exactly the TAKE_PROFIT_MODE enum. double slGrid[] = { 2.0, 3.0 }; int tpGrid[] = { 2, 3, 4, 6, 8, 10 }; int savedHorizon = m_barrierHorizonBars; int barsNow = m_labelCacheBars; uint t0 = GetTickCount(); string rows = ""; double bestExcess = -1.0; string bestName = ""; int bestSl = 0, bestTp = 0; //--- Per-candidate null draws, retained so the winner can be tested against the null of the MAXIMUM //--- rather than against its own. Only ELIGIBLE candidates are enrolled: the family the maximum was //--- actually taken over is the family the gate must correct for, and a disqualified pairing can never //--- be the winner however it scores. double drawMat[MI_GEOMETRY_MAX_CANDIDATES][MI_GEOMETRY_PERMUTATIONS]; int drawCount[MI_GEOMETRY_MAX_CANDIDATES]; int candidates = 0; double cfgSl = 0.0, cfgTp = 0.0; BarrierMultiples(cfgSl, cfgTp); double cfgExcess = -1.0; m_barrierScanLiveLabels = true; for(int a = 0; a < ArraySize(slGrid); a++) for(int b = 0; b < ArraySize(tpGrid); b++) { //--- A target tighter than the stop inverts the trade's whole premise and none of the shipped //--- pairings do it; skip rather than rank something nobody can select sensibly. if((double)tpGrid[b] < slGrid[a]) continue; m_barrierScanSlMult = slGrid[a]; m_barrierScanTpMult = (double)tpGrid[b]; m_barrierHorizonBars = ComputeBarrierHorizonBars(barsNow); bool clamped = m_barrierHorizonClamped; m_barrierScanTimeouts = 0; double gc[]; int gl[]; int gn = BuildMiSample(gc, gl); if(gn < MI_MIN_SAMPLES) continue; double obs = ScoreMiSample(gc, gl, gn, false); //--- Class shares of THIS geometry's label, so a geometry that scores well by having almost //--- nothing left to predict is visible as such instead of winning quietly. int cB = 0, cS = 0; for(int q = 0; q < gn; q++) { if(gl[q] == 0) cB++; else if(gl[q] == 1) cS++; } double dirShare = 100.0 * (cB + cS) / gn; double timeoutShare = 100.0 * m_barrierScanTimeouts / gn; //--- MIN REWARD:RISK, hoisted above the draws because it decides ENROLMENT in the family-wise null //--- and not merely the printed row - see the long note at the eligibility test below. bool rrOK = ((double)tpGrid[b] >= (double)Min_Risk_Reward_Ratio * slGrid[a]); bool eligible = (!clamped && rrOK); //--- These draws now serve two purposes. Per candidate they still centre the printed score. Across //--- candidates they form the null of the maximum, which is the only thing that can say whether the //--- WINNER is real - so they are retained rather than reduced to a mean and discarded. double nullSum = 0.0; int nd = 0; for(int s = 0; s < MI_GEOMETRY_PERMUTATIONS; s++) { double sc = ScoreMiSample(gc, gl, gn, true); if(sc < 0.0) continue; nullSum += sc; if(eligible && candidates < MI_GEOMETRY_MAX_CANDIDATES) drawMat[candidates][nd] = sc; nd++; } if(eligible && candidates < MI_GEOMETRY_MAX_CANDIDATES) { drawCount[candidates] = nd; candidates++; } double nullMean = (nd > 0) ? nullSum / nd : -1.0; double excess = (nullMean >= 0.0) ? (obs - nullMean) : 0.0; //--- Base rate m/(m+k) IS the break-even win rate at this geometry - print it so the ranking is //--- read next to the bar the model would have to clear, not in isolation. double breakeven = 100.0 * slGrid[a] / (slGrid[a] + (double)tpGrid[b]); //--- rrOK (hoisted above the draws) is MIN REWARD:RISK. Min_Risk_Reward_Ratio is a pure REJECTION //--- filter on live setups, so a geometry under it would be relabelled, retrained on, and then //--- have every one of its setups thrown away at the door - the failure that produced four //--- consecutive Market rejections for "no trading operations". The first version of this scan //--- ranked 2:2 top: 1:1 against a shipped 1:2 floor, i.e. it would have retrained four topologies //--- on a target the EA can never act on. Ineligible, not merely ranked down. string name = StringFormat("%.0f:%d", slGrid[a], tpGrid[b]); rows += StringFormat("%s%s(h%d%s,be%.0f%%,dir%.0f%%,to%.0f%%)=%+.5f%s", (rows == "" ? "" : " "), name, m_barrierHorizonBars, (clamped ? "!" : ""), breakeven, dirShare, timeoutShare, excess, (rrOK ? "" : "[ bestExcess) { bestExcess = excess; bestName = name; //--- The grid values ARE the enum values (SL_ATR_x2 == 2, TP_ATR_x8 == 8), so the winning //--- pairing can be adopted directly with no lookup table to drift out of step. bestSl = (int)slGrid[a]; bestTp = tpGrid[b]; } if(slGrid[a] == cfgSl && (double)tpGrid[b] == cfgTp) cfgExcess = excess; } m_barrierScanLiveLabels = false; m_barrierScanSlMult = 0.0; m_barrierScanTpMult = 0.0; m_barrierHorizonBars = savedHorizon; Print(ID + StringFormat(": barrier-geometry scan (SL:TP; h=horizon, '!'=CLAMPED, [ 0) { fwDraws = MI_GEOMETRY_PERMUTATIONS; for(int c = 0; c < candidates; c++) fwDraws = (int)MathMin(fwDraws, drawCount[c]); int atLeast = 0; for(int s = 0; s < fwDraws; s++) { double worst = -DBL_MAX; for(int c = 0; c < candidates; c++) { if(drawCount[c] < 2) continue; double sum = 0.0; for(int q = 0; q < drawCount[c]; q++) sum += drawMat[c][q]; double loo = (sum - drawMat[c][s]) / (drawCount[c] - 1); double e = drawMat[c][s] - loo; if(e > worst) worst = e; } if(worst > -DBL_MAX && worst >= bestExcess) atLeast++; } pFamily = (fwDraws > 0) ? (double)(1 + atLeast) / (fwDraws + 1) : 1.0; } bool winnerReal = (bestName != "" && bestExcess > 0.0 && fwDraws > 0 && pFamily <= MI_GEOMETRY_ALPHA); Print(ID + StringFormat(": barrier-geometry winner test - %s at %+.5f is the best of %d ELIGIBLE " "candidates, so it is tested against the null of the maximum over %d, not its " "own: p=%.4f over %d draws (need <=%.2f). %s", (bestName == "" ? "none" : bestName), bestExcess, candidates, candidates, pFamily, fwDraws, MI_GEOMETRY_ALPHA, (winnerReal ? "CLEARS - the ranking is not selection noise." : "DOES NOT CLEAR - a max this large happens routinely when every candidate is " "pure noise, so the ranking carries no information and the top row is not a " "finding. Change nothing."))); //--- ADOPT, don't advise. SL_Mode/TP_Mode stopped being inputs on 2026-08-07, so this scan is now the //--- thing that chooses the barrier - which is exactly why the family-wise gate above had to exist //--- first. Three conditions, all necessary: //--- winnerReal - it beat the null of the MAXIMUM, not merely the incumbent and not merely zero. //--- m_eraCount==0 - relabelling a partly-trained net would move the target out from under weights //--- already fitted to the old one. Same gate the indicator tuner uses. //--- != current - nothing to do when the measurement agrees with the default. //--- A model that already exists never reaches here with anything to change: its geometry is pinned in //--- the .cfg and adopted at load, so the pairing a run trains on is the pairing it keeps. if(winnerReal && m_eraCount == 0 && bestSl > 0 && bestTp > 0 && (bestSl != m_sl_mode || bestTp != m_tp_mode)) { Print(ID + StringFormat(": adopting barrier geometry %s - it carries %+.5f nats of entry-time " "information against the configured %.0f:%.0f's %+.5f, and cleared the " "family-wise gate. Relabelling and training on it. Chance precision equals " "break-even at EVERY geometry, so this does not hand us expectancy; it puts " "more of the answer inside the features' reach, which is the one thing no " "change of topology can do.", bestName, bestExcess, cfgSl, cfgTp, cfgExcess)); m_sl_mode = bestSl; m_tp_mode = bestTp; //--- The cache holds labels computed under the OLD barriers, so it has to be discarded rather than //--- appended to - Train()'s !m_labelCachePrebuilt gate then rebuilds it under the adopted pair //--- before era 0 starts. m_labelCachePrebuilt = false; ArrayInitialize(m_labelCacheHasValue, false); //--- AND UNLATCH THE HORIZON, which is otherwise resolved once per process and held. Adopting a //--- wider target without this labels the new geometry against the OLD ceiling - 2:8 wants ~192 //--- bars and would silently get 2:6's 128 - which is precisely the truncation that made every //--- model learn "target within 128 bars" while the EA holds to SL/TP (fixed 2026-08-01 in //--- 168422f). The truncation lands in Neutral, not in the timeout counter that watches for it, so //--- it does not announce itself. EnsureBarrierHorizon() re-derives and re-logs on the next call. m_barrierHorizonResolved = false; } else if(winnerReal && m_eraCount > 0 && bestSl > 0 && (bestSl != m_sl_mode || bestTp != m_tp_mode)) Print(ID + ": barrier-geometry scan prefers " + bestName + ", but this model is already trained " "(era " + IntegerToString(m_eraCount) + "). Its geometry is pinned to what it learned; " "delete the weights if you want it re-measured."); else if(bestName == "") Print(ID + ": barrier-geometry scan - every geometry with a long enough horizon was " "disqualified or scored at zero. Nothing here to switch to; the limit is the feature " "set, not the target."); } //+------------------------------------------------------------------+ //| Outer loop around Train(). Tuning is now a one-shot filter pass | //| that runs BEFORE the first era and costs seconds, so this is a | //| straight pass-through to Train() on every later call. | //+------------------------------------------------------------------+ void CExpertSignalAIBase::TuneIndicatorsAndTrain(datetime StartTrainBar = 0) { bool anyTunable = (m_useADCumulativeDelta || m_useADShorteningOfThrust || m_useADWyckoffEventStream || m_useADWyckoffFailedStructure || m_useADWyckoffSignificantBarInversion || m_useMA || m_useRSI || m_useMACD || m_useIchimoku); //--- Tune once per fresh model, before any weight has been trained. Gated on m_labelCachePrebuilt //--- because the score needs labels, and on era 0 because re-tuning a partly-trained network would //--- change its inputs out from under weights already fitted to the old ones. if(m_autoTuneIndicators && anyTunable && !m_tuneFilterDone && m_labelCachePrebuilt && m_eraCount == 0) { m_tuneFilterDone = true; SetStatusLabel(ID + " : scoring indicator settings..."); TuneIndicatorsByFilter(); //--- the winning parameters change the input vector, so the network must start from scratch on it BuildFreshTopology(); } //--- The DIAGNOSTIC half runs even when the sweep does not: on a resumed model, on one whose tuner is //--- switched off, and on one with nothing tunable. It reads the cached features and writes nothing, //--- so none of the reasons the sweep is gated apply to it - and tying it to that gate meant the only //--- way to see the answer on a running model was to delete the model. //--- //--- THAT INTENT WAS NOT ACHIEVED UNTIL 2026-08-07. Moving the diagnostic out of the tuner's gate //--- left it behind m_labelCachePrebuilt, which has exactly the same effect: the eager label pre-scan //--- runs only on a FRESH start, because a resumed net labels lazily per bar (see the "skipped //--- entirely when a trained net was loaded from disk" note in the prebuild). So on a resumed model //--- the flag is false forever and the entire MI block - headline, positive control, alignment scan, //--- lag profile, geometry scan, winner test - silently never ran. Measured on SP500 H1 2026-08-07: //--- attached at era 271, still nothing by era 314, and every diagnostic captured on 08-05/06 came //--- immediately after a weights reset. The only way to see the answer was still to delete the model. //--- //--- So drive the prebuild ourselves when it is the only thing missing. It is safe on a trained net: //--- its one fresh-net side effect, pushing the output-layer bias toward the dominant class, is //--- already gated on m_eraCount == 0, and the scan itself only fills label caches. Train()'s own //--- m_labelPrebuildActive gate advances it to completion, so this costs one short deferral (~1s at //--- 38k bars) on the first attach and nothing afterwards. //--- //--- NOT sampled from the lazily-filled cache instead: BuildMiSample skips bars that carry no cached //--- label, so on a resumed model it would quietly score whichever subset training happened to have //--- visited. That is a biased subsample presented as a measurement - the failure mode this whole //--- diagnostic exists to catch. else if(!m_miReportDone && !m_labelCachePrebuilt && !m_labelPrebuildActive) { //--- Announce only on a start that actually took. StartLabelCachePrebuild() returns without arming //--- if the buffers/history are not ready yet and is simply retried on the next call, so printing //--- unconditionally would repeat the line once per bar event until it succeeds. StartLabelCachePrebuild(); //--- Says WHICH case this is rather than asserting the resumed one. The first version claimed //--- "resumed from disk" unconditionally, and then printed it above a "seeding era 0" line on a //--- brand-new model - the branch fires whenever the cache is not built, which is equally true //--- before a fresh model's first prebuild. A diagnostic that misreports its own trigger is worse //--- than one that says nothing, because it gets quoted back as evidence. if(m_labelPrebuildActive) Print(ID + (m_modelLoadedFromDisk ? ": MI diagnostics need a complete label cache and this model resumed from disk " "(labels are filled lazily, so the cache covers only the bars training has " "visited) - running the one-time pre-scan now, then the report. Training resumes " "where it left off." : ": MI diagnostics need a complete label cache and this model has not built one yet " "- running the pre-scan now, then the report.")); } else if(!m_miReportDone && m_labelCachePrebuilt) { //--- WAIT FOR THE CROSS-ASSET PANEL. It is part of the feature vector but it is built inside //--- Train(), so on a fresh run this diagnostic would otherwise describe a NARROWER vector than //--- the one training goes on to use. Observed 2026-08-02 on SP500 H1: the MI report, the //--- alignment scan and the barrier-geometry scan all ran at 00:41:25, while the panel first //--- built successfully at 01:12:47 - so every number they printed, including the geometry scan //--- that is supposed to CHOOSE the training target, was measured on a feature set training //--- never saw. Train() rebuilds the panel each era, so simply deferring lands the report on an //--- era where the vector is complete. //--- Never wait forever: a terminal that cannot sync the reference symbols (the tester loads //--- auxiliary symbols from the terminal, not the server) must still get its diagnostics, with //--- the gap stated rather than hidden. if(m_crossAsset.IsReady() || m_miReportDeferrals >= MI_REPORT_MAX_DEFERRALS) ReportFeatureLabelInformation(); else m_miReportDeferrals++; } Train(StartTrainBar); } #endif // WARRIOR_AIBASE_AUTOTUNE_MQH