//+------------------------------------------------------------------+ //| 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, oversampling, backProp, checkpointing). | //+------------------------------------------------------------------+ void CExpertSignalAIBase::RefreshConvergedSignal(void) { int barsNow = (int)MathMin(Bars(m_symbol.Name(), PERIOD_CURRENT, dtStudied, TimeCurrent()) + m_historyBars, Bars(m_symbol.Name(), PERIOD_CURRENT)); if(!ResizeBuffers(barsNow) || !RefreshData()) return; 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(); dtStudied = m_Time.GetData(0); } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CExpertSignalAIBase::RefreshLatestSignal(void) { int i = 0; TempData.Clear(); TempData.Reserve((int)m_historyBars * m_neuronsCount); //--- Window ends AT (includes) bar i - see Train()'s matching r declaration comment for why: this //--- must be the same window Train() learned from, or the deployed model is being queried on a task //--- it was never trained for. int r = i; for(int b = 0; b < (int)m_historyBars; b++) { int bar_t = r + b; if(!BufferTempData(bar_t)) return; } if(TempData.Total() < (int)m_historyBars * m_neuronsCount) return; //--- 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: softmax first (fills TempData with the raw probs), then prior-correct it so //--- the deployed EA fires at the calibrated true-base-rate, not the oversampled-uniform rate the //--- raw argmax over-calls at. This is what makes live trading behave like the panel's live-fired //--- precision metric (which scores this same rule) - see AdjustedSignalFromSoftmax(). double rawSignal = ApplyClassificationSoftmax(); dPrevSignal = AdjustedSignalFromSoftmax(); if(m_inferenceOnly && DoubleToSignal(rawSignal) != Neutral && DoubleToSignal(dPrevSignal) == Neutral) Print(__FUNCTION__ + ": " + ID + " - raw softmax " + DoubleToString(rawSignal, 4) + " was neutralized by prior correction on " + TimeToString(m_Time.GetData(i)) + " | raw probs B=" + DoubleToString(TempData.At(0), 4) + " S=" + DoubleToString(TempData.At(1), 4) + " N=" + DoubleToString(TempData.At(2), 4) + " | priors B=" + DoubleToString(m_priorBuy, 4) + " S=" + DoubleToString(m_priorSell, 4) + " N=" + DoubleToString(m_priorNeutral, 4) + " | tau=" + DoubleToString(m_logitPriorStrength, 2)); } datetime bt = m_Time.GetData(i); //--- Keep a pure inference-side watermark of the newest bar this model has already evaluated. 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 bar instead, so it stays aligned to whichever history the current process is //--- actually traversing. m_lastBarTime = bt; //--- Live NMS: suppress this newest-bar arrow if a same-direction signal was already kept within //--- m_signalClusterWindow bars - the live equivalent of PruneDirectionalClusters' historical sweep, //--- so the forward chart declusters the same way the trained history does (see m_signalClusterWindow). ENUM_SIGNAL lsig = DoubleToSignal(dPrevSignal); if(lsig != Neutral && NmsLiveAccept(bt, lsig, MathAbs(dPrevSignal))) DrawObject(bt, dPrevSignal, m_High.GetData(i), m_Low.GetData(i)); else DeleteObject(bt); } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ double CExpertSignalAIBase::ApplyClassificationSoftmax(void) { // 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. | //| | //| The network trains on class-balance-OVERSAMPLED data, so its | //| softmax estimates P(y|x) under a ~uniform prior and structurally | //| over-calls the rare Buy/Sell classes. Re-weighting each class by | //| its TRUE base rate recovers the true-prior posterior (Saerens et | //| al. 2002 prior correction; Menon et al. 2021 logit adjustment, | //| ICLR): p'_y ∝ p_y · prior_y^tau . tau (m_logitPriorStrength) | //| scales it: 0 = raw (identical to ApplyClassificationSoftmax), 1 = | //| full calibration to the true base rate. Renormalised so the | //| returned magnitude is a genuine probability the confidence floor | //| and ConfidenceTier() can read directly. | //+------------------------------------------------------------------+ double CExpertSignalAIBase::AdjustedSignalFromSoftmax(void) { if(TempData.Total() < 3) return 0.0; double pBuy = TempData.At(0), pSell = TempData.At(1), pNeutral = TempData.At(2); //--- Disabled (tau<=0) or priors not measured yet (era 0, or a model with no saved .stats): behave //--- exactly like the raw argmax so this feature is a strict no-op in that case. //--- m_useLogitAdjustedLoss: the prior is already baked into the weights by the training //--- gradient, so applying it again here would correct for the same base rate twice and //--- push the decision back toward Neutral - undoing exactly what the loss just bought. if(m_useLogitAdjustedLoss || m_logitPriorStrength <= 0.0 || m_priorBuy <= 0.0 || m_priorSell <= 0.0 || m_priorNeutral <= 0.0) { if(pBuy > pSell && pBuy > pNeutral) return pBuy; if(pSell > pBuy && pSell > pNeutral) return -pSell; return 0.0; } //--- Work in log space (softmax outputs are strictly > 0, so log is finite): adj_y = log p_y + //--- tau·log prior_y, then re-softmax to a proper distribution. Max-subtracted for numerical safety. double aBuy = MathLog(pBuy) + m_logitPriorStrength * MathLog(m_priorBuy); double aSell = MathLog(pSell) + m_logitPriorStrength * MathLog(m_priorSell); double aNeutral = MathLog(pNeutral) + m_logitPriorStrength * MathLog(m_priorNeutral); double mx = MathMax(aBuy, MathMax(aSell, aNeutral)); double eBuy = MathExp(aBuy - mx), eSell = MathExp(aSell - mx), eNeutral = MathExp(aNeutral - mx); double z = eBuy + eSell + eNeutral; if(z <= 0.0) return 0.0; double qBuy = eBuy / z, qSell = eSell / z, qNeutral = eNeutral / z; //--- Same strict-majority / ties-to-Neutral rule as ApplyClassificationSoftmax(). if(qBuy > qSell && qBuy > qNeutral) return qBuy; if(qSell > qBuy && qSell > qNeutral) return -qSell; return 0.0; } //+------------------------------------------------------------------+ //| 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; } if(m_useStaticPrior || 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_useLogitAdjustedLoss || 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) { Net.ClearLogitAdjustment(); return; } //--- 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] = m_logitAdjustTau * MathLog(m_priorBuy); offsets[1] = m_logitAdjustTau * MathLog(m_priorSell); offsets[2] = m_logitAdjustTau * MathLog(m_priorNeutral); 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