forked from animatedread/Warrior_EA
The //| box blocks were excluded from0b06f8eand5efdb48and were what remained: 160 of them ran to 10+ lines, the longest to 88. Compressed to their leading topic sentences - 5 lines for a function header, 8 for a file header - keeping the box format and the standard MQL5 name/author lines verbatim. Verified at the BYTE level this time, across every in-scope file: the list of non-comment lines is byte-identical to HEAD and braces balance. The first check compared a locale-decoded 'git show' against a UTF-8 read and flagged 25 files that had not changed at all - every BOM and every non-ASCII line mismatched. 47,696 -> 40,665 lines in scope; comment share 38% -> 26%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1562 lines
83 KiB
MQL5
1562 lines
83 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| Warrior_EA |
|
|
//| AnimateDread |
|
|
//| |
|
|
//| Filter-based indicator auto-tuner (mutual information scoring). |
|
|
//+------------------------------------------------------------------+
|
|
#ifndef WARRIOR_AIBASE_AUTOTUNE_MQH
|
|
#define WARRIOR_AIBASE_AUTOTUNE_MQH
|
|
//--- ONCE-PER-CHART gate for the MI diagnostic suite on a multi-member ensemble. The first member
|
|
//--- to reach it runs it; the rest log one line and skip. Solo charts are untouched.
|
|
bool g_ensembleChartMiReportDone = false;
|
|
//--- ONCE-PER-CHART share of the indicator auto-tune SWEEP on a multi-member ensemble, same doctrine as
|
|
//--- the MI gate above: the sweep scores candidate indicator settings by feature/label MI, and every
|
|
//--- ensemble member holds identical indicators, identical cached features and identical labels, so all
|
|
//--- N sweeps are the same deterministic calculation (verified 2026-08-16 on SP500 H4: four members,
|
|
//--- byte-identical scores, spans and selection p). Worse, the sweep ends in the full MI diagnostic
|
|
//--- suite (ReportFeatureLabelInformation at its tail), which the MI gate above never intercepts on the
|
|
//--- sweep path - so each duplicate sweep also duplicated the ~200-draw permutation nulls, the slowest
|
|
//--- single block of "getting ready". The first member runs the sweep and publishes its outcome here;
|
|
//--- the rest apply the outcome (install the winner, or keep the configured settings the sweep restored)
|
|
//--- and skip both the sweep and the report. Same caveat as the MI gate: any winner ADOPTION is made by
|
|
//--- the donor and applied to every member via the flattened settings below, which is the consistent
|
|
//--- choice - members training on divergent feature vectors would not be an ensemble. Solo charts are
|
|
//--- untouched.
|
|
bool g_ensembleChartTuneDone = false;
|
|
bool g_ensembleChartTuneInstalled = false; // did the donor's sweep clear the family-wise gate and install?
|
|
double g_ensembleChartTuneSettings[]; // CADIndicatorTuner::Flatten() of the donor's final settings
|
|
//--- THE SAME DOCTRINE, APPLIED TO THE BARRIER GEOMETRY - and it was missing, which broke the
|
|
//--- ensemble. That was harmless while the scan only PRINTED.
|
|
bool g_ensembleChartGeomAdopted = false; // did the donor's scan adopt a pairing the siblings must take?
|
|
double g_ensembleChartGeomSl = 0.0; // the DERIVED pair (the one authority - see BarrierMultiples)
|
|
double g_ensembleChartGeomTp = 0.0;
|
|
int g_ensembleChartGeomSlMode = 0; // legacy mode ints, kept in step for the fallback/fingerprint
|
|
int g_ensembleChartGeomTpMode = 0;
|
|
#ifdef WARRIOR_EXPORT_FEATURES
|
|
//+------------------------------------------------------------------+
|
|
//| RESEARCH BUILD ONLY - see the declaration comment. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::ExportFeatureMatrix(void)
|
|
{
|
|
if(MQLInfoInteger(MQL_OPTIMIZATION))
|
|
return;
|
|
int barsNow = Bars(m_symbol.Name(), PERIOD_CURRENT);
|
|
//--- Clamp BEFORE the emptiness test, so a fully-capped symbol reports the depth it can actually
|
|
//--- export rather than the price-series depth it cannot.
|
|
barsNow = ServableBars(barsNow, "feature export");
|
|
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 | FILE_SHARE_READ | FILE_SHARE_WRITE, ',');
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
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 | FILE_SHARE_READ | FILE_SHARE_WRITE, ',');
|
|
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.
|
|
//+------------------------------------------------------------------+
|
|
//| MUTUAL INFORMATION between one cached feature column and the |
|
|
//| triple-barrier label, in nats, over a sample of in-sample bars. |
|
|
//+------------------------------------------------------------------+
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
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. THE PAD IS FIXED, NOT |labelBarOffset|.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
if(continuousTarget && n > 0)
|
|
{
|
|
//--- EQUAL-FREQUENCY TERCILES off the library, so this and the barrier stop ladder share one
|
|
//--- quantile definition instead of the nearest-rank indexing each used to spell out.
|
|
double vals[];
|
|
ArrayResize(vals, n);
|
|
ArrayCopy(vals, raw, 0, 0, n);
|
|
double probs[2] = {1.0 / 3.0, 2.0 / 3.0};
|
|
double cuts[];
|
|
if(!MathQuantile(vals, probs, cuts))
|
|
return -1;
|
|
double cut1 = cuts[0];
|
|
double cut2 = cuts[1];
|
|
//--- 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 && MathMin(vals) == MathMax(vals))
|
|
{
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalAIBase::ScoreMiSample(const double &cols[], int &labels[], int n, bool shuffleLabels)
|
|
{
|
|
if(n < MI_MIN_SAMPLES)
|
|
return -1.0;
|
|
//--- PERMUTATION BASELINE. So a raw MI figure is uninterpretable on its own: 0.004 nats could be
|
|
//--- a genuine weak signal or could be pure noise.
|
|
//--- BLOCK permutation, not a free one, and the difference is the whole validity of the test. That
|
|
//--- was label autocorrelation leaking through an independence assumption, not an edge. It is Lopez
|
|
//--- de Prado ch.
|
|
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--)
|
|
{
|
|
//--- ShuffleRandomIndex, not MathRand()%: with blockRows == 1 the block count equals the row
|
|
//--- count, which can exceed MathRand()'s 15-bit range - same bias as the pass-2 queue shuffle.
|
|
int j = ShuffleRandomIndex(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[];
|
|
//--- MI_TUNE_TARGET, not the barrier label - see the define's comment: the tuner selects
|
|
//--- indicator settings for the channel with measured signal (realised RANGE), not the one
|
|
//--- measured at the noise floor (direction).
|
|
int n = BuildMiSample(cols, labels, 0, 0, MI_TUNE_TARGET);
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
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.
|
|
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++)
|
|
{
|
|
//--- The longest uninterruptible stretch in the EA: every candidate re-creates handles,
|
|
//--- refreshes, and scores a full MI sample. Asked per candidate so a stop request costs at
|
|
//--- most one candidate rather than the rest of the descent - see ShutdownRequested().
|
|
if(ShutdownRequested())
|
|
{
|
|
//--- Hand the OPERATOR's settings back before leaving. best[] is mutated in place by
|
|
//--- the descent and the tuner object currently carries the LAST TRIAL's parameters,
|
|
//--- which nothing chose and which the .cfg would otherwise persist as if it had
|
|
//--- been selected.
|
|
m_indicatorTuner.Unflatten(configured);
|
|
PrintFormat("%s: auto-tune ABANDONED after %d candidates - stop requested. Configured"
|
|
" indicator settings restored; nothing installed.", ID, evaluated);
|
|
return;
|
|
}
|
|
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. Without this the
|
|
//--- buffers still hold values copied from the PREVIOUS handle, so every candidate is
|
|
//--- scored on identical features.
|
|
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.
|
|
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[];
|
|
//--- same target as the sweep's scorer, or the gate would test the winner against a
|
|
//--- different question than the one it was selected on
|
|
int wn = BuildMiSample(wc, wl, 0, 0, MI_TUNE_TARGET);
|
|
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++)
|
|
{
|
|
//--- A truncated null is not a smaller null, it is a WRONG one - fewer draws shifts p toward
|
|
//--- significance. So a stop here abandons the test entirely (draws stays 0, pFamily stays
|
|
//--- 1.0, install becomes false) rather than installing on a partial null.
|
|
if(ShutdownRequested())
|
|
{
|
|
draws = 0;
|
|
break;
|
|
}
|
|
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();
|
|
//--- A gated INSTALL is chart-level news, not just this model's: persist the winning periods so
|
|
//--- the classic votes, the signal-DB key and every later tuner seed adopt them on the next
|
|
//--- attach (restart-grained - see Variables\TunedPeriods.mqh for why not mid-run).
|
|
if(install)
|
|
SaveTunedPeriods(m_indicatorTuner.maPeriod, m_indicatorTuner.maType, m_indicatorTuner.rsiPeriod,
|
|
m_indicatorTuner.macdFast, m_indicatorTuner.macdSlow, m_indicatorTuner.macdSignal,
|
|
m_indicatorTuner.ichiTenkan, m_indicatorTuner.ichiKijun, m_indicatorTuner.ichiSenkou);
|
|
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. Landing on
|
|
//--- 0.0000 means every column read back constant, which is what a feature-extraction fault
|
|
//--- looks like.
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::ReportFeatureLabelInformation(void)
|
|
{
|
|
m_miReportDone = true;
|
|
//--- PERMUTATION TEST, done properly.
|
|
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++)
|
|
{
|
|
//--- A few hundred full MI scorings, and nothing below can start until they finish. Abandon the
|
|
//--- test outright rather than truncate it: fewer draws does not make a smaller null, it makes a
|
|
//--- WRONG one (p shifts toward significance), and this null is what licenses the direction target.
|
|
if(ShutdownRequested())
|
|
{
|
|
draws = 0;
|
|
break;
|
|
}
|
|
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++;
|
|
}
|
|
//--- Left the null early because the program is going away: everything from here on either prints a
|
|
//--- number derived from `draws` or starts another scan. Neither is worth a millisecond of the teardown
|
|
//--- budget, and m_dirEvidence staying false is the safe direction (a torn-down run deploys nothing).
|
|
if(ShutdownRequested())
|
|
return;
|
|
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. 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";
|
|
//--- SCREEN, not commentary. This test asks directly whether the features carry information
|
|
//--- about the DIRECTIONAL barrier label, so clearing it licenses the direction target on its
|
|
//--- own - see m_dirEvidence.
|
|
if(draws > 0 && pMean <= 0.05)
|
|
{
|
|
m_dirEvidence = true;
|
|
m_dirEvidenceWhy = "feature/label mutual information cleared its block-permuted null";
|
|
}
|
|
//--- Name the feature vector this was measured on. 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. 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. 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.
|
|
if(ShutdownRequested())
|
|
return;
|
|
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. 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.
|
|
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++)
|
|
{
|
|
//--- Nine sample builds. A partial profile cannot be read - the lookahead test compares the k<0
|
|
//--- side against k=0 and both must exist - so a stop abandons the scan rather than printing a row
|
|
//--- with holes in it that would look like a null result at the missing offsets.
|
|
if(ShutdownRequested())
|
|
return;
|
|
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.
|
|
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.
|
|
ReportExcursionInformation();
|
|
ReportBarrierGeometryScan();
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| WHICH BARRIER GEOMETRY IS ACTUALLY PREDICTABLE AT ENTRY. |
|
|
//+------------------------------------------------------------------+
|
|
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++)
|
|
{
|
|
//--- Five targets, each with its own full permutation null. Leaving early costs only the targets
|
|
//--- not yet reached; the ones already printed stand, and m_dirEvidence can only have been set by
|
|
//--- a null that ran to completion (see the draws=0 abandon below).
|
|
if(ShutdownRequested())
|
|
return;
|
|
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++)
|
|
{
|
|
//--- Abandon, never truncate: this null is what sets m_dirEvidence via the NORMALISED asymmetry
|
|
//--- line, and a short null biases p downward - i.e. toward licensing a direction target on a
|
|
//--- test that never finished. draws=0 makes the target below skip cleanly.
|
|
if(ShutdownRequested())
|
|
{
|
|
draws = 0;
|
|
break;
|
|
}
|
|
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.
|
|
if(asymCleared)
|
|
{
|
|
m_dirEvidence = true;
|
|
m_dirEvidenceWhy = "normalised excursion asymmetry cleared its block-permuted null";
|
|
}
|
|
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++)
|
|
{
|
|
//--- ~21 lags x MI_LAG_PERMUTATIONS full scorings, and the family-wise block below cannot run
|
|
//--- on a partial profile (its bar is the null of the MAXIMUM over lags - drop lags and the
|
|
//--- maximum is taken over a different family).
|
|
lagValid[k] = false;
|
|
lagExcess[k] = 0.0;
|
|
lagCount[k] = 0;
|
|
if(ShutdownRequested())
|
|
return 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++)
|
|
{
|
|
if(ShutdownRequested())
|
|
return 0;
|
|
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. Non-replication on identical data is the signature of
|
|
//--- an uncorrected multiple comparison.
|
|
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;
|
|
//--- The winner's own horizon, captured while it is in scope. Needed by the DETECTABILITY guard on the
|
|
//--- adoption below: a wider pairing takes longer to resolve, and how long it takes is what decides
|
|
//--- how many INDEPENDENT observations the OOS window can ever yield.
|
|
int bestHorizon = 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;
|
|
//--- CANDIDATE LIST, built up front so the CONFIGURED pair is scored alongside the grid instead
|
|
//--- of being looked up in it. The one mechanism built to price the shipped geometry could not
|
|
//--- see it.
|
|
double candSl[], candTp[];
|
|
bool candIsCfg[];
|
|
int candN = 0;
|
|
ArrayResize(candSl, ArraySize(slGrid) * ArraySize(tpGrid) + 1);
|
|
ArrayResize(candTp, ArraySize(slGrid) * ArraySize(tpGrid) + 1);
|
|
ArrayResize(candIsCfg, ArraySize(slGrid) * ArraySize(tpGrid) + 1);
|
|
for(int a = 0; a < ArraySize(slGrid); a++)
|
|
for(int b = 0; b < ArraySize(tpGrid); b++)
|
|
{
|
|
if((double)tpGrid[b] < slGrid[a])
|
|
continue;
|
|
candSl[candN] = slGrid[a];
|
|
candTp[candN] = (double)tpGrid[b];
|
|
candIsCfg[candN] = false;
|
|
candN++;
|
|
}
|
|
if(cfgSl > 0.0 && cfgTp > 0.0)
|
|
{
|
|
candSl[candN] = cfgSl;
|
|
candTp[candN] = cfgTp;
|
|
candIsCfg[candN] = true;
|
|
candN++;
|
|
}
|
|
m_barrierScanLiveLabels = true;
|
|
{
|
|
for(int c = 0; c < candN; c++)
|
|
{
|
|
//--- THE HEAVIEST SCAN IN THE EA: every pairing relabels the whole sampled history and
|
|
//--- then draws MI_GEOMETRY_PERMUTATIONS nulls off it.
|
|
if(ShutdownRequested())
|
|
break;
|
|
m_barrierScanSlMult = candSl[c];
|
|
m_barrierScanTpMult = candTp[c];
|
|
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;
|
|
//--- The MIN REWARD:RISK test that used to gate enrolment here is GONE (2026-08-09) along
|
|
//--- with Min_Risk_Reward_Ratio itself. RATIO FLOOR, reinstated 2026-08-19 - and NOT the
|
|
//--- rule that was removed in 2026-08-09.
|
|
bool ratioOK = (candSl[c] > 0.0 && candTp[c] >= BARRIER_TARGET_RR_MIN * candSl[c] - 0.01);
|
|
bool eligible = !clamped && ratioOK;
|
|
//--- 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++)
|
|
{
|
|
//--- Break only: the outer loop's check runs next and does the restore-and-return in one
|
|
//--- place. This candidate's partial draws are discarded with everything else, so a short
|
|
//--- null can never reach the family-wise gate below.
|
|
if(ShutdownRequested())
|
|
break;
|
|
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 * candSl[c] / (candSl[c] + candTp[c]);
|
|
//--- Ranking is now purely the measurement: every unclamped pairing competes, whatever its
|
|
//--- reward:risk.
|
|
string name = candIsCfg[c] ? StringFormat("CFG %.2f:%.2f", candSl[c], candTp[c])
|
|
: StringFormat("%.0f:%.0f%s", candSl[c], candTp[c], (ratioOK ? "" : "r"));
|
|
rows += StringFormat("%s%s(h%d%s,be%.0f%%,dir%.0f%%,to%.0f%%)=%+.5f", (rows == "" ? "" : " "),
|
|
name, m_barrierHorizonBars, (clamped ? "!" : ""), breakeven,
|
|
dirShare, timeoutShare, excess);
|
|
//--- Only unclamped, tradeable geometries are eligible to WIN - see the header. The rest
|
|
//--- are still printed, so a disqualification is visible rather than a silent omission.
|
|
if(eligible && !candIsCfg[c] && excess > 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)candSl[c];
|
|
bestTp = (int)candTp[c];
|
|
bestHorizon = m_barrierHorizonBars;
|
|
}
|
|
if(candIsCfg[c])
|
|
cfgExcess = excess;
|
|
}
|
|
}
|
|
m_barrierScanLiveLabels = false;
|
|
m_barrierScanSlMult = 0.0;
|
|
m_barrierScanTpMult = 0.0;
|
|
m_barrierHorizonBars = savedHorizon;
|
|
//--- THE ONE PLACE an abandoned scan leaves from, after the restore above and before anything reads
|
|
//--- what it collected. Nothing here is worth that, and nothing here is worth the teardown budget
|
|
//--- either.
|
|
if(ShutdownRequested())
|
|
{
|
|
PrintFormat("%s: barrier-geometry scan ABANDONED - stop requested. Scan state restored; the geometry"
|
|
" in force is unchanged.", ID);
|
|
return;
|
|
}
|
|
Print(ID + StringFormat(": barrier-geometry scan (SL:TP; h=horizon, '!'=CLAMPED and disqualified - a clamped "
|
|
"label truncates a trade the EA would hold to SL/TP; be=break-even win rate, "
|
|
"dir=%%bars with a tradeable direction, to=%%timed out; value is entry-time "
|
|
"information in nats above that geometry's own null; 'CFG' is the geometry "
|
|
"actually in force, scored as a peer but never crowned) - %s | configured "
|
|
"%.2f:%.2f scores %+.5f, best eligible GRID pairing is %s at %+.5f (%.1fs)",
|
|
rows, cfgSl, cfgTp, cfgExcess, (bestName == "" ? "none" : bestName), bestExcess,
|
|
(GetTickCount() - t0) / 1000.0));
|
|
//--- FAMILY-WISE GATE. bestExcess is a MAXIMUM over the eligible candidates, and the maximum of
|
|
//--- several draws from a null sits well above any single draw from it - so testing the winner
|
|
//--- against its own null asks the wrong question and will crown a winner on pure noise almost
|
|
//--- every time.
|
|
double pFamily = 1.0;
|
|
int fwDraws = 0;
|
|
double nullMaxSum = 0.0;
|
|
int nullMaxCount = 0;
|
|
if(candidates > 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++;
|
|
//--- WINNER'S-CURSE PENALTY, accumulated from the same draws the p-value uses. `worst` is
|
|
//--- the MAXIMUM excess over all candidates in a pure-noise draw, so its mean across draws
|
|
//--- is exactly what a best-of-K selection is expected to report when there is nothing
|
|
//--- there.
|
|
if(worst > -DBL_MAX)
|
|
{
|
|
nullMaxSum += worst;
|
|
nullMaxCount++;
|
|
}
|
|
}
|
|
pFamily = (fwDraws > 0) ? (double)(1 + atLeast) / (fwDraws + 1) : 1.0;
|
|
}
|
|
//--- The winner's excess, SHRUNK toward zero by that penalty. Clearing the family-wise gate says
|
|
//--- the ranking is real; it does NOT say the effect is as large as the top row reads.
|
|
double nullMaxMean = (nullMaxCount > 0) ? nullMaxSum / nullMaxCount : 0.0;
|
|
double shrunkExcess = bestExcess;
|
|
if(bestExcess > 0.0 && nullMaxMean > 0.0)
|
|
shrunkExcess = bestExcess * MathMax(0.0, 1.0 - (nullMaxMean * nullMaxMean) / (bestExcess * bestExcess));
|
|
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). SHRUNK effect %+.5f (a best-of-K maximum is "
|
|
"biased upward by construction; the winner's-curse penalty here is the MEAN "
|
|
"noise-draw maximum %+.5f, measured on these same draws - plan on the shrunk "
|
|
"number, not the raw one). %s", (bestName == "" ? "none" : bestName),
|
|
bestExcess, candidates, candidates, pFamily, fwDraws, MI_GEOMETRY_ALPHA, shrunkExcess, nullMaxMean,
|
|
(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.
|
|
bool winnerDetectable = true;
|
|
double needIndep = 0.0, haveIndep = 0.0;
|
|
if(bestSl > 0 && bestTp > 0 && bestHorizon > 0)
|
|
{
|
|
double p = (double)bestSl / ((double)bestSl + (double)bestTp); // break-even at this pairing
|
|
needIndep = BinomialCallsForEdge(p, ADOPT_MIN_DETECTABLE_EDGE, EDGE_MIN_SIGMAS);
|
|
haveIndep = (double)barsNow * (MathMax(0, MathMin(100, m_oosSplitPct)) / 100.0) / (double)bestHorizon;
|
|
winnerDetectable = (haveIndep >= needIndep);
|
|
}
|
|
if(winnerReal && !winnerDetectable && m_eraCount == 0)
|
|
Print(ID + StringFormat(": barrier-geometry winner %s NOT ADOPTED - it wins on information (%+.5f"
|
|
" nats) and loses on DETECTABILITY. At break-even %.0f%% it needs %.0f"
|
|
" independent calls to certify a %.0fpp edge, and its %d-bar horizon leaves"
|
|
" this OOS window at most %.0f - so no win rate a model could reach would"
|
|
" ever clear the deploy gate on this pairing. Keeping the incumbent. More"
|
|
" information per trade is worth nothing if it buys too few independent"
|
|
" trades to prove: WIDTH is not free, and this is the constraint that"
|
|
" decides, not the nats.",
|
|
bestName, bestExcess,
|
|
100.0 * (double)bestSl / ((double)bestSl + (double)bestTp),
|
|
needIndep, 100.0 * ADOPT_MIN_DETECTABLE_EDGE, bestHorizon, haveIndep));
|
|
if(winnerReal && winnerDetectable && 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));
|
|
//--- AND INTO THE PAIR THAT ACTUALLY GOVERNS, which the mode ints have not been since the
|
|
//--- derived geometry landed. ONE AUTHORITY: the derived pair. Two measurements choosing the
|
|
//--- same thing, one of them silently inert, and a log line that stated the opposite of what
|
|
//--- happened.
|
|
ApplyAdoptedGeometry((double)bestSl, (double)bestTp, bestSl, bestTp);
|
|
//--- PUBLISH IT TO THE CHART. The MI chain that ends in this scan runs once per chart, so the other
|
|
//--- three members never measure this and would otherwise keep labelling on their own derived pair
|
|
//--- while this one relabels - four members, two targets, one averaged vote. See the measurement in
|
|
//--- g_ensembleChartGeomAdopted's comment.
|
|
if(m_ensembleMember)
|
|
{
|
|
g_ensembleChartGeomAdopted = true;
|
|
g_ensembleChartGeomSl = m_derivedSlMult;
|
|
g_ensembleChartGeomTp = m_derivedTpMult;
|
|
g_ensembleChartGeomSlMode = m_sl_mode;
|
|
g_ensembleChartGeomTpMode = m_tp_mode;
|
|
}
|
|
}
|
|
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. See the |
|
|
//| declaration. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::ApplyAdoptedGeometry(double sl, double tp, int slMode, int tpMode)
|
|
{
|
|
//--- Legacy mode ints first. SL_Mode/TP_Mode stopped being inputs on 2026-08-07 and no longer decide
|
|
//--- anything, but the fallback path in BarrierMultiples and the config fingerprint still read them, so
|
|
//--- leaving them behind the derived pair is how the two disagree.
|
|
m_sl_mode = slMode;
|
|
m_tp_mode = tpMode;
|
|
//--- Same floor DeriveBarrierGeometry applies, for the same reason: OpenParams widens any stop tighter
|
|
//--- than this, and a live stop wider than the labelled one grades the model on a bet it is not placing.
|
|
if(sl < MIN_SL_ATR_MULTIPLIER)
|
|
sl = MIN_SL_ATR_MULTIPLIER;
|
|
m_derivedSlMult = sl;
|
|
m_derivedTpMult = tp;
|
|
m_geometryDerived = true;
|
|
//--- LATCHED, or the very next label prebuild undoes this. Without the latch the sequence is: adopt
|
|
//--- 2:8, invalidate the cache, re-derive 1.61:3.21, train on 1.61:3.21, and print that it adopted
|
|
//--- 2:8.
|
|
m_geometryAdopted = true;
|
|
//--- Republished immediately, not at the next era end: between here and there the label cache is rebuilt
|
|
//--- under the adopted pair, and a live order placed in that window would otherwise carry the superseded
|
|
//--- geometry.
|
|
g_DerivedSlAtrMult = m_derivedSlMult;
|
|
g_DerivedTpAtrMult = m_derivedTpMult;
|
|
//--- The sidecar holds the OLD pair; force it to be rewritten so a restart resumes on the adopted one
|
|
//--- rather than silently reverting to what this just replaced.
|
|
m_geometryCfgSaved = false;
|
|
Print(ID + StringFormat(": geometry authority - stop %.2f*ATR / target %.2f*ATR is now the pair the"
|
|
" labels, the deploy gate and the live order ALL read. The SL_Mode/TP_Mode"
|
|
" ints are kept in step but no longer decide anything.",
|
|
m_derivedSlMult, m_derivedTpMult));
|
|
//--- 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. The truncation
|
|
//--- lands in Neutral, not in the timeout counter that watches for it, so it does not announce
|
|
//--- itself.
|
|
m_barrierHorizonResolved = false;
|
|
}
|
|
void CExpertSignalAIBase::TuneIndicatorsAndTrain(datetime StartTrainBar = 0)
|
|
{
|
|
//--- FIRST STATEMENT IN THE WHOLE TRAINING ENTRY POINT, ahead of every latch below it (m_tuneFilterDone,
|
|
//--- g_ensembleChartTuneDone) so a stop cannot mark a sweep as "already run" without running it. The
|
|
//--- individual scans yield on ShutdownRequested() as well; this simply refuses to start the chain.
|
|
if(ShutdownRequested())
|
|
return;
|
|
//--- Publish the caller's window anchor so StartLabelCachePrebuild() sizes its window with the SAME
|
|
//--- expression Train() uses.
|
|
m_tuneStartTrainBar = StartTrainBar;
|
|
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;
|
|
if(m_ensembleMember && g_ensembleChartTuneDone)
|
|
{
|
|
//--- Another member on this chart already ran the identical sweep - apply its outcome
|
|
//--- instead of recomputing it (see g_ensembleChartTuneDone at the top of this file). SAY
|
|
//--- IT ON THE PANEL, not only in the journal.
|
|
PublishStatus(ID + " : adopting the chart's tuned indicators...");
|
|
//--- THE PARAMETERS are adopted only when a winner was installed...
|
|
if(g_ensembleChartTuneInstalled)
|
|
m_indicatorTuner.Unflatten(g_ensembleChartTuneSettings);
|
|
//--- ...but the HANDLES must be rebuilt EITHER WAY, and that is not a tidiness point - it
|
|
//--- is the cause of the "silent block failure" that cost six sessions. That is the whole
|
|
//--- finding: it was never four handles, it was ONE.
|
|
ReInitADIndicators(m_indicatorsPtr);
|
|
RefreshData();
|
|
Print(ID + ": indicator auto-tune already ran on this chart - same indicators, same features, "
|
|
"same labels, same answer. " +
|
|
(g_ensembleChartTuneInstalled
|
|
? "Adopting the installed winner so every member trains on the same feature vector."
|
|
: "Keeping the configured settings (the sweep's winner was rejected by the selection gate).") +
|
|
" The first member's auto-tune report above is this model's too.");
|
|
}
|
|
else
|
|
{
|
|
//--- Names the SCOPE, because the scope is what the other rows' silence means.
|
|
PublishStatus(ID + (m_ensembleMember
|
|
? " : scoring indicator settings for the whole chart..."
|
|
: " : scoring indicator settings..."));
|
|
//--- Snapshot the configured settings first: "did the sweep install?" is answered by comparing
|
|
//--- against the final settings, since a rejected winner is restored to exactly these values.
|
|
double tuneCfgBefore[];
|
|
m_indicatorTuner.Flatten(tuneCfgBefore);
|
|
TuneIndicatorsByFilter();
|
|
if(m_ensembleMember)
|
|
{
|
|
m_indicatorTuner.Flatten(g_ensembleChartTuneSettings);
|
|
g_ensembleChartTuneInstalled = false;
|
|
for(int tp = 0; tp < ArraySize(tuneCfgBefore); tp++)
|
|
if(g_ensembleChartTuneSettings[tp] != tuneCfgBefore[tp])
|
|
{
|
|
g_ensembleChartTuneInstalled = true;
|
|
break;
|
|
}
|
|
g_ensembleChartTuneDone = true;
|
|
//--- The sweep ends in ReportFeatureLabelInformation(), so the chart-level MI report is
|
|
//--- done too - mark it, or every other member would rerun the ~200-draw nulls the MI
|
|
//--- gate below exists to save.
|
|
if(m_miReportDone)
|
|
g_ensembleChartMiReportDone = true;
|
|
}
|
|
}
|
|
//--- 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.
|
|
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. 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.
|
|
if(m_ensembleMember && g_ensembleChartMiReportDone)
|
|
{
|
|
//--- see g_ensembleChartMiReportDone at the top of this file
|
|
m_miReportDone = true;
|
|
//--- Same reasoning as the tuner's adopt branch above: published, not just printed, so the row
|
|
//--- says why it is not repeating the measurement.
|
|
PublishStatus(ID + " : reusing the chart's information report...");
|
|
Print(ID + ": MI diagnostics already measured by another ensemble member on this chart - "
|
|
"same features, same labels, same answer. Skipped (saves the slowest part of the "
|
|
"ensemble's warm-up; the first member's report above is this model's too).");
|
|
//--- ...BUT THE GEOMETRY IS NOT A REPORT, IT IS A DECISION, and skipping the chain that
|
|
//--- makes it is not the same as declining it.
|
|
if(g_ensembleChartGeomAdopted && m_eraCount == 0 && g_ensembleChartGeomSl > 0.0
|
|
&& g_ensembleChartGeomTp > 0.0
|
|
&& (m_derivedSlMult != g_ensembleChartGeomSl || m_derivedTpMult != g_ensembleChartGeomTp))
|
|
{
|
|
PrintFormat("%s: adopting the barrier geometry the chart's scan chose - %.2f*ATR / %.2f*ATR."
|
|
" This member never ran the scan (the MI chain runs once per chart), and keeping"
|
|
" its own derived pair would put this ensemble's members on DIFFERENT targets"
|
|
" while the orchestrator averages their votes as one.",
|
|
ID, g_ensembleChartGeomSl, g_ensembleChartGeomTp);
|
|
ApplyAdoptedGeometry(g_ensembleChartGeomSl, g_ensembleChartGeomTp,
|
|
g_ensembleChartGeomSlMode, g_ensembleChartGeomTpMode);
|
|
}
|
|
}
|
|
else
|
|
if(m_crossAsset.IsReady() || m_miReportDeferrals >= MI_REPORT_MAX_DEFERRALS)
|
|
{
|
|
//--- THE LONGEST SINGLE STRETCH OF THE WARM-UP - the MI suite, the lag profile, the
|
|
//--- excursion targets and the geometry scan, each with its own few-hundred-draw
|
|
//--- permutation null - and until now it published NOTHING.
|
|
PublishStatus(ID + (m_ensembleMember
|
|
? " : measuring feature/label information for the whole chart..."
|
|
: " : measuring feature/label information..."));
|
|
ReportFeatureLabelInformation();
|
|
if(m_ensembleMember && m_miReportDone)
|
|
g_ensembleChartMiReportDone = true;
|
|
}
|
|
else
|
|
m_miReportDeferrals++;
|
|
}
|
|
Train(StartTrainBar);
|
|
}
|
|
#endif // WARRIOR_AIBASE_AUTOTUNE_MQH
|