Warrior_EA/Expert/Training/BaselineComparator.mqh
AnimateDread 8f2164698b feat(target): delete the barrier/geometry stack - the label is the verdict
Step 3 of the swing-pivot plan, whole-hog. The swing label is now the ONE
target and the era verdict is precision + recall per class against the
label's own base rate - no win rate, no break-even, no expectancy, no
geometry anywhere in training.

DELETED
- Expert/Excursion/ (4), Expert/BarrierHorizon/ (4), GeometrySweep,
  FirstPassageLadder, Labeling/TripleBarrier.mqh (CLabelOverlap survives
  in Labeling/LabelOverlap.mqh), 3 test EAs.
- TripleBarrierLabel + walk, fractal label, geometry derivation/scan/
  adoption, exit-policy replay, excursion MI targets, the drift verdict
  (DIRECTION_INTELLIGENT), the recall floor, balanced-accuracy telemetry,
  the barrier defines, the .cfg geometry adopt (slots kept as zeros for
  the positional layout), the derived-geometry live-order override.
- TRAINING_TARGET input/enum: direction models are always swing; META2
  re-keys the meta head onto label agreement (descriptor loses its two
  geometry slots).

REWORKED
- Labels.mqh (1795 -> ~370 lines): AdvanceSwingLabelState with
  FINALITY-GATED CACHING - an unresolved bar (pivot pair uncommitted) is
  never cached, so it can never freeze as a false Neutral; training,
  calibration, OOS scoring and online learning all skip unresolved bars.
- SDeployVerdict: significance-only; SOosTally chance = larger
  directional class share; pooled gate poolability = timeframe (record v2).
- Purge/embargo/declustering gaps: the measured mean label resolution
  lag (LabelResolutionBars), not a barrier horizon.
- Pool purge key + backfill DB rows: marked at the bar the label
  resolved on (m_labelResolveAge), not a fabricated barrier touch.
- Online learning frontier: finality, not a horizon delay.
- m_bestBalancedOos -> m_bestSelectionScore, m_erasSinceBestBalanced ->
  m_erasSinceBest, ensemble vote outcome arrays -> label arrays.

STEP 4 folded in: Entry_Multiplier / SL_Mode / TP_Mode / tradingdirection
are inputs again - trade management is the tester GA's search space.

Fingerprints: every direction model re-keys (TGT:SWG1 now unconditional,
CUT token gone); META1 -> META2. Full retrain, as planned.

Compile-verified in _claude_stage: Warrior_EA + both surviving test EAs,
0 errors, 0 warnings each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 20:42:31 -04:00

915 lines
45 KiB
MQL5

//+------------------------------------------------------------------+
//| Warrior_EA |
//| AnimateDread |
//| |
//| CBaselineComparator - two non-NN learners on the NN's matrix. |
//| |
//+------------------------------------------------------------------+
#ifndef WARRIOR_TRAINING_BASELINECOMPARATOR_MQH
#define WARRIOR_TRAINING_BASELINECOMPARATOR_MQH
#include "ITrainingData.mqh"
//--- ONCE-PER-CHART, same doctrine as g_ensembleChartMiReportDone (AutoTune.mqh) and for a stronger
//--- reason. Four un-chunked forest+MLP+OLS fits over a ~1000-column, 4000-row design, to print the
//--- same numbers four times.
bool g_ensembleChartBaselinesDone = false;
//--- THE BOUND IS THE EA THREAD, NOT THE BAR. This pass used to be described as bounded because it
//--- "cannot outrun the bar it runs on" - four hours on H4. That is the wrong target and was never
//--- enforced: the EA is single threaded, so for as long as this runs there is no training, no
//--- panel, no tick handling, and a removal request just queues.
#define BASELINE_MAX_TRAIN_ROWS 4000
#define BASELINE_MAX_SCORE_ROWS 4000
//--- ROWS x COLUMNS, because every fit here costs O(rows x width) and the row caps above carry no
//--- width term. 800k = the 4000-row cap at the ~200-column window these numbers were chosen for;
//--- at this build's 800 columns it buys 1000 rows and an MLP fit of ~13 s instead of 52 s.
#define BASELINE_MAX_CELLS 800000
//--- Wall clock for the whole pass, checked between phases. Deliberately far below the ~4,500 ms
//--- OnDeinit budget's timescale being reached at all: the point is that a stop never has to wait.
#define BASELINE_BUDGET_MS 45000
//--- CALIBRATION POINT for the linear phase, which is the one that cannot be bounded by a boundary
//--- check. LRBuild solves a (width+1)^2 normal-equation system, so its cost grows as width^3 and
//--- does not care how few rows there are.
#define BASELINE_LR_MS_AT_800 535000.0
#define BASELINE_TREES 32
#define BASELINE_SUBSAMPLE_RATIO 0.66
//--- Three models are tried, so the family-wise correction is over three - the same rule the deploy
//--- gate applies to its own best-of-N selection. Reporting each model's uncorrected p as if it were
//--- the only one tried is precisely the error catalogued four times already.
#define BASELINE_MODELS_TRIED 3
//--- MLP baseline. Small and bounded on purpose: this is a CONTROL, not a competitor. One restart,
//--- a hard iteration cap, and the decay ALGLIB's own docs recommend when you have no reason to
//--- pick another.
#define BASELINE_MLP_HIDDEN 8
#define BASELINE_MLP_DECAY 0.001
#define BASELINE_MLP_RESTARTS 1
#define BASELINE_MLP_WSTEP 0.01
#define BASELINE_MLP_MAXITS 100
//--- Folds for the MLP's cross-validated error bar. Three, not the usual five or ten: each fold is
//--- a full retrain, so this multiplies the MLP's cost by exactly this number on a six-core 2013
//--- box.
#define BASELINE_MLP_CV_FOLDS 3
//--- Below this the fit is not worth reporting: an OOS slice this thin cannot resolve an edge from
//--- noise at any precision, and a train slice this thin cannot fit anything but its own rows.
#define BASELINE_MIN_ROWS 200
//--- Redundancy report: the share of total variance the leading components must reach before the
//--- count is called the matrix's effective width, and the |r| above which a pair is called a copy.
#define BASELINE_PCA_VAR_SHARE 0.95
#define BASELINE_COLLINEAR_ABS_R 0.90
//--- A regime test needs at least this many DECLUSTERED samples per half to say anything at all.
#define BASELINE_MIN_REGIME_N 20
//+------------------------------------------------------------------+
//| PUT TWO COMPLETELY DIFFERENT LEARNERS ON THE NET'S OWN MATRIX. |
//| |
//| One question, and the whole module exists to answer it: when the |
//| net scores at chance, is that the MATRIX or is it us? An Alglib |
//| random forest and an Alglib MLP fit the same rows, the same |
//| labels and the same split. All at chance means the data is the |
//| limit. An off-the-shelf MLP well ABOVE our net on those rows |
//| would mean the limit is our implementation. |
//| |
//| It is a CONTROL, not a competitor - which is why the models are |
//| small, capped and un-tuned on purpose. |
//| |
//| It knows nothing about CExpertSignalAIBase. It reads a |
//| CTrainingDataView and prints; that is the entire contract, and it |
//| is what lets this be read, changed or dropped on its own. |
//+------------------------------------------------------------------+
class CBaselineComparator
{
private:
//--- BORROWED. The signal owns both the view and this object.
CTrainingDataView *m_data;
//--- ONCE PER MODEL. Set before the first thing that can fail, so a run that bails does not
//--- retry every era for the rest of the session.
bool m_done;
int BaselineCandidateBars(const int lo, const int hi, const int bars,
const int cap, int &rows[]);
bool BaselineBudgetSpent(const uint startTick, const string nextPhase);
void ReportCombiningWeights(void);
double CombinerSSE(const double &w[], const int members, const double &s[],
const double &t[], const int rows);
void ReportLinearLagProfile(const int bars, const int oosCutoff);
void ReportFeatureRedundancy(CMatrixDouble &rows, const int n);
void ReportRegimeStability(double &margins[], int &marginBars[]);
void ReportBaselineModel(const string label, const int calls, const int hits,
const int scored, const double chancePct);
public:
CBaselineComparator(void) : m_data(NULL), m_done(false) { }
~CBaselineComparator(void) { m_data = NULL; }
//--- Hand it the view once, when the owner is constructed. Everything else it needs it asks
//--- for; there is deliberately no setter for anything else.
void Bind(CTrainingDataView *data) { m_data = data; }
//--- Already reported? The owner asks so it can say so rather than silently skipping.
bool Done(void) const { return m_done; }
void RunBaselineComparison(const int bars, const int totalIter, const int oosCutoff);
};
//+------------------------------------------------------------------+
//| Bar indices in [lo, hi) that carry a label and could carry a full |
//| feature window, thinned by a uniform stride to at most `cap`. |
//| |
//| The window check is a bounds test only - whether BufferTempData |
//| will actually produce the row is not known until it is asked, and |
//| asking is the expensive part. So this over-selects slightly and |
//| the callers report the count they really got. |
//+------------------------------------------------------------------+
int CBaselineComparator::BaselineCandidateBars(const int lo, const int hi, const int bars,
const int cap, int &rows[])
{
int all[];
ArrayResize(all, 0);
for(int r = MathMax(lo, 0); r < hi; r++)
{
//--- The window reaches BACK from r, so once it runs off the deep end every larger r does too.
if(r + m_data.HistoryBars() - 1 >= bars)
break;
//--- HasLabel is the whole resolvedness test: the finality-gated cache never holds an
//--- unresolved bar.
if(!m_data.HasLabel(r))
continue;
int n = ArraySize(all);
ArrayResize(all, n + 1);
all[n] = r;
}
int total = ArraySize(all);
if(total <= cap || cap <= 0)
{
ArrayResize(rows, total);
for(int k = 0; k < total; k++)
rows[k] = all[k];
return total;
}
//--- UNIFORM stride, not the newest `cap` rows: taking a contiguous block would hand the models a
//--- narrower stretch of market than the net trained on, and any difference in score would then be
//--- partly a difference in regime.
double stride = (double)total / cap;
ArrayResize(rows, cap);
for(int k = 0; k < cap; k++)
rows[k] = all[(int)MathMin(total - 1, (int)(k * stride))];
return cap;
}
//+------------------------------------------------------------------+
//| Stop requested, or the pass has spent its wall clock. Replaces |
//| the bare ShutdownRequested() checks at every phase boundary: a |
//| stop and a blown budget need the same answer, and only one of |
//| them used to be asked. |
//+------------------------------------------------------------------+
bool CBaselineComparator::BaselineBudgetSpent(const uint startTick, const string nextPhase)
{
if(m_data.Stopping())
return true;
uint spent = GetTickCount() - startTick;
if(spent < BASELINE_BUDGET_MS)
return false;
Print(m_data.Id() + StringFormat(": Alglib baselines stopping before %s - %.1f s spent of a %.0f s budget."
" Everything printed above is complete; %s and the phases after it did not"
" run. The EA is single threaded, so this pass freezes training, the panel"
" and tick handling for exactly as long as it takes.",
nextPhase, spent / 1000.0, BASELINE_BUDGET_MS / 1000.0, nextPhase));
return true;
}
//+------------------------------------------------------------------+
//| TRAIN AND SCORE THE TWO NON-NN BASELINES. Once per run, at a |
//| pass-3 completion, and only when the trader asked for it. |
//+------------------------------------------------------------------+
void CBaselineComparator::RunBaselineComparison(const int bars, const int totalIter,
const int oosCutoff)
{
//--- THE pointer test for this whole module. Every private helper below is reached only from
//--- here, so guarding the one public entry guards all of them - and an unbound comparator is a
//--- wiring bug that must be loud, not a pass that quietly reports nothing.
if(CheckPointer(m_data) == POINTER_INVALID)
{
Print(__FUNCTION__ + ": ERROR - baseline comparator has no data view bound. No baselines this"
" run; the models are unaffected but the MATRIX-vs-IMPLEMENTATION question goes"
" unanswered.");
return;
}
if(!Run_Alglib_Baselines || m_done)
return;
m_done = true; // set FIRST: a run that bails below must not retry every era
if(m_data.IsEnsembleMember() && g_ensembleChartBaselinesDone)
{
Print(m_data.Id() + ": Alglib baselines already measured by another ensemble member on this chart - same"
" windows, same labels, same three fits, same answer. Skipped; the first member's report"
" above is this model's too.");
return;
}
g_ensembleChartBaselinesDone = true;
int width = m_data.HistoryBars() * m_data.FeaturesPerBar();
if(width <= 0 || oosCutoff <= 0 || totalIter <= oosCutoff)
{
Print(m_data.Id() + ": Alglib baselines skipped - no usable era geometry yet.");
return;
}
uint t0 = GetTickCount();
if(BaselineBudgetSpent(t0, "the matrix build"))
return;
//--- SAME SPLIT AS THE NET, read off the same helpers rather than recomputed here. IS starts one
//--- purge past the calibration band's far edge, exactly where the backprop queue starts.
int isLo = m_data.CalibrationHiIndex(totalIter, oosCutoff) + m_data.PurgeBars();
int trainBars[], scoreBars[];
//--- Rows shrink as the window widens - see BASELINE_MAX_CELLS. Floored at BASELINE_MIN_ROWS so a
//--- very wide window skips below on "too few rows to say anything" rather than silently fitting one.
int rowCap = (int)MathMax(BASELINE_MAX_CELLS / width, BASELINE_MIN_ROWS);
int trainCap = MathMin(BASELINE_MAX_TRAIN_ROWS, rowCap);
int scoreCap = MathMin(BASELINE_MAX_SCORE_ROWS, rowCap);
if(trainCap < BASELINE_MAX_TRAIN_ROWS)
Print(m_data.Id() + StringFormat(": Alglib baselines - %d-column window caps this run at %d rows (was %d)."
" Every fit costs rows x columns, and the un-capped design is what got the"
" EA force-terminated on 2026-08-21.",
width, trainCap, BASELINE_MAX_TRAIN_ROWS));
int nTrainWanted = BaselineCandidateBars(isLo, totalIter, bars, trainCap, trainBars);
int nScoreWanted = BaselineCandidateBars(0, oosCutoff, bars, scoreCap, scoreBars);
if(nTrainWanted < BASELINE_MIN_ROWS || nScoreWanted < BASELINE_MIN_ROWS)
{
Print(m_data.Id() + StringFormat(": Alglib baselines skipped - %d train / %d score bars carry a label,"
" below the %d needed to say anything.",
nTrainWanted, nScoreWanted, BASELINE_MIN_ROWS));
return;
}
Print(m_data.Id() + StringFormat(": Alglib baselines starting - %d inputs (%d bars x %d features),"
" %d train rows, %d OOS rows. Same windows, labels and split as the net.",
width, m_data.HistoryBars(), m_data.FeaturesPerBar(), nTrainWanted, nScoreWanted));
//--- BUILD THE TRAIN MATRIX. Last column is the 3-class target the forest wants (0=Sell, 1=Neutral,
//--- 2=Buy); the linear fit reuses the same allocation with that column shifted to -1/0/+1 below,
//--- so the two models cannot end up looking at different rows.
CMatrixDouble xy(nTrainWanted, width + 1);
//--- ...and, at no extra feature cost, the ANCHOR BAR'S OWN ROW alone. BuildFeatureWindow
//--- appends deepest-lookback first and lands on the anchor last, so those are the final
//--- FeaturesPerBar() values of every window.
CMatrixDouble anchors(nTrainWanted, m_data.FeaturesPerBar());
double x[];
ArrayResize(x, width);
int nTrain = 0;
for(int k = 0; k < nTrainWanted; k++)
{
int r = trainBars[k];
if(!m_data.RowFeatures(r, width, x))
continue;
for(int f = 0; f < width; f++)
xy.Set(nTrain, f, x[f]);
for(int f = 0; f < m_data.FeaturesPerBar(); f++)
anchors.Set(nTrain, f, x[width - m_data.FeaturesPerBar() + f]);
double cls = m_data.IsBuyLabel(r) ? 2.0 : (m_data.IsSellLabel(r) ? 0.0 : 1.0);
xy.Set(nTrain, width, cls);
nTrain++;
}
if(nTrain < BASELINE_MIN_ROWS || BaselineBudgetSpent(t0, "the forest"))
{
Print(m_data.Id() + StringFormat(": Alglib baselines abandoned - only %d of %d train windows built.",
nTrain, nTrainWanted));
return;
}
//--- FOREST. Builder object, not the deprecated DFBuildRandomDecisionForest one-shot. Variables per
//--- split are left on the library's own auto rule: with a window this wide any hand-picked number
//--- would be a tuned knob, and a baseline that needed tuning to lose would prove nothing.
CDecisionForestBuilder builder;
CDecisionForest forest;
CDFReport frep;
CDForest::DFBuilderCreate(builder);
CDForest::DFBuilderSetDataset(builder, xy, nTrain, width, 3);
CDForest::DFBuilderSetSubsampleRatio(builder, BASELINE_SUBSAMPLE_RATIO);
CDForest::DFBuilderSetRndVarsAuto(builder);
CDForest::DFBuilderSetSeed(builder, 1);
CDForest::DFBuilderSetImportanceNone(builder);
CDForest::DFBuilderBuildRandomForest(builder, BASELINE_TREES, forest, frep);
Print(m_data.Id() + StringFormat(": forest built - %d trees, out-of-bag class error %.3f, OOB avg CE %.4f.",
BASELINE_TREES, frep.m_oobrelclserror, frep.m_oobavgce));
if(BaselineBudgetSpent(t0, "the MLP"))
return;
//--- MLP. ALGLIB's own multilayer perceptron on the identical matrix - the control that
//--- separates "our architecture is wrong" from "our IMPLEMENTATION is wrong".
CMultilayerPerceptron mlp;
CMLPReport mrep;
int mlpInfo = 0;
CMLPBase::MLPCreateC1(width, BASELINE_MLP_HIDDEN, 3, mlp);
uint mlpStart = GetTickCount();
CMLPTrain::MLPTrainLBFGS(mlp, xy, nTrain, BASELINE_MLP_DECAY, BASELINE_MLP_RESTARTS,
BASELINE_MLP_WSTEP, BASELINE_MLP_MAXITS, mlpInfo, mrep);
uint mlpMs = GetTickCount() - mlpStart;
bool mlpOK = (mlpInfo > 0);
//--- CAPACITY FIRST, because it decides what the training error below is allowed to mean.
int mlpWeights = (width + 1) * BASELINE_MLP_HIDDEN + (BASELINE_MLP_HIDDEN + 1) * 3;
if(mlpOK)
Print(m_data.Id() + StringFormat(": ALGLIB MLP trained in %.1f s - %d inputs -> %d hidden -> 3, %d gradient"
" evaluations, training class error %.3f, avg CE %.4f.%s",
mlpMs / 1000.0, width, BASELINE_MLP_HIDDEN, mrep.m_ngrad,
mrep.m_RelCLSError, mrep.m_AvgCE,
(mlpWeights >= nTrain)
? StringFormat(" NOTE: %d weights against %d rows - this fit can memorise"
" its own rows outright, so read the OOS score below and"
" not the training error.", mlpWeights, nTrain)
: ""));
else
Print(m_data.Id() + StringFormat(": ALGLIB MLP did not train (info %d) - skipped below.", mlpInfo));
if(BaselineBudgetSpent(t0, "the cross-validation"))
return;
//--- ...AND AN ERROR BAR ON IT. ReportBaselineModel gives every row a binomial SE, which is the
//--- sampling error of SCORING a fixed model. PREDICTED, not attempted. K-fold cross-validation
//--- measures that second variance directly, on the training rows only, so it never touches the
//--- OOS window the score below is taken on.
uint cvPredictedMs = mlpMs * BASELINE_MLP_CV_FOLDS;
uint cvSpentMs = GetTickCount() - t0;
bool cvFits = (cvSpentMs + cvPredictedMs < BASELINE_BUDGET_MS);
if(mlpOK && !cvFits)
Print(m_data.Id() + StringFormat(": ALGLIB MLP cross-validation SKIPPED - %d folds x %.1f s = ~%.1f s"
" predicted against %.1f s left of the budget. It is an error bar on the"
" score below, not the score itself, so the pass continues without it.",
BASELINE_MLP_CV_FOLDS, mlpMs / 1000.0, cvPredictedMs / 1000.0,
(BASELINE_BUDGET_MS - cvSpentMs) / 1000.0));
if(mlpOK && cvFits)
{
CMultilayerPerceptron cvNet;
CMLPReport cvTrainRep;
CMLPCVReport cvRep;
int cvInfo = 0;
CMLPBase::MLPCreateC1(width, BASELINE_MLP_HIDDEN, 3, cvNet);
CMLPTrain::MLPKFoldCVLBFGS(cvNet, xy, nTrain, BASELINE_MLP_DECAY, BASELINE_MLP_RESTARTS,
BASELINE_MLP_WSTEP, BASELINE_MLP_MAXITS, BASELINE_MLP_CV_FOLDS,
cvInfo, cvTrainRep, cvRep);
if(cvInfo > 0)
Print(m_data.Id() + StringFormat(": ALGLIB MLP %d-fold CV - held-out class error %.3f vs %.3f in"
" training (avg CE %.4f vs %.4f). A large gap is the fit memorising"
" its own rows, and it caps what the OOS row below can mean.",
BASELINE_MLP_CV_FOLDS, cvRep.m_RelCLSError, mrep.m_RelCLSError,
cvRep.m_AvgCE, mrep.m_AvgCE));
else
Print(m_data.Id() + StringFormat(": ALGLIB MLP cross-validation did not run (info %d).", cvInfo));
if(BaselineBudgetSpent(t0, "the linear fit"))
return;
}
//--- LINEAR. Same rows, same columns, target shifted from the class index to a signed direction.
double lrPredictedMs = BASELINE_LR_MS_AT_800 * MathPow(width / 800.0, 3.0);
double lrLeftMs = (double)BASELINE_BUDGET_MS - (double)(GetTickCount() - t0);
bool lrIdentified = (nTrain > width);
bool lrAffordable = (lrPredictedMs < lrLeftMs);
CLinearModel linear;
CLRReport lrep;
int lrInfo = 0;
bool linearOK = false;
if(!lrIdentified || !lrAffordable)
Print(m_data.Id() + StringFormat(": linear baseline SKIPPED - %s. The forest and the MLP below are"
" unaffected; only the linear row is missing.",
!lrIdentified
? StringFormat("%d rows against %d columns, so the normal equations are"
" singular and any coefficients returned would be one"
" arbitrary solution of infinitely many", nTrain, width)
: StringFormat("~%.0f s predicted at %d columns (cost grows as width^3)"
" against %.0f s left of the budget", lrPredictedMs / 1000.0,
width, lrLeftMs / 1000.0)));
else
{
for(int k = 0; k < nTrain; k++)
xy.Set(k, width, xy.Get(k, width) - 1.0);
CLinReg::LRBuild(xy, nTrain, width, lrInfo, linear, lrep);
linearOK = (lrInfo > 0);
if(!linearOK)
Print(m_data.Id() + StringFormat(": linear baseline did not fit (Alglib info %d) - forest only below.",
lrInfo));
}
if(BaselineBudgetSpent(t0, "the OOS scoring"))
return;
//--- SCORE BOTH ON THE OOS SLICE, in the deploy gate's own currency: a call is a directional
//--- prediction, a hit is that direction WINNING at the measured geometry (the view's Outcome),
//--- and the benchmark is the always-call-one-direction win rate over the same bars. See the
//--- chancePrecPct derivation in Train() for why that benchmark and not the label base rate.
int scored = 0, winLongN = 0, winShortN = 0;
int fCalls = 0, fHits = 0, lCalls = 0, lHits = 0, mCalls = 0, mHits = 0;
double y[], my[];
//--- THE NET'S OWN per-bar outcome, collected on the same walk for the regime test below. Paired
//--- with the bar index so the test can decluster before it computes a p-value.
double margins[];
int marginBars[];
ArrayResize(margins, 0);
ArrayResize(marginBars, 0);
for(int k = 0; k < nScoreWanted; k++)
{
int r = scoreBars[k];
if(!m_data.RowFeatures(r, width, x))
continue;
scored++;
bool wl = m_data.IsBuyLabel(r), ws = m_data.IsSellLabel(r);
if(wl) winLongN++;
if(ws) winShortN++;
//--- The arrow cache holds pass 3's ADJUSTED decision per scored bar, and pass 3 has just
//--- finished, so it is complete for this era at exactly this moment (it is wiped at the NEXT
//--- era start).
bool calledBuy = false;
double calledMag = 0.0;
if(m_data.DirectionalCall(r, calledBuy, calledMag))
{
int mi = ArraySize(margins);
ArrayResize(margins, mi + 1);
ArrayResize(marginBars, mi + 1);
//--- SIGNED BY WHETHER IT WAS RIGHT, not by which way it pointed: the regime test asks
//--- whether the model's confidence still tracks its outcomes, so a confident loser has
//--- to score negative.
margins[mi] = calledMag * ((calledBuy ? wl : ws) ? 1.0 : -1.0);
marginBars[mi] = r;
}
//--- Forest: argmax over the three class probabilities. A Neutral argmax is an abstention and
//--- enters neither the numerator nor the denominator, exactly as the net's own Neutral does.
CDForest::DFProcess(forest, x, y);
if(ArraySize(y) == 3)
{
int arg = 0;
for(int c = 1; c < 3; c++)
if(y[c] > y[arg])
arg = c;
if(arg != 1) // 1 == Neutral == abstain
{
fCalls++;
if(arg == 2 ? wl : ws)
fHits++;
}
}
//--- MLP: argmax over the softmax outputs, Neutral abstains - scored exactly as the forest is.
if(mlpOK)
{
CMLPBase::MLPProcess(mlp, x, my);
if(ArraySize(my) == 3)
{
int marg = 0;
for(int c = 1; c < 3; c++)
if(my[c] > my[marg])
marg = c;
if(marg != 1)
{
mCalls++;
if(marg == 2 ? wl : ws)
mHits++;
}
}
}
//--- Linear: the sign of the fitted value. No threshold sweep - a threshold fitted on this same
//--- slice is the calibration leak this codebase carved a purged band to avoid, and a baseline
//--- allowed one operating point per candidate would be the best-of-N problem all over again.
if(linearOK)
{
double pred = CLinReg::LRProcess(linear, x);
if(MathIsValidNumber(pred) && pred != 0.0)
{
lCalls++;
bool won = (pred > 0.0) ? wl : ws;
if(won)
lHits++;
}
}
}
if(scored < BASELINE_MIN_ROWS)
{
Print(m_data.Id() + StringFormat(": Alglib baselines scored only %d OOS windows - nothing to report.",
scored));
return;
}
double chancePct = 100.0 * MathMax(winLongN, winShortN) / scored;
ReportBaselineModel("forest", fCalls, fHits, scored, chancePct);
if(mlpOK)
ReportBaselineModel("alglib-mlp", mCalls, mHits, scored, chancePct);
if(linearOK)
ReportBaselineModel("linear", lCalls, lHits, scored, chancePct);
double gatePrec = -1.0, gateChance = -1.0;
int gateCalls = 0;
m_data.GateReference(gatePrec, gateCalls, gateChance);
Print(m_data.Id() + StringFormat(": baseline reference - the net's own gate on this chart last read"
" %.1f%% precision on %d calls against %.1f%% chance. Read the rows above"
" against it: all at chance means the MATRIX is the limit, not the topology;"
" alglib-mlp well ABOVE our own net on the same rows would point at our"
" IMPLEMENTATION rather than at the data.",
gatePrec, gateCalls, gateChance));
if(BaselineBudgetSpent(t0, "the redundancy / regime / lag reports"))
return;
ReportFeatureRedundancy(anchors, nTrain);
ReportRegimeStability(margins, marginBars);
ReportLinearLagProfile(bars, oosCutoff);
ReportCombiningWeights();
}
//+------------------------------------------------------------------+
//| WHAT WOULD THE BEST MIX OF MEMBERS HAVE BEEN? |
//+------------------------------------------------------------------+
void CBaselineComparator::ReportCombiningWeights(void)
{
//--- ONE MEMBER RUNS THIS. The vote rows are ensemble-global, so every member would otherwise fit
//--- and print the identical answer once each.
if(!m_data.IsEnsembleMember() || m_data.EnsembleIndex() != 0 || g_ensVoteRows < BASELINE_MIN_ROWS)
return;
//--- Which slots actually carry a member this run - the registry can be sparse.
int slot[ENS_MAX_MEMBERS], members = 0;
for(int m = 0; m < ENS_MAX_MEMBERS; m++)
{
bool seen = false;
for(int r = 0; r < g_ensVoteRows && !seen; r++)
if(g_ensVoteMember[r * ENS_MAX_MEMBERS + m] != 0.0)
seen = true;
if(seen)
slot[members++] = m;
}
if(members < 2)
return; // nothing to combine
//--- Declustered, for the reason set out in ReportRegimeStability(): overlapping labels are not
//--- independent draws, and a mixture fitted on all of them is fitted to ~L copies of each bar.
int gap = (int)MathMax(m_data.LabelResolutionBars(), 1);
double sMat[], tVec[];
ArrayResize(sMat, g_ensVoteRows * members);
ArrayResize(tVec, g_ensVoteRows);
int rows = 0, lastRow = -1;
for(int r = 0; r < g_ensVoteRows; r++)
{
if(lastRow >= 0 && (r - lastRow) < gap)
continue;
bool wl = g_ensVoteLabelBuy[r], ws = g_ensVoteLabelSell[r];
double t = (wl && !ws) ? 1.0 : ((ws && !wl) ? -1.0 : 0.0);
for(int m = 0; m < members; m++)
sMat[rows * members + m] = g_ensVoteMember[r * ENS_MAX_MEMBERS + slot[m]] / 100.0;
tVec[rows++] = t;
lastRow = r;
}
if(rows < BASELINE_MIN_ROWS)
{
Print(m_data.Id() + StringFormat(": combining weights - %d vote rows decluster to %d independent bars at"
" a %d-bar horizon, too few to fit %d weights on.",
g_ensVoteRows, rows, gap, members));
return;
}
//--- Start at the equal mix, which is also the honest null: "no member deserves more than another".
double w[], bndl[], bndu[];
ArrayResize(w, members);
ArrayResize(bndl, members);
ArrayResize(bndu, members);
for(int m = 0; m < members; m++)
{
w[m] = 1.0 / members;
bndl[m] = 0.0;
bndu[m] = 1.0;
}
double sseEqual = CombinerSSE(w, members, sMat, tVec, rows);
//--- sum(w) == 1, as one linear equality row: [1 1 ... 1 | 1] with ct = 0 meaning "=".
CMatrixDouble lc(1, members + 1);
int ct[];
ArrayResize(ct, 1);
ct[0] = 0;
for(int m = 0; m < members; m++)
lc.Set(0, m, 1.0);
lc.Set(0, members, 1.0);
CMinBLEICState state;
CMinBLEICReport rep;
//--- CreateF, i.e. NUMERICAL gradients: with at most ENS_MAX_MEMBERS unknowns a finite-difference
//--- gradient is members+1 evaluations, which is nothing against the alternative of hand-deriving
//--- and maintaining an analytic one for a diagnostic.
CMinBLEIC::MinBLEICCreateF(members, w, 1.0e-6, state);
CMinBLEIC::MinBLEICSetBC(state, bndl, bndu);
CMinBLEIC::MinBLEICSetLC(state, lc, ct, 1);
CMinBLEIC::MinBLEICSetCond(state, 0.0, 0.0, 1.0e-8, 200);
//--- Driven by hand rather than through MinBLEICOptimize's delegate: the objective needs the two
//--- local matrices above, and a CNDimensional_Func subclass would have to smuggle them through a
//--- CObject to reach them.
double trial[];
ArrayResize(trial, members);
while(CMinBLEIC::MinBLEICIteration(state))
{
if(!state.m_needf)
continue;
for(int m = 0; m < members; m++)
trial[m] = state.m_x[m];
state.m_f = CombinerSSE(trial, members, sMat, tVec, rows);
}
double fitted[];
CMinBLEIC::MinBLEICResults(state, fitted, rep);
if(rep.m_terminationtype <= 0 || ArraySize(fitted) < members)
{
Print(m_data.Id() + StringFormat(": combining weights - MinBLEIC did not converge (termination %d).",
rep.m_terminationtype));
return;
}
double sseFit = CombinerSSE(fitted, members, sMat, tVec, rows);
string line = "";
for(int m = 0; m < members; m++)
//--- Slot index, not a name: g_aiSignals[] is declared in Warrior_EA.mq5, which is compiled
//--- AFTER this partial, so the registry is not reachable from here. The startup census line
//--- maps index to model id.
line += StringFormat(" m%d=%.3f", slot[m], fitted[m]);
//--- ...and the same mixture judged on the decision the EA actually makes: sign of the blend
//--- against which direction paid. This is the number that decides whether the fit meant anything.
int fitCalls = 0, fitHits = 0, eqCalls = 0, eqHits = 0;
for(int r = 0; r < rows; r++)
{
double bf = 0.0, be = 0.0;
for(int m = 0; m < members; m++)
{
bf += fitted[m] * sMat[r * members + m];
be += sMat[r * members + m] / members;
}
if(bf != 0.0 && tVec[r] != 0.0)
{
fitCalls++;
if((bf > 0.0) == (tVec[r] > 0.0))
fitHits++;
}
if(be != 0.0 && tVec[r] != 0.0)
{
eqCalls++;
if((be > 0.0) == (tVec[r] > 0.0))
eqHits++;
}
}
Print(m_data.Id() + StringFormat(": combining weights over %d independent bars -%s | SSE %.4f fitted vs"
" %.4f at the equal mix | directional hit rate %.1f%% (%d) vs %.1f%% (%d)"
" equal-weighted. %s",
rows, line, sseFit, sseEqual,
(fitCalls > 0 ? 100.0 * fitHits / fitCalls : 0.0), fitCalls,
(eqCalls > 0 ? 100.0 * eqHits / eqCalls : 0.0), eqCalls,
"Reported only - the live weights are unchanged, because moving them moves"
" the vote the deploy gate certifies."));
}
//+------------------------------------------------------------------+
//| Mean squared error of a weighted blend against the signed outcome.|
//| The objective MinBLEIC minimises above, and the number the equal |
//| mix is scored on, so both readings come from one expression. |
//+------------------------------------------------------------------+
double CBaselineComparator::CombinerSSE(const double &w[], const int members, const double &s[],
const double &t[], const int rows)
{
if(rows <= 0 || members <= 0)
return 0.0;
double acc = 0.0;
for(int r = 0; r < rows; r++)
{
double blend = 0.0;
for(int m = 0; m < members; m++)
blend += w[m] * s[r * members + m];
double e = blend - t[r];
acc += e * e;
}
return acc / rows;
}
//+------------------------------------------------------------------+
//| EVERY LAG AT ONCE, via FFT cross-correlation. |
//+------------------------------------------------------------------+
void CBaselineComparator::ReportLinearLagProfile(const int bars, const int oosCutoff)
{
int nvars = m_data.FeaturesPerBar();
int width = m_data.HistoryBars() * m_data.FeaturesPerBar();
if(nvars < 1 || width <= 0)
return;
//--- Newest-first walk over a contiguous OOS stretch, capped so one FFT stays cheap.
int want = (int)MathMin(BASELINE_MAX_SCORE_ROWS, oosCutoff);
double x[], series[], target[];
ArrayResize(x, width);
ArrayResize(series, want * nvars);
ArrayResize(target, want);
int n = 0;
for(int r = 0; r < oosCutoff && n < want; r++)
{
if(r + m_data.HistoryBars() - 1 >= bars)
break;
if(!m_data.HasLabel(r))
break; // a hole breaks contiguity - stop rather than splice across it
if(!m_data.RowFeatures(r, width, x))
break;
for(int f = 0; f < nvars; f++)
series[n * nvars + f] = x[width - nvars + f];
//--- Signed label. Neutral bars carry no direction and enter as 0 rather than being dropped,
//--- which would break the contiguity a lag index depends on.
bool wl = m_data.IsBuyLabel(r), ws = m_data.IsSellLabel(r);
target[n] = (wl && !ws) ? 1.0 : ((ws && !wl) ? -1.0 : 0.0);
n++;
}
if(n < BASELINE_MIN_ROWS)
{
Print(m_data.Id() + StringFormat(": linear lag profile skipped - only %d contiguous OOS bars.", n));
return;
}
//--- The band a correlation of exactly nothing still fluctuates inside, ~1/sqrt(n) per lag. Read
//--- against the MAXIMUM over all lags and every column, so it is a family of n*nvars draws - the
//--- best-of-N null, not a single one. Sidak over that family, not a bare 2-sigma line.
double noise = 1.0 / MathSqrt((double)n);
double bestAbs = 0.0;
int bestLag = 0, bestCol = -1;
double tgt[], col[], corr[];
ArrayResize(tgt, n);
for(int i = 0; i < n; i++)
tgt[i] = target[i];
ArrayResize(col, n);
for(int f = 0; f < nvars; f++)
{
for(int i = 0; i < n; i++)
col[i] = series[i * nvars + f];
//--- Both series are mean-centred first: CorrR1D is a raw sum of products, so a column with a
//--- non-zero mean would return its own mean times the target's at every lag and swamp the
//--- structure being looked for.
double mc = MathMean(col), mt = MathMean(tgt);
double cc[], tc[];
ArrayResize(cc, n);
ArrayResize(tc, n);
double sc = 0.0, st = 0.0;
for(int i = 0; i < n; i++)
{
cc[i] = col[i] - mc;
tc[i] = tgt[i] - mt;
sc += cc[i] * cc[i];
st += tc[i] * tc[i];
}
if(sc <= 0.0 || st <= 0.0)
continue; // a constant column correlates with nothing
double denom = MathSqrt(sc * st);
CCorr::CorrR1D(cc, n, tc, n, corr);
int cn = ArraySize(corr);
for(int k = 0; k < cn; k++)
{
double rr = MathAbs(corr[k]) / denom;
if(rr > bestAbs)
{
bestAbs = rr;
//--- CorrR1D returns the non-circular correlation with negative lags first; index n-1 is
//--- lag 0, so this reports the offset in bars with its sign.
bestLag = k - (n - 1);
bestCol = f;
}
}
if(m_data.Stopping())
return;
}
if(bestCol < 0)
{
Print(m_data.Id() + ": linear lag profile - every column was constant over the window.");
return;
}
double z = bestAbs / noise;
double pFamily = SidakFamilyP(z, n * nvars);
Print(m_data.Id() + StringFormat(": linear lag profile - strongest of %d columns x %d lags is column %d at"
" lag %+d, |r| %.4f (%.1f SE of the %.4f no-information band)."
" Family-wise p %.4f -> %s.",
nvars, 2 * n - 1, bestCol, bestLag, bestAbs, z, noise, pFamily,
(pFamily <= DEPLOY_FAMILY_WISE_ALPHA
? "SURVIVES the best-of-N null - worth a look"
: "consistent with no linear structure at any lag")));
}
//+------------------------------------------------------------------+
//| HOW WIDE IS THE MATRIX REALLY? Two readings of the same |
//| question, both on the anchor bar's own feature row. |
//+------------------------------------------------------------------+
void CBaselineComparator::ReportFeatureRedundancy(CMatrixDouble &rows, const int n)
{
int nvars = m_data.FeaturesPerBar();
if(n < BASELINE_MIN_ROWS || nvars < 2)
return;
//--- PAIRWISE. The worst offender by absolute correlation, plus how many pairs are effectively
//--- one column wearing two names.
CMatrixDouble corr;
if(CBaseStat::PearsonCorrM(rows, n, nvars, corr))
{
int pairs = 0, wi = -1, wj = -1;
double worst = 0.0;
for(int i = 0; i < nvars; i++)
for(int j = i + 1; j < nvars; j++)
{
double r = MathAbs(corr.Get(i, j));
if(!MathIsValidNumber(r))
continue;
if(r >= BASELINE_COLLINEAR_ABS_R)
pairs++;
if(r > worst)
{
worst = r;
wi = i;
wj = j;
}
}
Print(m_data.Id() + StringFormat(": matrix redundancy - %d of %d column pairs correlate above %.2f;"
" worst is columns %d/%d at |r| %.3f.",
pairs, nvars * (nvars - 1) / 2, BASELINE_COLLINEAR_ABS_R, wi, wj, worst));
}
//--- EFFECTIVE WIDTH. PCABuildBasis returns the component variances in decreasing order, so the
//--- count needed to reach BASELINE_PCA_VAR_SHARE is the number of directions the columns really
//--- span. Far cheaper here than on the full window: nvars^2, not (historyBars*nvars)^2.
int info = 0;
double s2[];
CMatrixDouble basis;
CPCAnalysis::PCABuildBasis(rows, n, nvars, info, s2, basis);
if(info <= 0 || ArraySize(s2) < nvars)
{
Print(m_data.Id() + StringFormat(": matrix redundancy - PCA did not converge (info %d).", info));
return;
}
double total = 0.0;
for(int i = 0; i < nvars; i++)
total += MathMax(0.0, s2[i]);
if(total <= 0.0)
return;
double cum = 0.0;
int need = nvars;
for(int i = 0; i < nvars; i++)
{
cum += MathMax(0.0, s2[i]);
if(cum / total >= BASELINE_PCA_VAR_SHARE)
{
need = i + 1;
break;
}
}
Print(m_data.Id() + StringFormat(": matrix redundancy - %d of %d columns carry %.0f%% of the variance"
" (top component alone %.0f%%). The window multiplies this by %d lags,"
" so the net sees ~%d effective inputs, not %d.",
need, nvars, BASELINE_PCA_VAR_SHARE * 100.0, 100.0 * s2[0] / total,
m_data.HistoryBars(), need * m_data.HistoryBars(),
m_data.HistoryBars() * nvars));
}
//+------------------------------------------------------------------+
//| HAS THE EDGE MOVED? Mann-Whitney U on the net's own signed |
//| margin (conviction, signed by whether the conviction paid), |
//| older half of the OOS window against newer. |
//+------------------------------------------------------------------+
void CBaselineComparator::ReportRegimeStability(double &margins[], int &marginBars[])
{
int n = ArraySize(margins);
if(n < 2 * BASELINE_MIN_REGIME_N)
return;
//--- One sample per label resolution lag. These are MQL5 timeseries indices, so the array ascends
//--- in index while descending in TIME - element 0 is the newest scored call.
int gap = (int)MathMax(m_data.LabelResolutionBars(), 1);
double kept[];
ArrayResize(kept, n);
int m = 0, lastBar = -1;
for(int i = 0; i < n; i++)
{
if(lastBar >= 0 && MathAbs(marginBars[i] - lastBar) < gap)
continue;
kept[m++] = margins[i];
lastBar = marginBars[i];
}
if(m < 2 * BASELINE_MIN_REGIME_N)
{
Print(m_data.Id() + StringFormat(": regime test skipped - %d scored calls decluster to only %d"
" independent samples at a %d-bar label horizon, below the %d per half"
" the test needs.", n, m, gap, BASELINE_MIN_REGIME_N));
return;
}
//--- kept[] is newest-first, so the SECOND half is the older window.
int half = m / 2;
double recent[], older[];
ArrayResize(recent, half);
ArrayResize(older, m - half);
for(int i = 0; i < half; i++)
recent[i] = kept[i];
for(int i = half; i < m; i++)
older[i - half] = kept[i];
double both = 1.0, left = 1.0, right = 1.0;
CMannWhitneyU::CMannWhitneyUTest(older, m - half, recent, half, both, left, right);
double meanOld = MathMean(older), meanNew = MathMean(recent);
Print(m_data.Id() + StringFormat(": regime test - signed margin over %d independent OOS calls"
" (%d older / %d newer, declustered from %d at a %d-bar horizon):"
" mean %+.3f -> %+.3f, Mann-Whitney p %.4f -> %s.",
m, m - half, half, n, gap, meanOld, meanNew, both,
(both <= 0.05 ? (meanNew < meanOld ? "DISTRIBUTION SHIFTED, and downward"
: "distribution shifted, upward")
: "no detectable shift")));
}
//+------------------------------------------------------------------+
//| One baseline's verdict, through the deploy gate's arithmetic. |
//+------------------------------------------------------------------+
void CBaselineComparator::ReportBaselineModel(const string label, const int calls, const int hits,
const int scored, const double chancePct)
{
if(calls <= 0)
{
Print(m_data.Id() + StringFormat(": baseline %s - abstained on all %d OOS windows. No edge measurable.",
label, scored));
return;
}
double precPct = 100.0 * hits / calls;
//--- DEFLATED, like the NN's own DEPLOY BAR is. These calls are consecutive bars sharing
//--- overlapping labels, not independent trades, so raw n understates the SE by ~sqrt(L) - and
//--- this line exists to be compared against the net measured on the SAME windows. Judging the
//--- baseline on the more permissive standard is how a forest at +1.0 SE reads as +3.6.
double effN = m_data.EffectiveSampleSize((double)calls);
double se = BinomialSEPct(chancePct / 100.0, effN);
if(se <= 0.0)
{
Print(m_data.Id() + StringFormat(": baseline %s - %.1f%% on %d calls, but chance %.1f%% is degenerate"
" so no test applies.", label, precPct, calls, chancePct));
return;
}
double z = (precPct - chancePct) / se;
double pFamily = SidakFamilyP(z, BASELINE_MODELS_TRIED);
Print(m_data.Id() + StringFormat(": baseline %s - %.1f%% precision on %d calls (%.0f%% coverage, worth"
" %.0f INDEPENDENT ones after the label overlap) vs %.1f%% chance | edge"
" %+.1fpp = %+.2f SE | family-wise p %.4f -> %s.",
label, precPct, calls, 100.0 * calls / scored, effN, chancePct,
precPct - chancePct, z, pFamily,
(pFamily <= DEPLOY_FAMILY_WISE_ALPHA ? "CLEARS the deploy gate"
: "does not clear")));
}
#endif