forked from animatedread/Warrior_EA
Since S3 (f64e0f8) the meta head casts no vote - it scores an entry the
consensus already cleared and vetoes the ones under the cost-adjusted
break-even. The code still said otherwise. LiveMetaGate() was a virtual on
CExpertSignalCustom, so MA, RSI, MACD, Ichimoku, the four direction nets,
the session and news filters and the risk guard each carried a meta-gate
method they had no business having; one class implemented it and a dozen
inherited it. The trading pipeline held the gate as a CExpertSignalCustom*
- a signal pointer, with a signal's two hundred other methods reachable
from the entry path.
Expert\Trading\MetaGate.mqh now owns the abstraction:
CMetaGate one pure virtual, Evaluate(), and the two static
readings of a verdict (Blocks / Scored)
META_GATE_* names for the four codes the three call sites used
to spell as bare 0/1/2 and test three different ways
(`< 0` here, `== 2` there, `else` for the rest).
Codes unchanged; only ONE of them blocks, and that
asymmetry is now stated where it lives.
SMetaGateTelemetry the five m_metaGate* members that were on the AI
signal base - inherited by every direction model,
meaningful for none of them. One lifetime, one
writer, one object; the arm latch and the two
counters are a set that clears together.
g_warriorMetaGate is a CMetaGate*. MQL5's single inheritance means the head
cannot also BE one (it already extends the AI base for the net, the era
loop, the feature windows, the label caches and persistence), so it owns a
bound CMetaGateAdapter and hands that out - the same shape CTrainingDataView
uses for the same reason. LiveMetaGate() is gone from the signal base.
Behaviour unchanged: same codes, same thresholds, same fail-open doctrine,
same live-only telemetry rule. The adapter fails open when unbound, on that
same doctrine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
568 lines
29 KiB
MQL5
568 lines
29 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)
|
|
{
|
|
//--- Meta target: the meta head scores PROPOSED TRADES, not a bare bar window - a candidate-less
|
|
//--- forward would also be width-mismatched against its input layer (window + descriptor).
|
|
if(IsMetaTarget())
|
|
return;
|
|
//--- 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 + MathMax(m_barrierHorizonBars, 1) + 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);
|
|
//--- A deployed model never enters Train(), so this is the only place its barrier horizon gets
|
|
//--- measured - and OnlineLearnStep() below depends on it being right. First call sizes buffers
|
|
//--- against the fallback, which is harmless: `need` is dominated by SWING_SCAN_CAP_BARS either way.
|
|
EnsureBarrierHorizon(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)
|
|
{
|
|
//--- Meta target: the live path is the S3 gate (CMetaGate), not the per-bar vote - see
|
|
//--- RefreshConvergedSignal's meta guard.
|
|
if(IsMetaTarget())
|
|
return false;
|
|
//--- 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
|
|
//--- TripleBarrierLabel'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 m_shadowNet's
|
|
//--- declaration 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 = (CheckPointer(m_shadowNet) != POINTER_INVALID) ? m_shadowNet : Net;
|
|
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;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| |
|
|
//+------------------------------------------------------------------+
|
|
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.
|
|
if(pBuy > pSell && pBuy > pNeutral)
|
|
return pBuy; // Buy signal
|
|
if(pSell > pBuy && pSell > pNeutral)
|
|
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 - the same rule as ApplyClassificationSoftmax(). The returned
|
|
//--- magnitude is a genuine probability, which the confidence floor and ConfidenceTier() read.
|
|
bool wantBuy = (pBuy > pSell && pBuy > pNeutral);
|
|
bool wantSell = (pSell > pBuy && pSell > pNeutral);
|
|
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);
|
|
if(pBuy > pSell && pBuy > pNeutral)
|
|
return pBuy - MathMax(pSell, pNeutral);
|
|
if(pSell > pBuy && pSell > pNeutral)
|
|
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
|
|
//--- Kept for the report only. The edge at the chosen point is worth SEEING - it is just no
|
|
//--- longer what chooses the point. The HORIZON-AWARE one, because this line is what the
|
|
//--- operator reads to judge a model.
|
|
double breakEvenPct = EmpiricalBreakEvenPct();
|
|
//--- 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."
|
|
" | Win rate at this point %.1f%% vs %.1f%% horizon-aware break-even"
|
|
" (edge %.1fpp; geometric %.1f%%) -"
|
|
" REPORTED, not optimised: the margin does not rank these trades, 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], breakEvenPct,
|
|
binPrec[bestBin] - breakEvenPct, CostAdjustedBreakEvenPct(),
|
|
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;
|
|
if(m_logitAdjustTau <= 0.0)
|
|
{
|
|
//--- Clear rather than merely skip: the input can be turned off on a chart that already installed
|
|
//--- offsets this session, and a stale adjustment would keep biasing the gradient silently.
|
|
Net.ClearLogitAdjustment();
|
|
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;
|
|
}
|
|
//--- Effective tau, capped so the offsets cannot swamp the head's usable logit range - see
|
|
//--- LOGIT_ADJUST_MAX_RANGE_FRACTION.
|
|
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 = m_logitAdjustTau;
|
|
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 " + DoubleToString(m_logitAdjustTau, 2) +
|
|
(tauEff < m_logitAdjustTau
|
|
? " CAPPED to " + DoubleToString(tauEff, 2) + " (uncapped it would consume " +
|
|
DoubleToString(100.0 * spread * m_logitAdjustTau / 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)
|
|
{
|
|
//--- Meta head consumes fired candidates, not a bare bar window - a candidate-less forward is
|
|
//--- width-mismatched against its input layer. Same guard as RefreshConvergedSignal.
|
|
if(IsMetaTarget())
|
|
return false;
|
|
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
|