forked from animatedread/Warrior_EA
~2,300 lines. META had real, repeatedly measured ranking skill and ZERO
operating points that ever cleared break-even (0/350 H1 eras, 1/999 H4
pre-2-sigma, 0/8 pooled fitted points). The clinching arithmetic was edge x
width = 0.095 ATR/trade against spread 0.099 ATR/trade, and the
dose-response showed the high-conviction tail is temporally unstable -
the precision-vs-threshold slope flips sign between calib and test on 3 of
4 symbols, so no ex-ante threshold rule exists. It shipped default-off and
never gated a live entry. The self-measured tier weights are what actually
rank the vote, and all six H4 instruments converged on them alone.
RETRAIN-NEUTRAL, and that is the property that made this safe:
- The weights fingerprint emitted "|TGT:META2" or "|TGT:SWG1" from an
if/else. Every direction model already took the SWG1 arm, so
collapsing it to an unconditional append is byte-identical. No .nnw or
.cfg is orphaned or re-keyed.
- NetInputWidth() lost its "+ MetaDescWidth()" term. MetaDescWidth()
returned 0 for every direction model, so the input layer is unchanged.
- DbLegacyAiSlot()'s slot 5 was reachable only with all four Use_* NNs
off AND meta on - a config that never shipped. Every existing .db keeps
its filename.
Deleted outright: Signals/SignalMETA.mqh, Expert/Trading/MetaGate.mqh (the
directory is now empty), Expert/Training/{MetaCorpus,MetaCandidateStore,
MetaFamilies}.mqh, Tests/Test_MetaFamilies.mq5, Meta_Labeling_Design.md.
Unwound in place, the delicate part: Training.mqh carried four
IsMetaTarget() branches whose else-arm WRAPPED the direction body (pass 1
queueing, pass 2 backprop, pass 2.5 calibration, pass 3 OOS scoring). Each
wrapper is removed and the direction body promoted back to its original
nesting - the bodies were never re-indented when the wrappers were added,
so the promoted code is byte-identical to what ran before META existed.
Also gone: the ensemble verdict's meta-veto replay and its
approved/vetoed/unscored counters, the per-family/per-side OOS
decomposition arrays, the m_isTrainQueueCand parallel queue and its
lockstep shuffle, and the S2 era report.
Also removed: the CMetaGate abstraction and the live CheckOpenPosition
veto; m_gates plus AddFilter's non-voter routing and IsVotingSignal()
(META was the only non-voting child, so m_gates was always empty);
m_parentSignal/SetParentSignal (existed only to reach the root's gate);
SweepPrepare/SweepPrepareIndicator (only caller was the corpus sweep);
IsMetaTarget() from all four view interfaces and their adapters;
Use_MetaLabeling, EnableMETA, Meta_ExportDataset, m_trainTarget.
EvalShift is KEPT - HistoricalNetVote() uses it for the filtered overlay,
not just the corpus sweep; only its comment changed. The 2-output softmax
arm in NetForward.mqh is kept too: it costs nothing and is the reusable
binary-head path, now commented as unclaimed rather than as META's.
Compile-verified in _claude_stage: 0 errors, 0 warnings, matching the
pre-edit baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
557 lines
28 KiB
MQL5
557 lines
28 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//+------------------------------------------------------------------+
|
|
//| Warrior_EA |
|
|
//| AnimateDread |
|
|
//| |
|
|
//| Read-time signal production: softmax, prior calibration, class p |
|
|
//+------------------------------------------------------------------+
|
|
#ifndef WARRIOR_AIBASE_INFERENCE_MQH
|
|
#define WARRIOR_AIBASE_INFERENCE_MQH
|
|
//+------------------------------------------------------------------+
|
|
//| Post-convergence "new bar" handler - see ScheduleTrainingIfNeeded()|
|
|
//| for why this exists: once m_trainingComplete is true, a plain new |
|
|
//| bar must NOT re-enter Train()'s full era loop (which resets the |
|
|
//| best-checkpoint/g_eta-decay tracking and runs real Net.backProp() |
|
|
//| passes again, silently perturbing an already-converged model |
|
|
//| forever, once per bar, with no way to ever actually finish). This |
|
|
//| only refreshes the price/indicator buffers and re-runs inference |
|
|
//| for the newest bar so dPrevSignal/the chart arrow stay current - |
|
|
//| identical cost to what Train() does per-bar, minus every bit of |
|
|
//| training (label caching, backProp, checkpointing). |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::RefreshConvergedSignal(void)
|
|
{
|
|
//--- Size the buffers from what the FEATURE BUILDER actually needs, not from a date delta. The
|
|
//--- result was silent - no error, no short window, just inference computing DIFFERENT features
|
|
//--- from the ones training learned on.
|
|
int need = (int)m_historyBars + SWING_SCAN_CAP_BARS + 2;
|
|
int barsNow = (int)MathMin(need, Bars(m_symbol.Name(), PERIOD_CURRENT));
|
|
//--- HOLD RATHER THAN TRADE ON A SHORT WINDOW. `need` is the depth the feature builder requires
|
|
//--- for inference features to match the ones training learned on; a shallower buffer does not
|
|
//--- fail, it makes the swing block take its graceful degraded path - which is precisely the
|
|
//--- silent feature-mismatch this whole `need` calculation was introduced to end (see above).
|
|
int servable = ServableBars(need, "live inference");
|
|
if(servable < need)
|
|
{
|
|
if(!m_inferenceDepthRefusalWarned)
|
|
{
|
|
m_inferenceDepthRefusalWarned = true;
|
|
PrintFormat("%s: LIVE INFERENCE HELD - the feature window needs %d bars and the indicators can"
|
|
" only serve %d. Computing a signal here would silently use the swing block's"
|
|
" degraded path, i.e. different features from the ones this model was trained on,"
|
|
" so no signal is emitted until the depth is available. See the indicator-cap line"
|
|
" above for how to raise it.", ID, need, servable);
|
|
}
|
|
return;
|
|
}
|
|
m_inferenceDepthRefusalWarned = false;
|
|
if(!ResizeBuffers(barsNow) || !RefreshData())
|
|
return;
|
|
//--- INVALIDATE THE NOW-RELATIVE BAR CACHES. Non-obvious and load-bearing: the feature cache is
|
|
//--- keyed by MQL5 series index, and index 0 means "newest bar", so every closed candle shifts
|
|
//--- what every cached row stands for.
|
|
EnsureBarCachesCapacity(barsNow);
|
|
//--- Same bar grid, same panel. Only as deep as inference actually reads. Asking for the full
|
|
//--- `barsNow` here would rebuild a training-depth panel on EVERY bar, which in the tester means
|
|
//--- one full multi-symbol resample per simulated bar.
|
|
BuildCrossAssetPanel((int)m_historyBars + CROSSASSET_SLOW_BARS + 2);
|
|
EnsureSpreadSeries(barsNow);
|
|
bool refreshed = RefreshLatestSignal();
|
|
//--- Continual learning: on a LIVE chart (never the tester/optimizer - OnlineLearnStep() self-
|
|
//--- guards on m_inferenceOnly) a deployed model keeps adapting to newly-confirmed structure.
|
|
OnlineLearnStep();
|
|
//--- Advance the live new-bar watermark ONLY on success. On failure the gate stays open, so the
|
|
//--- next tick retries.
|
|
if(refreshed)
|
|
dtStudied = m_Time.GetData(0);
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| |
|
|
//+------------------------------------------------------------------+
|
|
bool CExpertSignalAIBase::RefreshLatestSignal(void)
|
|
{
|
|
//--- Bar 1: the newest CLOSED bar, NOT the forming bar. Train() never produces such a window -
|
|
//--- every labeled bar is fully closed, and its label assumes entry at that bar's CLOSE (see
|
|
//--- SwingPivotDirectionLabel's header).
|
|
int i = 1;
|
|
int r = i;
|
|
if(!BuildFeatureWindow(r))
|
|
{
|
|
//--- One combined failure now (partial window OR short total) where there used to be two counters.
|
|
//--- Kept distinct in the tally by testing what actually landed: a window that built every bar but
|
|
//--- came up short is the "short" case, anything else is a feature-build failure.
|
|
if(TempData.Total() > 0 && TempData.Total() < (int)m_historyBars * m_neuronsCount)
|
|
m_refreshFailShort++;
|
|
else
|
|
m_refreshFailFeatures++; // see PrintInferenceTally()
|
|
//--- No opinion this bar rather than a stale one: dPrevSignal still holds the PREVIOUS bar's
|
|
//--- decision, and LongCondition()/ShortCondition() would keep voting that stale direction
|
|
//--- all bar.
|
|
dPrevSignal = 0.0;
|
|
return false;
|
|
}
|
|
//--- Live trading/inference reads from the EMA shadow net, not Net directly - see
|
|
//--- COnlineLearning::DeployNet()'s comment. Falls back to Net if the shadow isn't bootstrapped yet
|
|
//--- (should only be momentarily, on a genuinely fresh start before EnsureShadowNet() has run).
|
|
EnsureShadowNet();
|
|
CNet *deployNet = m_onlineLearning.DeployNet();
|
|
deployNet.feedForward(TempData);
|
|
deployNet.getResults(TempData);
|
|
if(m_outputNeuronsCount == 1)
|
|
dPrevSignal = TempData[0];
|
|
else
|
|
if(m_outputNeuronsCount == 3)
|
|
{
|
|
//--- Live decision. The call is kept because a dozen sites name it as "the live decision
|
|
//--- rule" and that is still exactly what it is - the correction now lives in the trained
|
|
//--- weights instead of here.
|
|
ApplyClassificationSoftmax();
|
|
dPrevSignal = AdjustedSignalFromSoftmax();
|
|
}
|
|
m_refreshOk++;
|
|
//--- bt anchors the DECISION bar (bar 1, the closed bar the window ends on) - it keys the arrow,
|
|
//--- its High/Low placement and NMS declustering, and now matches the rescan path, which draws
|
|
//--- each historical arrow at the bar its window ends on.
|
|
datetime bt = m_Time.GetData(i);
|
|
//--- Keep a pure inference-side watermark of the newest bar FRAME this model has already
|
|
//--- evaluated.
|
|
m_lastBarTime = m_Time.GetData(0);
|
|
//--- LIVE NMS, AND IT NOW GATES THE TRADE, NOT JUST THE ARROW.
|
|
ENUM_SIGNAL lsig = DoubleToSignal(dPrevSignal);
|
|
bool nmsAccept = (lsig != Neutral) && NmsLiveAccept(bt, lsig, MathAbs(dPrevSignal));
|
|
if(lsig != Neutral && !nmsAccept)
|
|
dPrevSignal = 0.0; // declustered away: no arrow, no vote, no position
|
|
switch(DoubleToSignal(dPrevSignal))
|
|
{
|
|
case Buy:
|
|
m_refreshBuy++;
|
|
break;
|
|
case Sell:
|
|
m_refreshSell++;
|
|
break;
|
|
default:
|
|
m_refreshNeutral++;
|
|
break;
|
|
}
|
|
if(nmsAccept)
|
|
DrawObject(bt, dPrevSignal, m_Close.GetData(i));
|
|
else
|
|
DeleteObject(bt);
|
|
return true;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Strict majority, ties to Neutral - the single derivation of the |
|
|
//| 3-class argmax rule. Every live-decision caller below derives |
|
|
//| its decision from this one test instead of re-deriving it. |
|
|
//+------------------------------------------------------------------+
|
|
ENUM_SIGNAL CExpertSignalAIBase::Argmax3(double pBuy, double pSell, double pNeutral)
|
|
{
|
|
if(pBuy > pSell && pBuy > pNeutral)
|
|
return Buy;
|
|
if(pSell > pBuy && pSell > pNeutral)
|
|
return Sell;
|
|
return Neutral; // also the fallback on any tie
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalAIBase::ApplyClassificationSoftmax(void)
|
|
{
|
|
//--- A non-finite logit poisons everything downstream: maxLogit, every exp(), the sum, and all
|
|
//--- three probabilities become NaN, and since NaN fails every comparison the two directional
|
|
//--- tests below are both false - so a NaN'd net returns Neutral on every bar forever and looks
|
|
//--- EXACTLY like a model that has simply gone quiet.
|
|
if(!MathIsValidNumber(TempData.At(0)) || !MathIsValidNumber(TempData.At(1)) || !MathIsValidNumber(TempData.At(2)))
|
|
{
|
|
static int nanLogitReports = 0;
|
|
// Bounded: this cannot heal on its own (the weights are already corrupt), so unlimited logging
|
|
// would fill the journal for as long as the chart stays attached. Three is enough to prove it.
|
|
if(nanLogitReports < 3)
|
|
{
|
|
nanLogitReports++;
|
|
PrintFormat("%s: %s NON-FINITE network output (%g / %g / %g) - forcing Neutral. The weights are "
|
|
"corrupt; reload the last good .nnw or reset and retrain. Report %d of 3.",
|
|
__FUNCTION__, ID, TempData.At(0), TempData.At(1), TempData.At(2), nanLogitReports);
|
|
}
|
|
return 0;
|
|
}
|
|
// CLASS_LOGIT_SCALE (AI\Network.mqh) must match the training-gradient softmax in
|
|
// backProp/backPropOCL exactly - this is the same normalization the loss was trained against.
|
|
double maxLogit = CLASS_LOGIT_SCALE * MathMax(TempData.At(0), MathMax(TempData.At(1), TempData.At(2)));
|
|
double sum = 0;
|
|
for(int res = 0; res < 3; res++)
|
|
{
|
|
double temp = exp(CLASS_LOGIT_SCALE * TempData.At(res) - maxLogit);
|
|
sum += temp;
|
|
TempData.Update(res, temp);
|
|
}
|
|
for(int res = 0; res < 3; res++)
|
|
TempData.Update(res, TempData.At(res) / sum);
|
|
double pBuy = TempData.At(0);
|
|
double pSell = TempData.At(1);
|
|
double pNeutral = TempData.At(2);
|
|
//--- TempData.Maximum(0,3) scans left-to-right and keeps the FIRST index on a tie, so any tie
|
|
//--- (including the degenerate all-equal 0.3333/0.3333/0.3333 case from a collapsed/untrained
|
|
//--- net) always resolved to Buy (index 0) - silently turning "the model has no idea" into a
|
|
//--- directional trade. Argmax3() ties to Neutral instead.
|
|
ENUM_SIGNAL winner = Argmax3(pBuy, pSell, pNeutral);
|
|
if(winner == Buy)
|
|
return pBuy; // Buy signal
|
|
if(winner == Sell)
|
|
return -pSell; // Sell signal
|
|
return 0; // Neutral signal (also the fallback on any tie)
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Post-hoc logit adjustment (prior correction) of the 3-class |
|
|
//| decision. |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalAIBase::AdjustedSignalFromSoftmax(void)
|
|
{
|
|
if(TempData.Total() < 3)
|
|
return 0.0;
|
|
double pBuy = TempData.At(0), pSell = TempData.At(1), pNeutral = TempData.At(2);
|
|
//--- Strict majority, ties to Neutral - same Argmax3() rule as ApplyClassificationSoftmax(). The
|
|
//--- returned magnitude is a genuine probability, which the confidence floor and ConfidenceTier() read.
|
|
ENUM_SIGNAL winner = Argmax3(pBuy, pSell, pNeutral);
|
|
bool wantBuy = (winner == Buy);
|
|
bool wantSell = (winner == Sell);
|
|
if(!wantBuy && !wantSell)
|
|
return 0.0;
|
|
//--- OPERATING POINT (2026-08-09). Argmax alone answers "which class is most likely"; it does
|
|
//--- not answer "is this worth trading", and those are different questions whenever the top two
|
|
//--- classes are nearly tied.
|
|
if(m_dirConfThreshold > 0.0)
|
|
{
|
|
double win = wantBuy ? pBuy : pSell;
|
|
double rival = wantBuy ? MathMax(pSell, pNeutral) : MathMax(pBuy, pNeutral);
|
|
if((win - rival) < m_dirConfThreshold)
|
|
return 0.0;
|
|
}
|
|
return wantBuy ? pBuy : -pSell;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| The statistic the operating point is expressed in - see the |
|
|
//| declaration. Reads the softmax ALREADY in TempData, so callers |
|
|
//| must have run ApplyClassificationSoftmax() first. |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalAIBase::DirectionalMargin(void)
|
|
{
|
|
if(TempData.Total() < 3)
|
|
return -1.0;
|
|
double pBuy = TempData.At(0), pSell = TempData.At(1), pNeutral = TempData.At(2);
|
|
ENUM_SIGNAL winner = Argmax3(pBuy, pSell, pNeutral);
|
|
if(winner == Buy)
|
|
return pBuy - MathMax(pSell, pNeutral);
|
|
if(winner == Sell)
|
|
return pSell - MathMax(pBuy, pNeutral);
|
|
return -1.0; // Neutral won: no directional call, so no operating point applies
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Clear the margin histogram at the start of the calibration walk. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::ResetDirConfHistogram(void)
|
|
{
|
|
ArrayInitialize(m_dirConfBinCalls, 0);
|
|
ArrayInitialize(m_dirConfBinHits, 0);
|
|
m_dirConfPrimaryBars = 0;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| One calibration sample. isPrimaryBar survives from when this was |
|
|
//| harvested inside pass 2's oversampled replay queue, where counting |
|
|
//| duplicated minority bars would have fitted the operating point to |
|
|
//| a class balance the live model never sees (the same correction |
|
|
//| m_cumIsTotal makes - see its note in Training.mqh). The calibration |
|
|
//| walk visits each bar exactly once and passes true; the parameter |
|
|
//| stays so any future caller must state which it is. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::AccumulateDirConfSample(double margin, bool wasCorrect, bool isPrimaryBar)
|
|
{
|
|
if(!isPrimaryBar)
|
|
return;
|
|
//--- Counted BEFORE the directional test: this is the coverage denominator, so it has to be every
|
|
//--- primary bar the model scored, including the ones it called Neutral. Using only directional
|
|
//--- bars would make coverage 100% by construction at every threshold.
|
|
m_dirConfPrimaryBars++;
|
|
if(margin < 0.0)
|
|
return; // Neutral won - not a directional call
|
|
int bin = (int)(margin * DIR_CONF_THRESHOLD_BINS);
|
|
if(bin < 0)
|
|
bin = 0;
|
|
if(bin >= DIR_CONF_THRESHOLD_BINS)
|
|
bin = DIR_CONF_THRESHOLD_BINS - 1; // margin can reach exactly 1.0
|
|
m_dirConfBinCalls[bin]++;
|
|
if(wasCorrect)
|
|
m_dirConfBinHits[bin]++;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Choose the operating point: the margin at which the model calls |
|
|
//| a direction as often as a direction actually occurs. One pass, |
|
|
//| top down, so the running totals are "calls at or above this bin" |
|
|
//| - the set a threshold there admits. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::FitDirConfThreshold(void)
|
|
{
|
|
long totalCalls = 0;
|
|
for(int b = 0; b < DIR_CONF_THRESHOLD_BINS; b++)
|
|
totalCalls += m_dirConfBinCalls[b];
|
|
if(totalCalls < DIR_CONF_MIN_FIT_CALLS || m_dirConfPrimaryBars <= 0)
|
|
{
|
|
//--- Not enough evidence to place an operating point. KEEP THE PREVIOUS ONE - the old
|
|
//--- behaviour here was to reset to 0.0, which is "call a direction on every bar", the single
|
|
//--- most exposed setting in the range.
|
|
if(!m_dirConfSparseWarned)
|
|
{
|
|
m_dirConfSparseWarned = true;
|
|
Print(ID + StringFormat(": directional confidence threshold NOT refitted - only %d directional "
|
|
"calls in the held-out calibration slice this era (need %d). Keeping "
|
|
"the previous operating point %.2f; this is normal for the first eras "
|
|
"and self-corrects as the model starts calling directions.",
|
|
(int)totalCalls, DIR_CONF_MIN_FIT_CALLS, m_dirConfThreshold));
|
|
}
|
|
return;
|
|
}
|
|
//--- THE TARGET IS THE LABEL RATE. Call a direction as often as a direction actually occurs -
|
|
//--- nothing else. A whole null-of-the-maximum apparatus was then built to hold that down.
|
|
double targetPct = ScanDirectionalRatePct();
|
|
double eraPct = EraDirectionalRatePct();
|
|
bool fromScan = (targetPct >= 0.0);
|
|
if(!fromScan)
|
|
targetPct = eraPct; // resumed model with no prebuild - the era tally is the only measurement
|
|
if(targetPct < 0.0)
|
|
return; // nothing measured either way: keep the operating point we have
|
|
//--- Running "at or above this bin" totals, which is exactly the population a threshold there
|
|
//--- admits. Coverage therefore rises monotonically as the sweep descends, so |coverage - target|
|
|
//--- is V-shaped and the first minimum found is the answer.
|
|
double binCov[DIR_CONF_THRESHOLD_BINS];
|
|
double binPrec[DIR_CONF_THRESHOLD_BINS];
|
|
long runCalls = 0, runHits = 0;
|
|
double bestGap = DBL_MAX;
|
|
int bestBin = -1;
|
|
for(int b = DIR_CONF_THRESHOLD_BINS - 1; b >= 0; b--)
|
|
{
|
|
binCov[b] = 0.0;
|
|
binPrec[b] = 0.0;
|
|
runCalls += m_dirConfBinCalls[b];
|
|
runHits += m_dirConfBinHits[b];
|
|
if(runCalls <= 0)
|
|
continue;
|
|
binCov[b] = 100.0 * (double)runCalls / m_dirConfPrimaryBars;
|
|
binPrec[b] = 100.0 * (double)runHits / runCalls;
|
|
double gap = MathAbs(binCov[b] - targetPct);
|
|
//--- Strict <, so a tie keeps the MORE selective bin - the sweep reaches it first. Same
|
|
//--- tie-break direction the expectancy version used, and for the same reason.
|
|
if(gap < bestGap)
|
|
{
|
|
bestGap = gap;
|
|
bestBin = b;
|
|
}
|
|
}
|
|
if(bestBin < 0)
|
|
return;
|
|
double prevThresh = m_dirConfThreshold;
|
|
m_dirConfThreshold = (double)bestBin / DIR_CONF_THRESHOLD_BINS;
|
|
//--- Logged when it moves a bin AND the era cadence is due. The threshold updates every era
|
|
//--- regardless; only the announcement is throttled.
|
|
if(TrainLogDue() && MathAbs(m_dirConfThreshold - prevThresh) >= 1.0 / DIR_CONF_THRESHOLD_BINS)
|
|
Print(ID + StringFormat(": directional confidence threshold %.2f -> %.2f, fitted on CALIBRATION"
|
|
" over %d held-out bars: calls a direction on %.1f%% of them against a"
|
|
" %s-measured label rate of %.1f%% (miss %.1fpp - the closest of %d bins)."
|
|
" Below this winner-vs-rival margin the model abstains."
|
|
" | Precision at this point %.1f%% -"
|
|
" REPORTED, not optimised: the margin does not rank these calls, and"
|
|
" selecting on that curve is what made this threshold thrash."
|
|
" | Scan says %.1f%%, era loop says %.1f%% - if these disagree the"
|
|
" populations differ and the scan is the one the operator reads.",
|
|
prevThresh, m_dirConfThreshold, (int)m_dirConfPrimaryBars,
|
|
binCov[bestBin], fromScan ? "scan" : "era-loop", targetPct, bestGap,
|
|
DIR_CONF_THRESHOLD_BINS, binPrec[bestBin],
|
|
ScanDirectionalRatePct(), eraPct));
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| EMA-updates the persisted true class base rates from a finished |
|
|
//| era's true class counts. First real measurement seeds directly; |
|
|
//| thereafter blended with the same smoothing as the accuracy/ |
|
|
//| confidence EMAs so one noisy era can't swing the live decision. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::UpdateClassPriors(long buyCnt, long sellCnt, long neutralCnt)
|
|
{
|
|
long tot = buyCnt + sellCnt + neutralCnt;
|
|
if(tot <= 0)
|
|
return;
|
|
double pb = (double)buyCnt / tot, ps = (double)sellCnt / tot, pn = (double)neutralCnt / tot;
|
|
if(m_priorNeutral <= 0.0) // first real measurement
|
|
{
|
|
m_priorBuy = pb;
|
|
m_priorSell = ps;
|
|
m_priorNeutral = pn;
|
|
return;
|
|
}
|
|
//--- (Was `m_useStaticPrior || m_freezePriorCalibration`. Those were two separate user-facing inputs
|
|
//--- whose only effect anywhere in the codebase was this one OR - two controls for one decision.
|
|
//--- UseStaticPrior was removed 2026-07-31; see the class-imbalance audit in Variables\Inputs.mqh.)
|
|
if(m_freezePriorCalibration)
|
|
return;
|
|
double k = Net.recentAverageSmoothingFactor;
|
|
if(k < 1.0)
|
|
k = 1.0;
|
|
m_priorBuy += (pb - m_priorBuy) / k;
|
|
m_priorSell += (ps - m_priorSell) / k;
|
|
m_priorNeutral += (pn - m_priorNeutral) / k;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Installs the training-time logit offsets - see the declaration. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::ApplyLogitAdjustment(void)
|
|
{
|
|
if(CheckPointer(Net) == POINTER_INVALID)
|
|
return;
|
|
//--- Priors not measured yet (era 0 before the first tally, or a model with no .stats): leave
|
|
//--- the gradient unadjusted rather than guessing a distribution. The next era installs them.
|
|
if(m_priorBuy <= 0.0 || m_priorSell <= 0.0 || m_priorNeutral <= 0.0)
|
|
{
|
|
if(m_eraCount > 0 && !m_logitAdjustSkipWarned)
|
|
{
|
|
m_logitAdjustSkipWarned = true;
|
|
Print(ID + ": WARNING - class-imbalance correction is NOT running at era " +
|
|
IntegerToString(m_eraCount) + ": the class priors have never been measured (Buy " +
|
|
DoubleToString(m_priorBuy, 4) + " Sell " + DoubleToString(m_priorSell, 4) + " Neutral " +
|
|
DoubleToString(m_priorNeutral, 4) + "). Training is falling back to plain cross-entropy, "
|
|
"which on a skewed label set collapses to the majority class.");
|
|
}
|
|
Net.ClearLogitAdjustment();
|
|
return;
|
|
}
|
|
//--- Tau is not a knob: 1.0 is the full log-prior, Menon et al.'s consistent value, and the only
|
|
//--- run-specific part - the priors and the cap below - is measured, not chosen. The cap keeps the
|
|
//--- offsets from swamping the head's usable logit range (LOGIT_ADJUST_MAX_RANGE_FRACTION); when
|
|
//--- it binds, the head is the constraint and a different tau would not fix it.
|
|
double lb = MathLog(m_priorBuy), ls = MathLog(m_priorSell), lnn = MathLog(m_priorNeutral);
|
|
//--- ALL THREE CLASSES, WITH ONE INVARIANT: THE ABSTAIN CLASS IS NEVER SUBSIDISED.
|
|
double mid = (lb + ls + lnn) / 3.0;
|
|
double spread = MathMax(lb, MathMax(ls, lnn)) - MathMin(lb, MathMin(ls, lnn));
|
|
double tauEff = 1.0;
|
|
if(spread > 0.0)
|
|
{
|
|
double cap = LOGIT_ADJUST_MAX_RANGE_FRACTION * CLASS_LOGIT_SCALE / spread;
|
|
if(tauEff > cap)
|
|
tauEff = cap;
|
|
}
|
|
if(!m_logitAdjustLogged)
|
|
{
|
|
m_logitAdjustLogged = true;
|
|
//--- Says which way the abstain class is being pushed, because that is the whole question this
|
|
//--- correction has got wrong in both directions before.
|
|
Print(ID + ": logit adjustment - measured priors Buy " + DoubleToString(m_priorBuy * 100.0, 2) +
|
|
"% Sell " + DoubleToString(m_priorSell * 100.0, 2) + "% Neutral " +
|
|
DoubleToString(m_priorNeutral * 100.0, 2) + "% | APPLIED across all three, log-prior"
|
|
" spread " + DoubleToString(spread, 2) + " | Neutral offset " +
|
|
DoubleToString(MathMax(tauEff * (lnn - mid), 0.0), 2) +
|
|
(m_priorNeutral < m_priorBuy && m_priorNeutral < m_priorSell
|
|
? " - CLAMPED to zero: Neutral is the RAREST class here, and paying the model to abstain is"
|
|
" the 2026-08-16 collapse. Uncapped it would have been " +
|
|
DoubleToString(tauEff * (lnn - mid), 2)
|
|
: " - Neutral is over-represented, so this PENALISES abstention") +
|
|
" against a logit range of " +
|
|
DoubleToString(CLASS_LOGIT_SCALE, 1) + " | tau 1.00" +
|
|
(tauEff < 1.0
|
|
? " CAPPED to " + DoubleToString(tauEff, 2) + " (uncapped it would consume " +
|
|
DoubleToString(100.0 * spread / CLASS_LOGIT_SCALE, 0) +
|
|
"% of the range and saturate the head)"
|
|
: " (uncapped - within budget)"));
|
|
}
|
|
//--- ORDERED to match the output layer: [0]=Buy, [1]=Sell, [2]=Neutral - the order
|
|
//--- BuildFreshTopology emits and the order the softmax gradient reads (AI\Network.mqh).
|
|
double offsets[3];
|
|
offsets[0] = tauEff * (lb - mid);
|
|
offsets[1] = tauEff * (ls - mid);
|
|
//--- A NEGATIVE offset here is a subsidy: it makes the head produce a larger raw Neutral logit to
|
|
//--- classify Neutral correctly, which is exactly what wins Neutral more bars at inference. Clamped
|
|
//--- away. A positive one penalises over-abstention and is allowed through.
|
|
offsets[2] = MathMax(tauEff * (lnn - mid), 0.0);
|
|
Net.SetLogitAdjustment(offsets);
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Converts a double to ENUM_SIGNAL. |
|
|
//+------------------------------------------------------------------+
|
|
ENUM_SIGNAL CExpertSignalAIBase::DoubleToSignal(double value)
|
|
{
|
|
value = NormalizeDouble(value, 2); // Round 'value' to two decimal places
|
|
if(value < -1.0 || value > 1.0)
|
|
return Undefine; // out of range, e.g. the -2 "not yet studied" sentinel
|
|
if(m_outputNeuronsCount == 3)
|
|
{
|
|
if(value > 0.0)
|
|
return Buy;
|
|
if(value < 0.0)
|
|
return Sell;
|
|
return Neutral;
|
|
}
|
|
if(value > 0.50)
|
|
return Buy;
|
|
if(value < -0.50)
|
|
return Sell;
|
|
return Neutral;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Throttled, SIDE-EFFECT-FREE forward of the current decision bar, |
|
|
//| for display only (the HUD member lines and the prospective |
|
|
//| vote). |
|
|
//+------------------------------------------------------------------+
|
|
//--- (uint) so the throttle comparison is unsigned-vs-unsigned: age is a uint tick delta, and
|
|
//--- the ternary picking between these is a runtime expression the compiler cannot constant-
|
|
//--- fold, so bare int literals here drew a sign-mismatch warning (reported 2026-08-19).
|
|
#define DISPLAY_FWD_MIN_MS ((uint)4000)
|
|
#define DISPLAY_FWD_ERA_MS ((uint)1000)
|
|
bool CExpertSignalAIBase::DisplayInference(void)
|
|
{
|
|
if(CheckPointer(Net) == POINTER_INVALID)
|
|
return false;
|
|
if(m_outputNeuronsCount != 1 && m_outputNeuronsCount != 3)
|
|
return false;
|
|
uint now = GetTickCount();
|
|
uint age = now - m_dispStamp; // unsigned subtraction survives the 49-day wrap
|
|
bool eraMoved = ((long)m_eraCount != m_dispEra);
|
|
if(m_dispStamp != 0 && age < (eraMoved ? DISPLAY_FWD_ERA_MS : DISPLAY_FWD_MIN_MS))
|
|
return m_dispValid; // serve the cache (or keep failing quietly) until the throttle opens
|
|
m_dispStamp = now;
|
|
if(!BuildFeatureWindow(1))
|
|
return m_dispValid; // window not buildable (warm-up, indicator hole): keep the last read
|
|
//--- Save/restore, NOT set/clear - see the header. Frozen, this forward is a pure function.
|
|
bool bnWasFrozen = Net.GetBatchNormFrozen();
|
|
if(!bnWasFrozen)
|
|
Net.SetBatchNormFrozen(true);
|
|
bool fwdOk = Net.feedForward(TempData);
|
|
if(fwdOk)
|
|
Net.getResults(TempData);
|
|
if(!bnWasFrozen)
|
|
Net.SetBatchNormFrozen(false);
|
|
if(!fwdOk)
|
|
return m_dispValid;
|
|
if(m_outputNeuronsCount == 1)
|
|
{
|
|
double v = TempData.At(0);
|
|
if(!MathIsValidNumber(v))
|
|
return m_dispValid; // NaN net: keep the last finite read, the NaN latch reports elsewhere
|
|
m_dispProbs[0] = v;
|
|
m_dispProbs[1] = 0.0;
|
|
m_dispProbs[2] = 0.0;
|
|
m_dispSignal = v;
|
|
}
|
|
else
|
|
{
|
|
//--- Same two calls, same order, as the live decision in RefreshLatestSignal: softmax INTO
|
|
//--- TempData, then the strict-majority read.
|
|
ApplyClassificationSoftmax();
|
|
double p0 = TempData.At(0), p1 = TempData.At(1), p2 = TempData.At(2);
|
|
if(!MathIsValidNumber(p0) || !MathIsValidNumber(p1) || !MathIsValidNumber(p2))
|
|
return m_dispValid;
|
|
m_dispProbs[0] = p0; // Buy
|
|
m_dispProbs[1] = p1; // Sell
|
|
m_dispProbs[2] = p2; // Neutral
|
|
m_dispSignal = AdjustedSignalFromSoftmax();
|
|
}
|
|
m_dispEra = (long)m_eraCount;
|
|
m_dispValid = true;
|
|
return true;
|
|
}
|
|
#endif // WARRIOR_AIBASE_INFERENCE_MQH
|