forked from animatedread/Warrior_EA
1. THE POOL FIX WAS LANDING ON A TOPOLOGY THAT COULD NOT SEE IT. ComputeFirstLayerWidth budgets against EstimatedInSampleBars, which counts this chart's own bars PLUS the training pool. On a COLD fleet start every chart derives and pins its topology BEFORE any chart has published a pool file - measured on the 18:13 start, model creation at 18:13:21 against a first publish at 18:13:48. All six sized as if training alone, wrote that into .cfg, and adopted it back on every later start even with the pool full. SP500 ran a first layer floored to 16 while adopting 30229 peer rows. Adopt-don't-compare exists to protect weights shaped by those sizes. It was also running for a model with NO .nnw, where there is nothing to protect and the .cfg is just a record of one unlucky moment. The four derived sizes are now re-measured when no weights exist. Safe on all three counts that matter: free (nothing to discard), cannot loop (once weights exist the .cfg is authoritative again), and cannot fragment the pool - the derived width is NOT in BuildModelFingerprint, which keys only on the FEATURE layout. Verified: field 2 of the fingerprint is LEGACY_HISTORY_BARS_SLOT, not the first-layer width. TO TAKE EFFECT the weights must be wiped while the TrainPool is KEPT - the census has to be non-empty at derivation time. A full wipe empties the pool and reproduces the original condition exactly. 2. THE KEEP-SCREEN LATCHED ON AN UNDERPOWERED SAMPLE. MI_MIN_SAMPLES is a floor for "can this be computed", and it was being used as the bar for "is this answer final". The screen fired on the first era clearing 200 rows and latched, measuring at 202-773 samples where a warm chart gives ~2065. Columns kept then tracked SAMPLE SIZE rather than information - EURUSD kept 0 of 49 at n=202, SP500 kept 15 at n=773, and the ordering across all six charts was very nearly monotone in n. A thin sample is still measured and printed, but it no longer closes the question: below MI_GOOD_SAMPLE_FRACTION of the target the result is labelled underpowered and a later era supersedes it, bounded by the same attempt budget. An underpowered screen that latches is worse than one that waits, because it looks like a result. Build tag -> fleet-pool-v2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
350 lines
21 KiB
MQL5
350 lines
21 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| Warrior_EA |
|
|
//| AnimateDread |
|
|
//| |
|
|
//| The MI evidence screen - "does this feature vector predict this |
|
|
//| label at all", answered without training, topology or |
|
|
//| convergence, and the label-alignment lookahead scan. |
|
|
//+------------------------------------------------------------------+
|
|
#ifndef WARRIOR_AIBASE_FEATURESCREEN_MQH
|
|
#define WARRIOR_AIBASE_FEATURESCREEN_MQH
|
|
//+------------------------------------------------------------------+
|
|
//| SPLIT OUT OF AutoTune.mqh (2026-08-23): that file was two |
|
|
//| responsibilities - SEARCH (TuneIndicatorsByFilter, coordinate- |
|
|
//| descent over indicator settings) and MEASUREMENT (this file, which |
|
|
//| never mutates an indicator parameter). They share the MI engine |
|
|
//| that stays in AutoTune.mqh (FeatureColumnMI/BuildMiSample/ |
|
|
//| ScoreMiSample) because BOTH consume it - a genuine shared |
|
|
//| dependency, not a reason to keep the two responsibilities in one |
|
|
//| file. TuneIndicatorsAndTrain(), the entry point that decides which |
|
|
//| of the two branches a given model runs this process, also stays in |
|
|
//| AutoTune.mqh: it is the coordinator, not a member of either side. |
|
|
//| |
|
|
//| Still body-only method definitions of CExpertSignalAIBase, exactly |
|
|
//| like every other Expert\AIBase\*.mqh file - MQL5 has no partial |
|
|
//| classes, so this split is a FILE-organisation move (legibility, |
|
|
//| SRP-per-file) rather than a coupling reduction. See |
|
|
//| project_oop_module_pattern for what a REAL decoupling of this |
|
|
//| domain would need (a view + adapter, MQL5's single-inheritance |
|
|
//| tax) - not attempted here because nothing downstream yet needs one.|
|
|
//+------------------------------------------------------------------+
|
|
//+------------------------------------------------------------------+
|
|
//| "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)
|
|
{
|
|
//--- THE LATCH IS AT THE END, NOT HERE, AND THAT WAS A BUG WORTH A SESSION. It used to be set on
|
|
//--- entry, so a COLD start - where the label cache is allocated before it is filled, BuildMiSample
|
|
//--- finds no row carrying a label, and returns 0 - disabled the screen for the whole run on the
|
|
//--- first attempt. The first ensemble member then propagated it to g_ensembleChartMiReportDone and
|
|
//--- silenced every other member on the chart. Warm starts never showed it.
|
|
//--- PERMUTATION TEST, done properly.
|
|
double cols[];
|
|
int labels[];
|
|
int nSample = BuildMiSample(cols, labels);
|
|
//--- NOT ENOUGH SAMPLE IS NOT A MEASUREMENT. Reporting it as one printed "-1.00000 nats over 0
|
|
//--- permutations" beside a plausible-looking "strongest single feature 0.05979" - which was a
|
|
//--- STALE m_miBestColumn left by an earlier scoring call, not a number measured here. Say what
|
|
//--- happened, keep the flag clear so the next era retries, and print nothing that reads as data.
|
|
if(nSample < MI_MIN_SAMPLES)
|
|
{
|
|
m_miReportAttempts++;
|
|
bool giveUp = (m_miReportAttempts >= MI_REPORT_MAX_ATTEMPTS);
|
|
if(giveUp)
|
|
m_miReportDone = true;
|
|
Print(ID + StringFormat(": feature keep-screen DEFERRED - the usable sample holds %d row(s)"
|
|
" and %d are required (label cache %d bars, %d of them carrying a"
|
|
" resolved label). This is the cold-start ordering case: the cache is"
|
|
" allocated before it is filled. Attempt %d of %d%s",
|
|
nSample, MI_MIN_SAMPLES, m_labelCacheBars, LabelCacheResolvedCount(),
|
|
m_miReportAttempts, MI_REPORT_MAX_ATTEMPTS,
|
|
giveUp ? " - GIVING UP for this run; no keep-screen will be measured."
|
|
: " - retrying next era."));
|
|
return;
|
|
}
|
|
double observed = ScoreMiSample(cols, labels, nSample, false);
|
|
double signalBestCol = m_miBestColumn;
|
|
double labelEntropy = m_miLabelEntropy;
|
|
//--- OBSERVED PER-COLUMN MI, copied before the null loop overwrites m_miColumn on every draw.
|
|
double obsCol[];
|
|
int colN = (observed >= 0.0) ? ArraySize(m_miColumn) : 0;
|
|
int colAtLeast[];
|
|
if(colN > 0)
|
|
{
|
|
ArrayResize(obsCol, colN);
|
|
ArrayResize(colAtLeast, colN);
|
|
ArrayInitialize(colAtLeast, 0);
|
|
for(int f = 0; f < colN; f++)
|
|
obsCol[f] = m_miColumn[f];
|
|
}
|
|
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's spread prices the lookahead margin below.
|
|
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++;
|
|
//--- PER-COLUMN null, accumulated from the same draws. Each column gets its OWN null
|
|
//--- distribution here, which is what a per-column p-value needs - distinct from the
|
|
//--- null-of-the-max above, which answers the single family-wise question "is the strongest
|
|
//--- column real" and is far too conservative to select a SET with.
|
|
for(int f = 0; f < colN && f < ArraySize(m_miColumn); f++)
|
|
if(m_miColumn[f] >= obsCol[f])
|
|
colAtLeast[f]++;
|
|
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.
|
|
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 = PermutationPValue(atLeastMean, draws);
|
|
double pBestCol = PermutationPValue(atLeastBestCol, draws);
|
|
//--- 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";
|
|
//--- 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 %.1f-bar mean label "
|
|
"resolution, %.1fs) [%s]. %s.",
|
|
observed, floorMean, floorSd, draws, pMean,
|
|
signalBestCol, floorBestCol, pBestCol, excessShare, labelEntropy,
|
|
nSample, m_miStrideBars, m_miNullBlocks, MeanLabelLifespan(),
|
|
(GetTickCount() - tPerm) / 1000.0, vecNote, verdict));
|
|
//--- WHICH columns carry it, not just whether one does. Report-only: nothing prunes on this yet, and
|
|
//--- the number it produces is exactly what decides whether pruning is worth doing - a cut that keeps
|
|
//--- 3 of 52 columns is not a feature set, and one that keeps 45 is not worth a fingerprint re-key.
|
|
if(draws > 0 && colN > 0)
|
|
{
|
|
double pcol[];
|
|
ArrayResize(pcol, colN);
|
|
for(int f = 0; f < colN; f++)
|
|
pcol[f] = PermutationPValue(colAtLeast[f], draws);
|
|
double sorted[];
|
|
ArrayResize(sorted, colN);
|
|
ArrayCopy(sorted, pcol);
|
|
ArraySort(sorted);
|
|
//--- Benjamini-Hochberg: the largest k with p_(k) <= (k/m)*q; keep every column at or below it.
|
|
int keep = 0;
|
|
for(int k = 1; k <= colN; k++)
|
|
if(sorted[k - 1] <= (double)k / colN * MI_KEEP_FDR_Q)
|
|
keep = k;
|
|
double cutP = (keep > 0) ? sorted[keep - 1] : 0.0;
|
|
//--- THE KEPT SET AS A HEX BITMASK, column 0 = bit 0 of the first nibble-group. Emitted so the
|
|
//--- masks of two charts can be compared by eye and by grep. That comparison is the open
|
|
//--- question a mask cannot be built without: identical masks across the fleet mean ONE
|
|
//--- fleet-wide mask keeps every chart in a single pool group, while divergent masks would
|
|
//--- split six charts into six groups of one - and pooling is the only thing currently holding
|
|
//--- the FX charts above the capacity floor, so a prune could cost more than it buys.
|
|
string maskHex = "";
|
|
for(int b = 0; b < colN; b += 4)
|
|
{
|
|
int nib = 0;
|
|
for(int q = 0; q < 4 && b + q < colN; q++)
|
|
if(pcol[b + q] <= cutP && keep > 0)
|
|
nib |= (1 << q);
|
|
maskHex += StringFormat("%X", nib);
|
|
}
|
|
//--- The whole point of the exercise, stated in the units the capacity budget uses.
|
|
int widthNow = m_historyBars * colN;
|
|
int widthKept = m_historyBars * MathMax(keep, 1);
|
|
double effN = EffectiveSampleSize((double)EstimatedInSampleBarsRaw());
|
|
Print(ID + StringFormat(": feature keep-screen (REPORT ONLY, nothing prunes yet) - %d of %d"
|
|
" columns clear Benjamini-Hochberg at q=%.2f (cut p<=%.4f). Input width"
|
|
" would go %d -> %d (%d bars x columns), and the first-layer budget"
|
|
" effN/(width+1) %.1f -> %.1f against a %d-wide floor.%s",
|
|
keep, colN, MI_KEEP_FDR_Q, cutP, widthNow, widthKept, m_historyBars,
|
|
effN / (widthNow + 1.0), effN / (widthKept + 1.0), FIRST_LAYER_MIN_WIDTH,
|
|
(m_crossAsset.IsReady() ? ""
|
|
: " CAVEAT: measured with cross-asset ABSENT, so these columns are a"
|
|
" SUBSET of what training uses - a mask built from this would not"
|
|
" cover the cross-asset block at all.")));
|
|
Print(ID + ": feature keep-mask " + maskHex + " (hex, column 0 = low bit; compare across charts"
|
|
" before building a real mask - divergent masks would fragment the training pool).");
|
|
}
|
|
//--- LATCH ONLY ON A SAMPLE WORTH KEEPING. A thin sample still gets measured and printed - the
|
|
//--- numbers are real for what they are - but it does not close the question, so a later era with
|
|
//--- more resolved labels can supersede it. Bounded by the same attempt budget as the deferral path,
|
|
//--- so a chart that never reaches the target still ends up with its best available answer.
|
|
int goodSample = (int)(MI_SAMPLE_BARS * MI_GOOD_SAMPLE_FRACTION);
|
|
if(nSample < goodSample && m_miReportAttempts < MI_REPORT_MAX_ATTEMPTS - 1)
|
|
{
|
|
m_miReportAttempts++;
|
|
Print(ID + StringFormat(": that screen is UNDERPOWERED and does NOT close the question - %d"
|
|
" samples against a %d target (%d of %d cached bars carry a resolved"
|
|
" label). Columns kept at this sample size track POWER as much as"
|
|
" information. Re-measuring on a later era (attempt %d of %d).",
|
|
nSample, goodSample, LabelCacheResolvedCount(), m_labelCacheBars,
|
|
m_miReportAttempts, MI_REPORT_MAX_ATTEMPTS));
|
|
return;
|
|
}
|
|
//--- THE MEASUREMENT EXISTS NOW, so latch. Everything below is commentary and a positive control,
|
|
//--- each with its own teardown escape - none of it changes whether this era measured the screen.
|
|
m_miReportDone = true;
|
|
//--- 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 (%.1f-bar mean "
|
|
"label resolution, %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 "
|
|
"is what would settle it.", m_miNullBlocks, MeanLabelLifespan(),
|
|
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 = (int)MathMax(1.0, MeanLabelLifespan() / 4.0);
|
|
{
|
|
//--- 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 (mean resolution %.1f bars) "
|
|
"scores %.5f nats against the ~%.5f noise floor; by a quarter of that (%d bars) "
|
|
"it is already down to %.5f, which is how fast this target decorrelates. %s",
|
|
MeanLabelLifespan(),
|
|
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 swing leg 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 legs are already under "
|
|
"way, 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 5 bars into a leg than at the entry the model actually trades.",
|
|
farPast, atZero, (atZero > 1e-9 ? farPast / atZero : 0.0));
|
|
Print(ID + ": MI label-alignment scan (label from bar i+k; higher index = OLDER bar, so k<0 is the "
|
|
"future) - " + profile + alignVerdict);
|
|
}
|
|
#endif // WARRIOR_AIBASE_FEATURESCREEN_MQH
|