forked from mnbvc188199/Warrior_EA
1dda479 clamped the training sweep. It left five other paths asking the indicators
for a depth they cannot serve, and on a live account the quiet ones are worse than
the stall was - a stalled chart is visible, a chart trading on a degraded feature
window is not.
ServableBars(want, context) is now the single gate, and all six go through it:
training sweep clamp, floored at TRAIN_MIN_CLAMPED_BARS (below that a small
positive BarsCalculated is warm-up, which m_coldSweepTick owns)
label prebuild clamp - labels come from price/ADZigZag and would survive a
capped MA, but ResizeBuffers sizes EVERY buffer and a failed
CopyBuffer leaves m_MA EMPTY for the next reader, so this path
could silently re-break the block Train()'s clamp just fixed
live inference HOLD. Below `need` the swing block takes its degraded path and
inference runs on a different feature distribution than the model
was fitted on. This EA sizes real positions off that output, so
no signal beats a mismatched one
online learning HOLD, same reason and worse - this path WRITES to a live trading
model, so a mismatched (features, label) pair is not a wrong arrow,
it is a wrong weight update that compounds every bar
chart rescan clamp - SIGNAL_RESCAN_LOOKBACK_BARS is 5000 and MT5's smallest
"Max bars in chart" is also 5000, so this one is genuinely
reachable; uncapped it repaints the window all-Neutral
research export clamp before the emptiness test, so a capped symbol exports the
depth it has rather than writing a CSV with a dead feature block -
an artefact that looks complete and is silently wrong
Both HOLDs are insurance, not expected states: `need` tops out near 1,152 bars
(16 + 750 + 384 + 2) against a 5,000 floor on the terminal setting. They exist so
the failure mode is unreachable rather than merely unlikely.
Not changed: a genuinely SHORT price history still takes the old degraded path at
every site. That is pre-existing behaviour and narrowing it would mute charts that
trade today, so it stays a separate decision rather than a side effect of this fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
802 lines
51 KiB
MQL5
802 lines
51 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| Warrior_EA |
|
|
//| AnimateDread |
|
|
//| |
|
|
//| Read-time signal production: softmax, prior calibration, class p|
|
|
//| |
|
|
//| PARTIAL IMPLEMENTATION FILE - not standalone. |
|
|
//| This holds CExpertSignalAIBase method BODIES only. The class |
|
|
//| declaration lives in Expert\ExpertSignalAIBase.mqh, which |
|
|
//| #includes this file at the bottom, after the declaration. Do not |
|
|
//| include it anywhere else and do not compile it on its own. |
|
|
//| |
|
|
//| Split out purely to make the 8216-line original navigable; the |
|
|
//| code inside was moved verbatim, not rewritten. |
|
|
//+------------------------------------------------------------------+
|
|
#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/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: live inference is S3's work (the meta head consumes FIRED CANDIDATES via the
|
|
//--- per-side hooks, not a bare bar window - a candidate-less forward would also be width-
|
|
//--- mismatched against the meta input layer). Until S3 lands, a trained meta model just holds.
|
|
if(IsMetaTarget())
|
|
return;
|
|
//--- Size the buffers from what the FEATURE BUILDER actually needs, not from a date delta.
|
|
//--- This used to be `Bars(sym, period, dtStudied, TimeCurrent()) + m_historyBars`. dtStudied is a
|
|
//--- training watermark, and in the Strategy Tester it is loaded from a LIVE-chart save whose
|
|
//--- timestamp is AHEAD of the simulated date - so the interval inverts, Bars() returns ~0, and the
|
|
//--- buffer came out at exactly m_historyBars. That is just deep enough for the OHLC window to
|
|
//--- succeed and far too shallow for the swing-context block behind it: the Donchian-50, the 20-bar
|
|
//--- return and the SMA extension all reach further back than m_historyBars, hit the end of the
|
|
//--- loaded series, and take their graceful degraded path. The result was silent - no error, no short
|
|
//--- window, just inference computing DIFFERENT features from the ones training learned on. Live it
|
|
//--- was the same bug with a milder constant (the delta is ~1 bar, giving m_historyBars + 1).
|
|
//--- SWING_SCAN_CAP_BARS is the deepest lookback any feature performs (FindConfirmedZigZagPivot's
|
|
//--- bound); everything else in BufferTempDataCompute reaches less far.
|
|
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). If the
|
|
//--- indicators cannot serve `need`, the only safe output is no output: a signal computed from a
|
|
//--- different feature distribution than the model was fitted on is worse than no signal, and this
|
|
//--- EA sizes real positions off it.
|
|
//---
|
|
//--- In practice this cannot fire on a sane terminal - `need` tops out around 1,152 bars (16 + 750 +
|
|
//--- 384 + 2) and the SMALLEST "Max bars in chart" MT5 offers is 5,000. It is insurance against the
|
|
//--- failure mode being reachable at all, not a case expected in the field.
|
|
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. Train() is the only other caller of this, and once m_trainingComplete is
|
|
//--- set ScheduleTrainingIfNeeded() routes every subsequent bar HERE instead - Train() is never
|
|
//--- re-entered, so without this call nothing ever clears the cache again for the rest of the process.
|
|
//--- A chart that trained to convergence (or was deployed via DeployNow()) would then keep replaying
|
|
//--- the rows computed for the last training era's bar grid: BufferTempData(0..m_historyBars-1) all hit
|
|
//--- the cache, the feature window never changes, and dPrevSignal freezes at its convergence-time value
|
|
//--- forever - silently, since every buffer above refreshed correctly and the vector is the right SHAPE.
|
|
//--- OnlineLearnStep() below would compound it by backpropping those stale features against freshly
|
|
//--- resolved labels, i.e. actively training the deployed model on mismatched pairs.
|
|
//--- A freshly started inference-only process (a backtest, or a buyer loading a deployed .nnw) was
|
|
//--- never affected: it never allocates these arrays, so BufferTempData()'s `cacheable` test is false
|
|
//--- and it always recomputes. This is a live/forward-chart fix, not a backtest one.
|
|
EnsureBarCachesCapacity(barsNow);
|
|
//--- Same bar grid, same panel. A deployed model never enters Train(), so this is the only place
|
|
//--- its cross-asset panel gets built - and it must be built from the SAME reference set training
|
|
//--- used, or inference reads a different feature vector than the weights were fitted to.
|
|
//--- Only as deep as inference actually reads. RefreshLatestSignal() touches bars 1..m_historyBars
|
|
//--- (window ends on the newest CLOSED bar - the +2 slack below covers the extra bar of depth)
|
|
//--- and the panel's own slow window reaches CROSSASSET_SLOW_BARS further back - nothing else. 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. The cache check in
|
|
//--- BuildCrossAssetPanel is >=, so a deeper panel left over from training still satisfies this.
|
|
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. Runs AFTER the
|
|
//--- live signal is drawn (so the arrow uses the shadow as it was for THIS bar's decision) and BEFORE
|
|
//--- dtStudied advances (OnlineLearnStep keeps its own time watermark, independent of dtStudied).
|
|
OnlineLearnStep();
|
|
//--- Advance the live new-bar watermark ONLY on success. Advancing it unconditionally meant a
|
|
//--- transient window failure (indicator hole, history hiccup) closed the gate for the rest of the
|
|
//--- bar with the PREVIOUS bar's dPrevSignal still voting - the tester path (m_lastBarTime) already
|
|
//--- advanced only on success and self-healed; this is the live path catching up. On failure the
|
|
//--- gate stays open, so the next tick retries.
|
|
if(refreshed)
|
|
dtStudied = m_Time.GetData(0);
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| |
|
|
//+------------------------------------------------------------------+
|
|
bool CExpertSignalAIBase::RefreshLatestSignal(void)
|
|
{
|
|
//--- Meta target: no live inference path until S3 - see RefreshConvergedSignal's meta guard.
|
|
if(IsMetaTarget())
|
|
return false;
|
|
//--- Bar 1: the newest CLOSED bar, NOT the forming bar. This runs at the first tick after a bar
|
|
//--- opens, when series index 0 is a bar with one tick of data: (close-open)/atr ~ 0, high ~ low,
|
|
//--- a degenerate volume block, indicators computed on a 1-tick candle. 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). The training-parity query at this instant (fixed
|
|
//--- 2026-08-11) is therefore the window ending on bar 1, whose close IS the current price - the
|
|
//--- exact instant the label's hypothetical entry happens. The old i = 0 fed the deployed model an
|
|
//--- out-of-distribution final timestep - the timestep the LSTM/HYBRID output is keyed to - and
|
|
//--- semantically asked for the label of a bar whose close was still an hour away, so the deploy
|
|
//--- gate's OOS scores (closed bars, pass 3) measured a different query than live executed. Both
|
|
//--- paths go through BuildFeatureWindow(), which guarantees identical construction; this index is
|
|
//--- what makes them the same QUESTION.
|
|
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. The caller retries (RefreshConvergedSignal only advances dtStudied on success), so a
|
|
//--- transient failure costs ticks, not the 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. ApplyClassificationSoftmax() computes the softmax INTO TempData and returns
|
|
//--- the decision; AdjustedSignalFromSoftmax() re-reads that same TempData and applies the same
|
|
//--- strict-majority/ties-to-Neutral rule, so since the read-time prior correction was removed
|
|
//--- (2026-07-31) the two provably agree. 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.
|
|
//--- The "raw softmax was neutralized by prior correction" diagnostic that used to sit here went
|
|
//--- with it: with nothing between the two values it could never fire again.
|
|
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.
|
|
//--- This must be the FORMING bar's open time (index 0), not bt: the new-bar gate compares it
|
|
//--- against SERIES_LASTBAR_DATE (also the forming bar's open), so anchoring it at bt (bar 1)
|
|
//--- would compare one bar behind and re-fire the refresh on every tick forever. The tester may
|
|
//--- load dtStudied from a live-chart save whose timestamp is AHEAD of the simulated backtest date
|
|
//--- range; using that training watermark to decide whether a "new bar" exists then freezes
|
|
//--- dPrevSignal at its init-bar value for the whole run. m_lastBarTime is this runtime's own
|
|
//--- latest evaluated frame instead, so it stays aligned to whichever history the current process
|
|
//--- is actually traversing.
|
|
m_lastBarTime = m_Time.GetData(0);
|
|
//--- LIVE NMS, AND IT NOW GATES THE TRADE, NOT JUST THE ARROW.
|
|
//---
|
|
//--- It used to sit at the bottom of this function wrapped around DrawObject() alone, so a suppressed
|
|
//--- bar lost its arrow and still traded: dPrevSignal was never touched, and dPrevSignal is what
|
|
//--- LongCondition()/ShortCondition()/SignedAIConfidence() read. The chart therefore showed roughly one
|
|
//--- arrow per EIGHT positions the EA would open - measured on SP500 H1 2026-08-09, where CONV called a
|
|
//--- direction on 64% of bars while ~40 arrows appeared across the ~500 visible ones. Worse, the arrows
|
|
//--- that survived were not a random eighth: rule 2 below keeps the HIGHER-CONFIDENCE side of a
|
|
//--- cluster, so the visible set was systematically the best member of each run. A chart that shows the
|
|
//--- best of every eight decisions and hides the rest reads far better than the model is, which is the
|
|
//--- same best-of-N selection error this codebase has now corrected in four other places - this time on
|
|
//--- the display layer, where it is most likely to mislead the person deciding whether to trade it.
|
|
//---
|
|
//--- Neutralising dPrevSignal (rather than adding a separate "may trade" flag consulted at each of the
|
|
//--- half-dozen read sites) is deliberate: it leaves exactly ONE definition of what this model decided
|
|
//--- this bar, so the arrow, the panel's "Current signal", the confidence handed to sizing/SL/TP/
|
|
//--- trailing, the refresh tally below and the order itself cannot drift apart again. One arrow is now
|
|
//--- one trade, which is what makes the chart an honest record.
|
|
//---
|
|
//--- NOTE the scoring consequence, deliberately NOT papered over: the era line's dir-precision still
|
|
//--- counts EVERY directional call, so it now describes a larger population than the one that trades.
|
|
//--- The era line carries a separate declustered figure alongside it (see m_oosNmsFired) so both are
|
|
//--- visible; the selection metric is not switched over until those numbers show what the coverage
|
|
//--- floor should be, because a blind switch is how the minRR and recall-floor catch-22s happened.
|
|
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_High.GetData(i), m_Low.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. That is the failure mode this project has chased repeatedly
|
|
// from the outside (panel says "no directional calls", nobody can tell whether the model is
|
|
// cautious or dead). Detect it here, at the one place the raw logits are first read, and say so.
|
|
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. Buy/Sell now only win with a strict majority over BOTH other classes; every tie,
|
|
// 2-way or 3-way, falls through to Neutral.
|
|
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. Reads the raw softmax probabilities ApplyClassification|
|
|
//| Softmax() left in TempData[0..2] and returns the prior-corrected |
|
|
//| signed decision (+P'(buy)/-P'(sell)/0-neutral), the exact rule |
|
|
//| live trading fires on and the live-fired precision metric scores. |
|
|
//| |
|
|
//| RAW ARGMAX, deliberately. The prior correction this function used |
|
|
//| to apply at read time (Saerens et al. 2002) was REMOVED |
|
|
//| 2026-07-31 along with the AILogitPriorStrength input. |
|
|
//| |
|
|
//| Why there is nothing to correct here: the logit-adjusted loss |
|
|
//| adds tau*log(prior_c) to each class logit inside the TRAINING |
|
|
//| gradient, so the network learns to absorb the offset and its raw |
|
|
//| argmax is ALREADY the balanced-error-optimal decision. Applying a |
|
|
//| second correction at inference would account for the same base |
|
|
//| rate twice and push the decision back toward Neutral - undoing |
|
|
//| exactly what the loss bought. The old code knew this: the whole |
|
|
//| adjustment sat behind an `if(m_useLogitAdjustedLoss) return raw` |
|
|
//| guard and had been unreachable for the entire shipped default |
|
|
//| configuration. Kept as a named function rather than inlined |
|
|
//| because a dozen call sites document themselves by calling "the |
|
|
//| live decision rule" - and that is exactly what this is. |
|
|
//+------------------------------------------------------------------+
|
|
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. A marginal directional win over Neutral used to become a trade, which is the
|
|
//--- mechanical source of the model calling a direction on ~90% of bars. Below the fitted margin
|
|
//--- this abstains instead - and abstaining is not a loss of information, it is the model declining
|
|
//--- to act on a distinction it cannot make. See DIR_CONF_THRESHOLD_BINS for how the value is chosen.
|
|
//---
|
|
//--- Returning Neutral rather than exposing a separate "tradeable" flag is deliberate, and matches
|
|
//--- the same decision made for live NMS (see RefreshLatestSignal): one definition of what this model
|
|
//--- decided this bar, so the arrow, the panel, the confidence handed to sizing/SL/TP, the OOS score
|
|
//--- and the order itself cannot drift apart.
|
|
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 that maximises EXPECTANCY |
|
|
//| on the held-out calibration slice while still calling a direction |
|
|
//| often enough to clear the SAME coverage floor the deploy gate |
|
|
//| uses. Held-out matters as much as the objective does - see |
|
|
//| DIR_CONF_CALIB_PCT_OF_IS for what fitting it on the training |
|
|
//| bars did to the sign of (p - break-even). |
|
|
//| |
|
|
//| Swept from the top down so the running totals are "calls at or |
|
|
//| above this bin", which is exactly the set a threshold there would |
|
|
//| admit - one pass, no nested loop over candidate thresholds. |
|
|
//| |
|
|
//| TIES GO TO THE LOWER THRESHOLD. Precision is a ratio of counts |
|
|
//| and plateaus over ranges of margin; taking the highest threshold |
|
|
//| on a plateau would buy identical precision for strictly less |
|
|
//| coverage, and coverage is what keeps the model tradeable. |
|
|
//+------------------------------------------------------------------+
|
|
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. A failed measurement must never decay to the most aggressive value it
|
|
//--- could have returned; the last threshold that WAS fitted is a strictly better estimate than
|
|
//--- the one setting we know maximises exposure. At era 0 the previous value is 0.0 regardless,
|
|
//--- so the cold-start path is unchanged.
|
|
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 floor is the true directional base rate x MIN_COVERAGE_FRACTION_OF_BASE_RATE, matching
|
|
//--- Train()'s minCoveragePct exactly. Derived from THIS era's own IS labels rather than passed in,
|
|
//--- so the two cannot fall out of step when one of them is edited.
|
|
long trueDir = m_trueBuyCount + m_trueSellCount;
|
|
long trueTot = trueDir + m_trueNeutralCount;
|
|
double baseRatePct = (trueTot > 0) ? 100.0 * (double)trueDir / trueTot : 0.0;
|
|
double minCoveragePct = baseRatePct * MIN_COVERAGE_FRACTION_OF_BASE_RATE;
|
|
//--- EXPECTANCY, NOT PRECISION. Maximising the win rate alone has no answer for a PLATEAU, and the
|
|
//--- previous `precPct >= bestPrec` resolved one by walking to ever more coverage. That is a
|
|
//--- catastrophe on exactly the models that need a threshold most: a net with no edge scores its base
|
|
//--- rate at EVERY threshold, which is a perfect plateau, so the walk ran to bin 0 and returned
|
|
//--- threshold 0.0 - fire on every bar. Observed 2026-08-10 as PAI "overshooting signals" while the
|
|
//--- other three stayed selective; PAI has the most degenerate margin distribution (OOS outputs
|
|
//--- spanning the full 0.000..1.000 where CONV sits at 0.214..0.814), so its plateau is the flattest.
|
|
//---
|
|
//--- The money quantity is expectancy per BAR, and for a k:m barrier
|
|
//--- EV = (p - p0) * (k + m) with p0 = m/(m+k),
|
|
//--- so EV per bar = coverage * (p - p0) * (k + m). (k+m) is constant across thresholds, which
|
|
//--- leaves coverage * (p - p0) as the objective. It behaves correctly in all three regimes and
|
|
//--- needs no tie-break rule:
|
|
//--- p > p0 everywhere -> more coverage is more money, so it takes the coverage (the old
|
|
//--- behaviour, but for a reason rather than as a plateau artifact)
|
|
//--- p flat AT p0 -> every point scores 0 and the floor decides; no runaway
|
|
//--- p < p0 everywhere -> the LEAST coverage loses the least, so it becomes MORE selective
|
|
//--- instead of trading everything, which is the current reality for all
|
|
//--- four models and the opposite of what the old rule did.
|
|
//--- COST-ADJUSTED, 2026-08-17. This is the reference the operating-point objective subtracts, so
|
|
//--- using the frictionless SL/(SL+TP) here made every candidate threshold look better than it was by
|
|
//--- the width of the spread - on SP500 H4 that was 2.2pp against a measured edge of 2.3pp, i.e. very
|
|
//--- nearly all of it. See CostAdjustedBreakEvenPct.
|
|
double breakEvenPct = CostAdjustedBreakEvenPct();
|
|
//--- Per-bin curve, cached so the second pass does not re-accumulate. Values are the running
|
|
//--- "at or above this bin" totals, which is exactly the population a threshold there admits.
|
|
double binCov[DIR_CONF_THRESHOLD_BINS];
|
|
double binPrec[DIR_CONF_THRESHOLD_BINS];
|
|
double binScore[DIR_CONF_THRESHOLD_BINS];
|
|
double binSe[DIR_CONF_THRESHOLD_BINS];
|
|
bool binOk[DIR_CONF_THRESHOLD_BINS];
|
|
long runCalls = 0, runHits = 0;
|
|
double bestScore = -DBL_MAX, bestSe = 0.0;
|
|
int bestBin = -1, floorBin = -1, eligibleBins = 0;
|
|
for(int b = DIR_CONF_THRESHOLD_BINS - 1; b >= 0; b--)
|
|
{
|
|
binOk[b] = false;
|
|
binCov[b] = 0.0;
|
|
binPrec[b] = 0.0;
|
|
binScore[b] = 0.0;
|
|
binSe[b] = 0.0;
|
|
runCalls += m_dirConfBinCalls[b];
|
|
runHits += m_dirConfBinHits[b];
|
|
if(runCalls <= 0)
|
|
continue;
|
|
double coveragePct = 100.0 * (double)runCalls / m_dirConfPrimaryBars;
|
|
if(coveragePct < minCoveragePct)
|
|
continue; // too selective to be deployable
|
|
double precPct = 100.0 * (double)runHits / runCalls;
|
|
double p = precPct / 100.0;
|
|
binOk[b] = true;
|
|
binCov[b] = coveragePct;
|
|
binPrec[b] = precPct;
|
|
binScore[b] = coveragePct * (precPct - breakEvenPct);
|
|
//--- Binomial standard error of the win rate at this operating point, carried into the score's
|
|
//--- own units. Coverage is measured against a FIXED denominator every bin, so it is far better
|
|
//--- determined than the win rate; the score's error is dominated by the precision term.
|
|
//--- ON THE EFFECTIVE SAMPLE, not the raw call count (2026-08-17). These calls are triple-barrier
|
|
//--- outcomes on consecutive bars, so they overlap: at a 384-bar horizon, neighbouring labels share
|
|
//--- almost their entire outcome window and are nothing like independent draws, and runCalls
|
|
//--- understates the error by up to ~sqrt(mean lifespan).
|
|
//--- NOT because the gate below was observed to misfire - measured over the full 6,930-era run it
|
|
//--- fires on 1.5-8.2% of eras, at or under the ~5% a family-wise test should. See
|
|
//--- EffectiveSampleSize(); this is a formula correction, not a bug fix, and it makes the bar
|
|
//--- higher rather than lower.
|
|
binSe[b] = coveragePct * 100.0 * MathSqrt(MathMax(p * (1.0 - p), 0.0)
|
|
/ EffectiveSampleSize((double)runCalls));
|
|
//--- The sweep runs top-down, so the FIRST eligible bin is the most selective one that still
|
|
//--- clears the coverage floor. That point is the deterministic fallback below.
|
|
if(floorBin < 0)
|
|
floorBin = b;
|
|
eligibleBins++;
|
|
//--- Strict >, so a genuine tie keeps the MORE selective point (the loop reaches it first). The
|
|
//--- old >= did the reverse and that is what made the plateau run away.
|
|
if(binScore[b] > bestScore)
|
|
{
|
|
bestScore = binScore[b];
|
|
bestSe = binSe[b];
|
|
bestBin = b;
|
|
}
|
|
}
|
|
if(bestBin < 0)
|
|
{
|
|
//--- Even calling on every directional argmax does not reach the coverage floor, so there is no
|
|
//--- room to be MORE selective. Unthresholded is then the only setting that can clear the gate.
|
|
m_dirConfThreshold = 0.0;
|
|
return;
|
|
}
|
|
//--- SELECTION UNDER A NULL OF THE MAXIMUM, with a parsimony fallback in the spirit of the
|
|
//--- one-standard-error rule (Breiman et al. 1984, CART 3.4.3; Hastie/Tibshirani/Friedman, ESL 2ed
|
|
//--- 7.10 - prefer the simpler model when the score difference is inside the noise). The bare
|
|
//--- argmax above is the right ANSWER only if the curve it maximises is measured well enough to
|
|
//--- rank its own candidates, and on this data it is not. Measured over 98 consecutive fits of the
|
|
//--- shipped SP500 H4 model:
|
|
//---
|
|
//--- correlation(chosen threshold, win rate at it) = -0.056 over the full 0.00..0.74 range
|
|
//--- win rate stdev across fits = 1.32pp
|
|
//--- binomial SE of that win rate at ~1430 calls = 1.25pp
|
|
//---
|
|
//--- The correlation is zero - the margin does not rank trades at all - and the era-to-era spread
|
|
//--- IS its own sampling error to within 0.07pp. So `coverage x (precision - breakEven)` was
|
|
//--- `coverage x (3.4 +/- 1.3)`, and taking the argmax over ~37 eligible bins returned whichever
|
|
//--- bin drew the luckiest sample. The threshold then teleported 0.42 -> 0.04 -> 0.74 in three
|
|
//--- eras, swinging OOS coverage 0% -> 39%, which left the era win rate measured on 1-5 calls and
|
|
//--- swinging 0% <-> 100%. That is the whole of the "training is highly unstable" report, and none
|
|
//--- of it was the optimizer.
|
|
//---
|
|
//--- This is the same defect the family-wise gate rule already governs elsewhere in this file - a
|
|
//--- best-of-N adopted without a null of the maximum - applied here to the operating point rather
|
|
//--- than the deploy decision.
|
|
//---
|
|
//--- THE RULE. The argmax is adopted only if it beats the DETERMINISTIC fallback by more than a
|
|
//--- best-of-N maximum could manage on noise alone; otherwise the fallback is taken.
|
|
//---
|
|
//--- Fallback = the most selective bin that still clears the coverage floor. That point is chosen
|
|
//--- from the MARGIN DISTRIBUTION only - it never consults a win rate - so it carries none of the
|
|
//--- outcome noise that was driving the thrash, and it moves era to era only when the model's own
|
|
//--- confidence distribution genuinely moves. It is also the conservative end of the sweep: the
|
|
//--- fewest bars called that still leaves a deployable model, which is the right default on a
|
|
//--- funded account when no operating point has been shown to be better than another.
|
|
//---
|
|
//--- A plain one-standard-error band was the first thing tried here and it is NOT sufficient: the
|
|
//--- band edge is bestScore - bestSE, and with an edge of 2.3pp against a 1.25pp standard error
|
|
//--- bestScore is itself +/-50%, so the admitted set - and the coverage it implies - would still
|
|
//--- wander by half its own width every era. The fallback has to be independent of the noisy
|
|
//--- quantity, not merely a wider window around it.
|
|
//---
|
|
//--- Significance uses the null of the MAXIMUM, not a per-candidate test: the argmax is the best of
|
|
//--- `eligibleBins` draws, and the expected maximum of N standard normals grows like sqrt(2 ln N),
|
|
//--- so that is the bar it has to clear. Same correction the deploy gate already applies to
|
|
//--- best-of-N model selection, applied here to the operating point.
|
|
double refScore = binScore[floorBin];
|
|
double refSe = binSe[floorBin];
|
|
//--- Conservative: the two points are NESTED samples, so their difference is better determined than
|
|
//--- this independent-errors sum implies. Erring toward "not significant" is the safe direction.
|
|
double seDiff = MathSqrt(bestSe * bestSe + refSe * refSe);
|
|
double zMax = MathSqrt(2.0 * MathLog(MathMax((double)eligibleBins, 2.0)));
|
|
bool separates = ((bestScore - refScore) > zMax * seDiff);
|
|
int selBin = (separates ? bestBin : floorBin);
|
|
double bestThresh = (double)selBin / DIR_CONF_THRESHOLD_BINS;
|
|
double bestPrec = binPrec[selBin];
|
|
double bestCov = binCov[selBin];
|
|
double prevThresh = m_dirConfThreshold;
|
|
m_dirConfThreshold = bestThresh;
|
|
//--- Built as a local rather than inlined into the ternary: the two branches are long enough that
|
|
//--- keeping them out of the argument list is what makes the call readable.
|
|
string ruleNote = "which CLEARS that bar, so the margin genuinely separates these operating points"
|
|
" and the argmax was adopted";
|
|
if(!separates)
|
|
ruleNote = "which it does NOT clear - the margin does not rank these trades, so the operating"
|
|
" point fell back to the most selective bin that still clears the coverage floor."
|
|
" That fallback reads the margin DISTRIBUTION only, never a win rate, so it cannot"
|
|
" thrash on outcome noise the way the argmax did";
|
|
//--- Logged only when it actually moves a bin, so a stable operating point stays quiet.
|
|
if(MathAbs(m_dirConfThreshold - prevThresh) >= 1.0 / DIR_CONF_THRESHOLD_BINS)
|
|
Print(ID + StringFormat(": directional confidence threshold %.2f -> %.2f (fitted on %d HELD-OUT "
|
|
"calibration bars: %.1f%% coverage at %.1f%% WIN RATE vs " + DoubleToString(breakEvenPct, 1) +
|
|
"%% break-even, edge " + DoubleToString(bestPrec - breakEvenPct, 1) +
|
|
"pp, coverage floor %.1f%%). Below "
|
|
"this winner-vs-rival margin the model abstains instead of trading. The "
|
|
"rate is wins - target before stop on the side actually called - not "
|
|
"agreement with the collapsed 3-class label; see m_oosBuyPredictedWins."
|
|
" | best-of-N gate over %d eligible bins: argmax %.2f scored %.0f vs the"
|
|
" %.2f fallback's %.0f, a gap of %.0f against a null-of-the-maximum bar"
|
|
" of %.0f (z_max %.2f x SE %.0f, sized on the EFFECTIVE sample - labels"
|
|
" overlap by a mean lifespan of %.0f bars, so n is deflated %.0fx), %s",
|
|
prevThresh, m_dirConfThreshold, (int)m_dirConfPrimaryBars, bestCov,
|
|
bestPrec, minCoveragePct, eligibleBins,
|
|
(double)bestBin / DIR_CONF_THRESHOLD_BINS, bestScore,
|
|
(double)floorBin / DIR_CONF_THRESHOLD_BINS, refScore,
|
|
bestScore - refScore, zMax * seDiff, zMax, seDiff,
|
|
MeanLabelLifespan(), MeanLabelLifespan(), ruleNote));
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| 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.
|
|
//--- THIS USED TO BE SILENT, and that silence hid a whole-run failure: while the auto-tune search ran,
|
|
//--- UpdateClassPriors() was skipped in eval mode, so this branch was taken on EVERY era and the
|
|
//--- imbalance correction never once ran - with nothing in the log to say so. A mechanism that
|
|
//--- declines to act must announce it; the alternative is indistinguishable from working. Third time
|
|
//--- this codebase has been bitten by a quiet no-op, so it now warns every time it is not merely the
|
|
//--- expected era-0 case.
|
|
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. The binding quantity is the SPREAD between the largest and
|
|
//--- smallest offset, not their absolute size: softmax is shift-invariant, so a constant added to
|
|
//--- all three classes changes nothing and only their differences move the decision.
|
|
double lb = MathLog(m_priorBuy), ls = MathLog(m_priorSell), lnn = MathLog(m_priorNeutral);
|
|
//--- THE CORRECTION SPANS THE DECIDABLE CLASSES ONLY - Buy against Sell. Neutral is excluded, and
|
|
//--- that exclusion is the whole point of this block (2026-08-16).
|
|
//---
|
|
//--- Logit adjustment (Menon et al. 2020) makes the classifier Bayes-optimal for BALANCED error by
|
|
//--- subsidising rare classes. It was wired here when Neutral was the DOMINANT class - the era of
|
|
//--- "big move up / big move down / nothing much", where the majority outcome was no move and the
|
|
//--- correction pulled the model off it. The triple-barrier relabel (b4a704d) inverted that: the
|
|
//--- barriers are now the EA's own SL/TP, so ~89% of bars RESOLVE and only the timeouts are Neutral.
|
|
//--- Measured on SP500 H4: Buy 48.26% Sell 41.13% Neutral 10.61%. Neutral became the RAREST class,
|
|
//--- and the correction dutifully started subsidising it - by tau*(log pB - log pN) = 1.20 logits at
|
|
//--- the capped tau of 0.79. With no directional edge to overcome that (direction is closed at
|
|
//--- best-of-999, p=1.0000), the model took the free lunch: OOS recall Buy:1% Sell:0% Neutral:100%,
|
|
//--- softmax saturated at spread 0.9993, and the first-layer weight block froze at 0.000% dW/W while
|
|
//--- the head kept twitching. The anti-collapse mechanism WAS the collapse.
|
|
//---
|
|
//--- Neutral is not a class worth predicting here - it is the ABSTAIN outcome, and abstention is
|
|
//--- already owned by a better mechanism: m_dirConfThreshold, refitted every era on the held-out
|
|
//--- calibration band against a coverage floor and the measured break-even. Subsidising the abstain
|
|
//--- class does the same job twice and spends the entire correction suppressing the only decisions
|
|
//--- that can make money. What DOES deserve correcting is Buy vs Sell: a trending symbol resolves
|
|
//--- more long barriers than short ones, and left uncorrected the model inherits that drift as a
|
|
//--- standing directional bias. Here that is log(0.4826) - log(0.4113) = 0.16, so the offsets are
|
|
//--- tiny - which is the correct answer, not a broken one. The two classes were already balanced;
|
|
//--- all the old spread of 1.52 ever described was how rare a timeout is.
|
|
//---
|
|
//--- Centred on the midpoint of the two so the pair is corrected against EACH OTHER and Neutral sits
|
|
//--- at zero. Softmax is shift-invariant, so only the differences matter: Neutral now sits within
|
|
//--- tau*0.08 of both trading classes instead of 1.20 above them.
|
|
double mid = 0.5 * (lb + ls);
|
|
double spread = MathAbs(lb - ls);
|
|
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;
|
|
//--- Reports BOTH spreads on purpose. The Buy-vs-Sell one is what is actually applied; the
|
|
//--- all-three one is what the old code applied, and printing them side by side is what makes it
|
|
//--- visible when a label set has drifted so far that the abstain class is the rare one.
|
|
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 Buy/Sell only, log-prior"
|
|
" spread " + DoubleToString(spread, 2) + " (all three would be " +
|
|
DoubleToString(MathMax(lb, MathMax(ls, lnn)) - MathMin(lb, MathMin(ls, lnn)), 2) +
|
|
"; Neutral is the ABSTAIN outcome and is never subsidised - m_dirConfThreshold owns"
|
|
" abstention)" +
|
|
(m_priorNeutral < m_priorBuy && m_priorNeutral < m_priorSell
|
|
? " | note: Neutral is the RAREST class here, so the pre-2026-08-16 all-three form would"
|
|
" have BOOSTED it by " +
|
|
DoubleToString(tauEff * (MathMax(lb, ls) - lnn), 2) + " logits"
|
|
: "") +
|
|
" 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);
|
|
offsets[2] = 0.0; // ABSTAIN class - never subsidised; see the block above
|
|
Net.SetLogitAdjustment(offsets);
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Converts a double to ENUM_SIGNAL. |
|
|
//| 3-output (softmax classification) case: dPrevSignal's *sign* |
|
|
//| alone already encodes the argmax-selected class (+prob for Buy, |
|
|
//| -prob for Sell, exactly 0.0 for Neutral - see Train()/ |
|
|
//| RefreshLatestSignal()), so classification here is pure argmax: |
|
|
//| whichever class the network actually picked, full stop. No |
|
|
//| magnitude threshold is applied - confidence magnitude is a |
|
|
//| separate concern, already exposed via AIConfidence()/ |
|
|
//| SignedAIConfidence() (MathAbs(dPrevSignal)/dPrevSignal) for the |
|
|
//| signal engine's own confidence-weighted filters/lot sizing/SLTP, |
|
|
//| so this keeps "which class" and "how confident" decoupled. |
|
|
//| 1-output (tanh regression) case: unrelated network shape, keeps |
|
|
//| the original 0.50 magnitude cutoff as a genuine confidence gate. |
|
|
//+------------------------------------------------------------------+
|
|
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;
|
|
}
|
|
#endif // WARRIOR_AIBASE_INFERENCE_MQH
|