Warrior_EA/Expert/AIBase/Inference.mqh

383 lines
22 KiB
MQL5
Raw Permalink Normal View History

feat(ai): logit-adjusted loss, replacing oversampling and the post-hoc prior Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add tau*log(prior_c) to each class logit inside the training gradient. Softmax CE on adjusted logits is consistent for BALANCED error - the metric checkpoint selection already ranks on - so the loss and the deploy decision finally optimize the same thing. The engine already computed a true softmax + categorical-CE gradient and wrote it over the per-neuron sigmoid delta, so this is an offset added to three logits in the two places that gradient is built (backProp scalar path and backPropOCL). No backend, kernel or DLL change; the forward pass and every inference path are untouched, which is the point - the network learns to absorb the offset, so its raw argmax becomes the balanced-optimal decision with nothing applied at inference. Replaces rather than stacks. Minority replay is disabled while this is on, and the post-hoc inference prior is forced off. Stacking is not a theoretical worry: simulated on the measured 1118/1119/34298 distribution in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced, Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance - and BOTH together score 45.4% with Neutral recall at 0%, worse than either alone. Buda et al. 2018 predicts exactly that. Motivation from the six-chart run: every topology took one direction to ~50% recall and abandoned the other, the direction chosen arbitrarily (the batch-norm control went Buy 1% / Sell 42%, the inverse of the other five). One era in 1,301 cleared the per-class recall floor. Fingerprinted conditionally, so the converged 60.7% models on disk keep their filenames and stay loadable as the fallback. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:05:14 -04:00
//+------------------------------------------------------------------+
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
//| 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). |
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
//+------------------------------------------------------------------+
void CExpertSignalAIBase::RefreshConvergedSignal(void)
{
feat(ai): triple-barrier labels replace exact-pivot ZigZag targets The 31:1 class imbalance was self-inflicted by the TARGET, not a property of the market. Labelling only the exact bar where a ZigZag pivot confirms gave Buy 1164 / Sell 1164 / Neutral 35841, and every correction mechanism this codebase accumulated sits downstream of that one choice: the logit-adjusted loss and its range cap, the prior EMA, the +-3.0 output-bias seed, balanced-accuracy-then-precision selection with its coverage floor, the recall floor and its catch-22, the alternation gate, NMS, and the four oversampling designs that collapsed before them. The reference this engine is built on (references/neuronetworksbook.pdf ch. 3.1/3.3) also uses ZigZag, but targets the DIRECTION TO THE NEXT EXTREMUM on every bar - ~50/50 by construction, with no imbalance to correct at all. It never had this problem because it never asked "is this the pivot bar". Labels are now the triple barrier (Lopez de Prado ch. 3), using the EA's OWN SL_Mode/TP_Mode: does a trade opened at this bar's close reach its target before its stop, within a horizon. Buy = long resolves, Sell = short resolves, Neutral = neither. Consequences: - dir-precision in the era line stops being a proxy and becomes the win rate of the strategy under its own exit rules. - Expected balance ~25/25/50 at the shipped 1:3 (gambler's ruin), i.e. ~2:1 instead of 31:1. Measured and logged at the end of the prebuild. - Spread is charged on both legs, so it is a NET win rate. - Intrabar ambiguity resolves to the STOP. OHLC cannot order two touches inside one bar and the optimistic reading is how a backtested edge becomes a live loss. ZigZag stays as input features (EnableSwingContext) and now also supplies the vertical barrier: the horizon is the median confirmed leg length, snapped to a coarse ladder. Derived, not configured, and deliberately kept out of the filename fingerprint - a filename keyed on a measured quantity orphans a trained model the moment the measurement moves. Removed, because the premise died with the old target: - the alternation gate. Correct for pivot labels (a ZigZag cannot emit two same-type pivots in a row, so a repeat was provably a false fire), and wrong for barrier labels, which answer each bar independently. It also took its worst consequence with it: a one-sided model previously got ONE trade per backtest, a hard blocker on marketplace validation. - SignalClusterWindow now defaults off - it de-duplicated repeats that are now real trades. Kept as an opt-in display control. - LABEL_WINDOW_BARS, the pivot-widening pass, ConfirmedZigZagLabel. - the era-0 output-bias seed now needs a genuinely dominant class (0.70) rather than 0.40; at ~50% Neutral a +-3.0 seed is a distortion, not a correction. Also fixed, both found while wiring the above: 1. RefreshConvergedSignal sized its buffers from a date delta (Bars(sym, period, dtStudied, TimeCurrent())). dtStudied is a training watermark; in the tester it is loaded from a live-chart save AHEAD of the simulated date, so the interval inverted, Bars() returned ~0, and the buffer came out at exactly m_historyBars - deep enough for the OHLC window and far too shallow for the Donchian-50 / 20-bar-return / SMA extension behind it. Inference silently computed DIFFERENT features from the ones training learned on, live as well as in the tester. Now sized from what the feature builder actually needs. 2. The barrier horizon is resolved on the deployed path too. A deployed model never enters Train(), so it never reached the prebuild, and OnlineLearnStep reads the horizon as its confirmation delay - left at the fallback it would have backpropped bars whose barriers had not resolved. Silent lookahead in the one place that writes to a live model. SL_Mode/TP_Mode join the weights fingerprint: they define the labels now, so a model trained at 1:3 must never be silently reused at 1:1. This re-keys every pre-existing model by design - none were trained on this task. Inference census extended with the vote gate. LongCondition/ShortCondition open with a readiness check the refresh counters never see; in the tester it reduces to "the seeded _optcache.nnw must have LOADED", and if it did not, every vote is hard-zeroed while the model still answers Buy. The old three counters would have read that as "the model says Neutral" - false, and a completely different fix. This is the leading candidate for the zero-direction backtest and the census can now name it in one run. Both builds compile 0 errors / 0 warnings. Forces a full retrain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:39:49 -04:00
//--- 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));
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
if(!ResizeBuffers(barsNow) || !RefreshData())
return;
fix: the sequence models were reading the window backwards BuildFeatureWindow() replaces eight hand-rolled copies of the same loop and feeds the window OLDEST BAR FIRST. Every copy fed it newest-first, because MQL5 timeseries indices run backwards and `r + b` with b ascending walks into the past. Harmless for PAI and CONV - a dense layer learns a weight per position either way, a conv learns time-mirrored kernels. Not harmless for the recurrent stacks: - LSTM_SeqStepForward reads `inputs + t*Iw`, so step t is block t. - It writes output[] only when t == steps-1: the visible output IS the last hidden state. - c_t = f*c_{t-1} + i*g decays toward the start of the sequence. lstm_seq_flowcheck.cpp measured block 0's influence on the output at 1.2e-2 of block T-1's, at the shipped forget bias of 1.0. So the bar being PREDICTED sat at the far end of the decay and the output was handed to the OLDEST bar in the window - the exact inverse of what the window is for. ~80x backwards on LSTM and HYBRID, on all three tiers (OpenCL kernel, CPU DLL, pure-MQL5 inference), which is why it never surfaced as a backend discrepancy. This does not create edge - the MI diagnostics read at the noise floor (p=0.4975) with a working positive control. It makes the one hypothesis those diagnostics explicitly do NOT cover testable: they are marginal and per-bar, and state they "cannot rule out one that only exists in combination or across time". The sequence model is the instrument for across-time structure and it has been crippled, so that hypothesis has never been honestly tested. Fingerprint gets an unconditional |WIN:2 - the vector keeps its shape and its features, so a stale .nnw would load cleanly and run a model fitted to one ordering against the other, silently. Re-keying every config is the point, not collateral damage. FORCES A FULL RETRAIN. Also: the now-relative bar caches are re-keyed on the two live paths. EnsureBarCachesCapacity() was only ever called from training paths, but once m_trainingComplete is set ScheduleTrainingIfNeeded() routes every bar to RefreshConvergedSignal() and Train() is never re-entered - so nothing cleared the feature cache again for the life of the process. A chart that trained to convergence kept replaying the rows computed for the last training era's bar grid: the live signal froze at its convergence-time value, and OnlineLearnStep() backpropped those stale features against freshly resolved labels. Backtests were never affected (an inference-only process never allocates the arrays, so every read recomputes). Compiles clean: 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:28:44 -04:00
//--- 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);
fix(signals): revive a dead MA model, and demote Sanyaku from state to event Two defects surfaced by research/test_classic.py, both verified fixed by re-running the transcription against 178k bars of EURUSD H1. CSignalMA model 1 could never fire. For any recursive average - and MA_TYPE_EMA is the shipped default - MA(i) = a*Close(i) + (1-a)*MA(i+1), so DiffMA(i) = a * (Close(i) - MA(i+1)) DiffCloseMA(i) = (1-a) * (Close(i) - MA(i+1)) are positive multiples of one quantity and always share a sign. Model 1 asks for a close BELOW a RISING average, which is precisely the combination that identity forbids: 0.000% of bars, either direction, any symbol. The MQL5 standard library this was ported from defaults to MODE_SMA, where the two are merely correlated - the bug arrived with the EMA default, not with the port. Reading the slope one bar back (DiffMAPrev) breaks the tie for every MA type while keeping the model's stated meaning. Now fires on 7.92% of bars. CSignalIchimoku model 11 fired on 27% of bars at weight 100. Sanyaku is three standing STATES conjoined with no transition term, so it held across long stretches - and being last in the if-chain at the top weight, the module's highest-conviction reading was also its most common one, overwriting all eight event models below it on a quarter of all bars. The old comment rejected an event form because "demanding all three flip on the same bar would fire almost never" - true, but that is not the alternative. Kouten is the TURN: the ALIGNMENT transitions, and only one role need change for it to. Testing !Sanyaku(idx+1) fires once per aligned stretch. Now 2.17%, in line with Kumo breakout (2.4%) and the strong TK cross (1.1%). DataReady() extended one bar deeper to cover the lookback. Neither pattern showed edge before or after; this is about the models meaning what they say and the vote not being dominated by a constant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:14:34 -04:00
//--- 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.
feat(ai): spread as a volatility-regime feature, and fix a stale-index cache in both new blocks Adds spread/ATR and the spread change ratio as network inputs (EnableSpreadFeature, default on). Spread is the one microstructure channel that is both FX-available and genuinely historical in the Strategy Tester - "during testing, the spread is not modeled but is taken from historical data" - so unlike swap, signed tick flow or depth of market it is something a backtest can honestly validate. What it encodes, stated precisely because the raw measurement overstates it. research/test_spread.py found spr/atr the strongest single feature in this codebase, on 5 of 8 instrument/geometry cells at 2-4x any volume feature. But the barrier LABEL charges the spread inside its own barriers, so a wide-spread bar is mechanically likelier to resolve as a loss and the feature would partly be predicting its own cost model. Relabelling at zero cost and re-measuring the identical feature showed 20-40% of it WAS that tautology and the majority was not (XAUUSD retained 97%). What survives is a volatility-regime reading: spread is near-fixed while ATR is not, so the ratio runs high exactly when realised volatility is below its own ATR estimate, which genuinely predicts whether ATR-scaled barriers get reached. It is UNSIGNED - Neutral-vs-directional only, never a side. Also fixes a stale-index bug I introduced with the cross-asset panel and had just repeated in the spread series. Both cached on length alone: if(m_crossAsset.Bars() >= bars) return true; MQL5 series indices are relative to NOW, so one new closed candle shifts every index by one. Keyed only on length, the panel keeps serving its index 0 as a bar that is no longer the newest, and every cross-asset value is read one bar out of step with the price features sitting beside it in the same vector - silently, with no error and no shape change. This is the same class of defect as the dtStudied watermark behind the zero-direction backtests. Both now carry a datetime anchor on m_Time.GetData(0), the same invalidation key the label/feature bar caches already use. And a performance fix that fell out of it: with correct invalidation the panel rebuilds on every new bar, and RefreshConvergedSignal runs per bar - which in the tester would mean one full multi-symbol resample per simulated bar at training depth. Inference only reads bars 0..m_historyBars-1 plus the panel's own slow window, so it now requests exactly that. The cache check is >=, so a deeper panel left from training still satisfies it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:42:40 -04:00
//--- Only as deep as inference actually reads. RefreshLatestSignal() touches bars 0..m_historyBars-1
//--- 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);
feat(ai): triple-barrier labels replace exact-pivot ZigZag targets The 31:1 class imbalance was self-inflicted by the TARGET, not a property of the market. Labelling only the exact bar where a ZigZag pivot confirms gave Buy 1164 / Sell 1164 / Neutral 35841, and every correction mechanism this codebase accumulated sits downstream of that one choice: the logit-adjusted loss and its range cap, the prior EMA, the +-3.0 output-bias seed, balanced-accuracy-then-precision selection with its coverage floor, the recall floor and its catch-22, the alternation gate, NMS, and the four oversampling designs that collapsed before them. The reference this engine is built on (references/neuronetworksbook.pdf ch. 3.1/3.3) also uses ZigZag, but targets the DIRECTION TO THE NEXT EXTREMUM on every bar - ~50/50 by construction, with no imbalance to correct at all. It never had this problem because it never asked "is this the pivot bar". Labels are now the triple barrier (Lopez de Prado ch. 3), using the EA's OWN SL_Mode/TP_Mode: does a trade opened at this bar's close reach its target before its stop, within a horizon. Buy = long resolves, Sell = short resolves, Neutral = neither. Consequences: - dir-precision in the era line stops being a proxy and becomes the win rate of the strategy under its own exit rules. - Expected balance ~25/25/50 at the shipped 1:3 (gambler's ruin), i.e. ~2:1 instead of 31:1. Measured and logged at the end of the prebuild. - Spread is charged on both legs, so it is a NET win rate. - Intrabar ambiguity resolves to the STOP. OHLC cannot order two touches inside one bar and the optimistic reading is how a backtested edge becomes a live loss. ZigZag stays as input features (EnableSwingContext) and now also supplies the vertical barrier: the horizon is the median confirmed leg length, snapped to a coarse ladder. Derived, not configured, and deliberately kept out of the filename fingerprint - a filename keyed on a measured quantity orphans a trained model the moment the measurement moves. Removed, because the premise died with the old target: - the alternation gate. Correct for pivot labels (a ZigZag cannot emit two same-type pivots in a row, so a repeat was provably a false fire), and wrong for barrier labels, which answer each bar independently. It also took its worst consequence with it: a one-sided model previously got ONE trade per backtest, a hard blocker on marketplace validation. - SignalClusterWindow now defaults off - it de-duplicated repeats that are now real trades. Kept as an opt-in display control. - LABEL_WINDOW_BARS, the pivot-widening pass, ConfirmedZigZagLabel. - the era-0 output-bias seed now needs a genuinely dominant class (0.70) rather than 0.40; at ~50% Neutral a +-3.0 seed is a distortion, not a correction. Also fixed, both found while wiring the above: 1. RefreshConvergedSignal sized its buffers from a date delta (Bars(sym, period, dtStudied, TimeCurrent())). dtStudied is a training watermark; in the tester it is loaded from a live-chart save AHEAD of the simulated date, so the interval inverted, Bars() returned ~0, and the buffer came out at exactly m_historyBars - deep enough for the OHLC window and far too shallow for the Donchian-50 / 20-bar-return / SMA extension behind it. Inference silently computed DIFFERENT features from the ones training learned on, live as well as in the tester. Now sized from what the feature builder actually needs. 2. The barrier horizon is resolved on the deployed path too. A deployed model never enters Train(), so it never reached the prebuild, and OnlineLearnStep reads the horizon as its confirmation delay - left at the fallback it would have backpropped bars whose barriers had not resolved. Silent lookahead in the one place that writes to a live model. SL_Mode/TP_Mode join the weights fingerprint: they define the labels now, so a model trained at 1:3 must never be silently reused at 1:1. This re-keys every pre-existing model by design - none were trained on this task. Inference census extended with the vote gate. LongCondition/ShortCondition open with a readiness check the refresh counters never see; in the tester it reduces to "the seeded _optcache.nnw must have LOADED", and if it did not, every vote is hard-zeroed while the model still answers Buy. The old three counters would have read that as "the model says Neutral" - false, and a completely different fix. This is the leading candidate for the zero-direction backtest and the census can now name it in one run. Both builds compile 0 errors / 0 warnings. Forces a full retrain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:39:49 -04:00
//--- 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);
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
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;
//--- 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
fix: the sequence models were reading the window backwards BuildFeatureWindow() replaces eight hand-rolled copies of the same loop and feeds the window OLDEST BAR FIRST. Every copy fed it newest-first, because MQL5 timeseries indices run backwards and `r + b` with b ascending walks into the past. Harmless for PAI and CONV - a dense layer learns a weight per position either way, a conv learns time-mirrored kernels. Not harmless for the recurrent stacks: - LSTM_SeqStepForward reads `inputs + t*Iw`, so step t is block t. - It writes output[] only when t == steps-1: the visible output IS the last hidden state. - c_t = f*c_{t-1} + i*g decays toward the start of the sequence. lstm_seq_flowcheck.cpp measured block 0's influence on the output at 1.2e-2 of block T-1's, at the shipped forget bias of 1.0. So the bar being PREDICTED sat at the far end of the decay and the output was handed to the OLDEST bar in the window - the exact inverse of what the window is for. ~80x backwards on LSTM and HYBRID, on all three tiers (OpenCL kernel, CPU DLL, pure-MQL5 inference), which is why it never surfaced as a backend discrepancy. This does not create edge - the MI diagnostics read at the noise floor (p=0.4975) with a working positive control. It makes the one hypothesis those diagnostics explicitly do NOT cover testable: they are marginal and per-bar, and state they "cannot rule out one that only exists in combination or across time". The sequence model is the instrument for across-time structure and it has been crippled, so that hypothesis has never been honestly tested. Fingerprint gets an unconditional |WIN:2 - the vector keeps its shape and its features, so a stale .nnw would load cleanly and run a model fitted to one ordering against the other, silently. Re-keying every config is the point, not collateral damage. FORCES A FULL RETRAIN. Also: the now-relative bar caches are re-keyed on the two live paths. EnsureBarCachesCapacity() was only ever called from training paths, but once m_trainingComplete is set ScheduleTrainingIfNeeded() routes every bar to RefreshConvergedSignal() and Train() is never re-entered - so nothing cleared the feature cache again for the life of the process. A chart that trained to convergence kept replaying the rows computed for the last training era's bar grid: the live signal froze at its convergence-time value, and OnlineLearnStep() backpropped those stale features against freshly resolved labels. Backtests were never affected (an inference-only process never allocates the arrays, so every read recomputes). Compiles clean: 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:28:44 -04:00
//--- it was never trained for. Both go through BuildFeatureWindow(), which is what guarantees that.
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
int r = i;
fix: the sequence models were reading the window backwards BuildFeatureWindow() replaces eight hand-rolled copies of the same loop and feeds the window OLDEST BAR FIRST. Every copy fed it newest-first, because MQL5 timeseries indices run backwards and `r + b` with b ascending walks into the past. Harmless for PAI and CONV - a dense layer learns a weight per position either way, a conv learns time-mirrored kernels. Not harmless for the recurrent stacks: - LSTM_SeqStepForward reads `inputs + t*Iw`, so step t is block t. - It writes output[] only when t == steps-1: the visible output IS the last hidden state. - c_t = f*c_{t-1} + i*g decays toward the start of the sequence. lstm_seq_flowcheck.cpp measured block 0's influence on the output at 1.2e-2 of block T-1's, at the shipped forget bias of 1.0. So the bar being PREDICTED sat at the far end of the decay and the output was handed to the OLDEST bar in the window - the exact inverse of what the window is for. ~80x backwards on LSTM and HYBRID, on all three tiers (OpenCL kernel, CPU DLL, pure-MQL5 inference), which is why it never surfaced as a backend discrepancy. This does not create edge - the MI diagnostics read at the noise floor (p=0.4975) with a working positive control. It makes the one hypothesis those diagnostics explicitly do NOT cover testable: they are marginal and per-bar, and state they "cannot rule out one that only exists in combination or across time". The sequence model is the instrument for across-time structure and it has been crippled, so that hypothesis has never been honestly tested. Fingerprint gets an unconditional |WIN:2 - the vector keeps its shape and its features, so a stale .nnw would load cleanly and run a model fitted to one ordering against the other, silently. Re-keying every config is the point, not collateral damage. FORCES A FULL RETRAIN. Also: the now-relative bar caches are re-keyed on the two live paths. EnsureBarCachesCapacity() was only ever called from training paths, but once m_trainingComplete is set ScheduleTrainingIfNeeded() routes every bar to RefreshConvergedSignal() and Train() is never re-entered - so nothing cleared the feature cache again for the life of the process. A chart that trained to convergence kept replaying the rows computed for the last training era's bar grid: the live signal froze at its convergence-time value, and OnlineLearnStep() backpropped those stale features against freshly resolved labels. Backtests were never affected (an inference-only process never allocates the arrays, so every read recomputes). Compiles clean: 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:28:44 -04:00
if(!BuildFeatureWindow(r))
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
{
fix: the sequence models were reading the window backwards BuildFeatureWindow() replaces eight hand-rolled copies of the same loop and feeds the window OLDEST BAR FIRST. Every copy fed it newest-first, because MQL5 timeseries indices run backwards and `r + b` with b ascending walks into the past. Harmless for PAI and CONV - a dense layer learns a weight per position either way, a conv learns time-mirrored kernels. Not harmless for the recurrent stacks: - LSTM_SeqStepForward reads `inputs + t*Iw`, so step t is block t. - It writes output[] only when t == steps-1: the visible output IS the last hidden state. - c_t = f*c_{t-1} + i*g decays toward the start of the sequence. lstm_seq_flowcheck.cpp measured block 0's influence on the output at 1.2e-2 of block T-1's, at the shipped forget bias of 1.0. So the bar being PREDICTED sat at the far end of the decay and the output was handed to the OLDEST bar in the window - the exact inverse of what the window is for. ~80x backwards on LSTM and HYBRID, on all three tiers (OpenCL kernel, CPU DLL, pure-MQL5 inference), which is why it never surfaced as a backend discrepancy. This does not create edge - the MI diagnostics read at the noise floor (p=0.4975) with a working positive control. It makes the one hypothesis those diagnostics explicitly do NOT cover testable: they are marginal and per-bar, and state they "cannot rule out one that only exists in combination or across time". The sequence model is the instrument for across-time structure and it has been crippled, so that hypothesis has never been honestly tested. Fingerprint gets an unconditional |WIN:2 - the vector keeps its shape and its features, so a stale .nnw would load cleanly and run a model fitted to one ordering against the other, silently. Re-keying every config is the point, not collateral damage. FORCES A FULL RETRAIN. Also: the now-relative bar caches are re-keyed on the two live paths. EnsureBarCachesCapacity() was only ever called from training paths, but once m_trainingComplete is set ScheduleTrainingIfNeeded() routes every bar to RefreshConvergedSignal() and Train() is never re-entered - so nothing cleared the feature cache again for the life of the process. A chart that trained to convergence kept replaying the rows computed for the last training era's bar grid: the live signal froze at its convergence-time value, and OnlineLearnStep() backpropped those stale features against freshly resolved labels. Backtests were never affected (an inference-only process never allocates the arrays, so every read recomputes). Compiles clean: 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:28:44 -04:00
//--- 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
diag: inference-path census, to explain zero-trade backtests A backtest of the CONVERGED CONV model produced "Final directional result: 0.00000000" on every one of 1744 bars and therefore zero trades. Nothing in the log could separate the three candidate causes, and each needs a different fix: 1. RefreshLatestSignal never called (new-bar gate never fires) 2. called, but bailing at one of its two early returns 3. running fine, and the model genuinely answers Neutral every bar Counts all three plus the Buy/Sell/Neutral split, printed once at shutdown via StopTraining (which the tester reaches through OnDeinit). Three increments per bar against a full feedForward - not worth gating. Ruled out while writing this, so the next session does not re-derive it: - the alternation gate (m_lastNonNeutralSignal) is NOT the cause. It starts at Neutral, so a first Buy would still fire and show up as one non-zero direction. We saw zero. It IS still a live hazard for a one-sided model - CONV currently calls Buy:17% Sell:0%, and after the first Buy every later Buy is suppressed until a Sell that never comes - but it cannot explain an all-zero run. - shallow buffers do not hard-fail the feature builder: the swing-context Donchian loop breaks gracefully when it runs off loaded history. It does mean converged-path inference computes Donchian/return/SMA features over a TRUNCATED window versus training, which is a real train/inference skew worth its own fix, but it degrades features rather than zeroing them. Both builds 0/0. Diagnostic only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:24:32 -04:00
m_refreshFailFeatures++; // see PrintInferenceTally()
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
return;
diag: inference-path census, to explain zero-trade backtests A backtest of the CONVERGED CONV model produced "Final directional result: 0.00000000" on every one of 1744 bars and therefore zero trades. Nothing in the log could separate the three candidate causes, and each needs a different fix: 1. RefreshLatestSignal never called (new-bar gate never fires) 2. called, but bailing at one of its two early returns 3. running fine, and the model genuinely answers Neutral every bar Counts all three plus the Buy/Sell/Neutral split, printed once at shutdown via StopTraining (which the tester reaches through OnDeinit). Three increments per bar against a full feedForward - not worth gating. Ruled out while writing this, so the next session does not re-derive it: - the alternation gate (m_lastNonNeutralSignal) is NOT the cause. It starts at Neutral, so a first Buy would still fire and show up as one non-zero direction. We saw zero. It IS still a live hazard for a one-sided model - CONV currently calls Buy:17% Sell:0%, and after the first Buy every later Buy is suppressed until a Sell that never comes - but it cannot explain an all-zero run. - shallow buffers do not hard-fail the feature builder: the swing-context Donchian loop breaks gracefully when it runs off loaded history. It does mean converged-path inference computes Donchian/return/SMA features over a TRUNCATED window versus training, which is a real train/inference skew worth its own fix, but it degrades features rather than zeroing them. Both builds 0/0. Diagnostic only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:24:32 -04:00
}
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
//--- 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)
{
refactor(ai): nine class-imbalance inputs down to two The imbalance section offered nine controls for one job. Audited against the code, five of them did not do what their names said at the shipped defaults: AILogitPriorStrength DEAD - Inference.mqh's post-hoc prior early-returns whenever the adjusted loss is on, which is default. OversampleParity DEAD in training - Training.mqh gated the replay loop on !useLogitAdjustedLoss (correctly, citing Buda et al. 2018). Live only in the online-learning path. EnableMinorityReplay DEAD as replay. It survived ONLY as a focal-gamma damper - "replay minority bars through pass-2 oversampling" was a focal-loss switch. ConstrainReplay DEAD as a cap; it only chose damper 0.125 vs 0.25. UseStaticPrior An exact duplicate of FreezePriorCalibration - the two were OR'd together in the single place either is read. So they were not five mechanisms fighting; they were one mechanism plus eight knobs that mostly described machinery that no longer ran. That is worse than a real conflict, because the log agreed with the names: the label-cache line printed "reps up to 28x (90% parity) (seeding era 0's class-balance oversampling)" on every run, describing an oversampling pass that had been switched off. It is fixed here too - it cost this session a wrong diagnosis. The one genuine redundancy was focal loss, running at gamma*0.125 alongside the adjusted loss: two corrections on the same axis, the exact stacking failure this file already cited Buda et al. for in two other places, damped by a replay flag whose replay path was itself dead. Removed rather than re-tuned. The plateau ladder is unaffected - its escape is the learning-rate warm restart; the gamma anneal beside it only ever stepped toward zero. WHAT REMAINS is logit-adjusted loss (Menon et al. 2021) plus a prior freeze: LogitAdjustTau 0 = off; replaces the separate EnableLogitAdjusted- Loss boolean, since a strength dial where 0 already means off does not need an on/off switch beside it. FreezePriorCalibration unchanged. It is the only one of the six corrections with a consistency guarantee, and it is consistent for exactly the balanced-error metric checkpoint selection already ranks on - so the loss and the deploy decision optimize one thing. The online continual-learning path keeps its own alpha-balanced focal weight, now as constants pinned to the removed inputs' shipped defaults, so its behaviour is unchanged. It legitimately needs its own correction: ApplyLogitAdjustment() only runs inside a training run, so a deployed model that was reloaded carries no logit offsets and would otherwise stream 31:1 data into itself uncorrected. The weights-filename fingerprint is BYTE-IDENTICAL. The focal slot was a double fed to a %d conversion and had always emitted a literal 0; the |MR: segment is written as the constant its shipped defaults produced. Dropping either would have re-keyed every model and forced a from-scratch retrain of the one topology currently converged and trading. Also removed as orphans: FOCAL_GAMMA_PRESET, MAX_OVERSAMPLE_REPLICAS, OVERSAMPLE_PARITY_FRACTION, PLATEAU_GAMMA_STEP, and the now-unreachable "neutralized by prior correction" diagnostic. Both builds compile 0 errors, 0 warnings. No retrain forced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:46:57 -04:00
//--- 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();
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
dPrevSignal = AdjustedSignalFromSoftmax();
}
diag: inference-path census, to explain zero-trade backtests A backtest of the CONVERGED CONV model produced "Final directional result: 0.00000000" on every one of 1744 bars and therefore zero trades. Nothing in the log could separate the three candidate causes, and each needs a different fix: 1. RefreshLatestSignal never called (new-bar gate never fires) 2. called, but bailing at one of its two early returns 3. running fine, and the model genuinely answers Neutral every bar Counts all three plus the Buy/Sell/Neutral split, printed once at shutdown via StopTraining (which the tester reaches through OnDeinit). Three increments per bar against a full feedForward - not worth gating. Ruled out while writing this, so the next session does not re-derive it: - the alternation gate (m_lastNonNeutralSignal) is NOT the cause. It starts at Neutral, so a first Buy would still fire and show up as one non-zero direction. We saw zero. It IS still a live hazard for a one-sided model - CONV currently calls Buy:17% Sell:0%, and after the first Buy every later Buy is suppressed until a Sell that never comes - but it cannot explain an all-zero run. - shallow buffers do not hard-fail the feature builder: the swing-context Donchian loop breaks gracefully when it runs off loaded history. It does mean converged-path inference computes Donchian/return/SMA features over a TRUNCATED window versus training, which is a real train/inference skew worth its own fix, but it degrades features rather than zeroing them. Both builds 0/0. Diagnostic only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:24:32 -04:00
m_refreshOk++;
switch(DoubleToSignal(dPrevSignal))
{
case Buy:
m_refreshBuy++;
break;
case Sell:
m_refreshSell++;
break;
default:
m_refreshNeutral++;
break;
}
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
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)
{
// 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;
}
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
// 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. |
//| |
refactor(ai): nine class-imbalance inputs down to two The imbalance section offered nine controls for one job. Audited against the code, five of them did not do what their names said at the shipped defaults: AILogitPriorStrength DEAD - Inference.mqh's post-hoc prior early-returns whenever the adjusted loss is on, which is default. OversampleParity DEAD in training - Training.mqh gated the replay loop on !useLogitAdjustedLoss (correctly, citing Buda et al. 2018). Live only in the online-learning path. EnableMinorityReplay DEAD as replay. It survived ONLY as a focal-gamma damper - "replay minority bars through pass-2 oversampling" was a focal-loss switch. ConstrainReplay DEAD as a cap; it only chose damper 0.125 vs 0.25. UseStaticPrior An exact duplicate of FreezePriorCalibration - the two were OR'd together in the single place either is read. So they were not five mechanisms fighting; they were one mechanism plus eight knobs that mostly described machinery that no longer ran. That is worse than a real conflict, because the log agreed with the names: the label-cache line printed "reps up to 28x (90% parity) (seeding era 0's class-balance oversampling)" on every run, describing an oversampling pass that had been switched off. It is fixed here too - it cost this session a wrong diagnosis. The one genuine redundancy was focal loss, running at gamma*0.125 alongside the adjusted loss: two corrections on the same axis, the exact stacking failure this file already cited Buda et al. for in two other places, damped by a replay flag whose replay path was itself dead. Removed rather than re-tuned. The plateau ladder is unaffected - its escape is the learning-rate warm restart; the gamma anneal beside it only ever stepped toward zero. WHAT REMAINS is logit-adjusted loss (Menon et al. 2021) plus a prior freeze: LogitAdjustTau 0 = off; replaces the separate EnableLogitAdjusted- Loss boolean, since a strength dial where 0 already means off does not need an on/off switch beside it. FreezePriorCalibration unchanged. It is the only one of the six corrections with a consistency guarantee, and it is consistent for exactly the balanced-error metric checkpoint selection already ranks on - so the loss and the deploy decision optimize one thing. The online continual-learning path keeps its own alpha-balanced focal weight, now as constants pinned to the removed inputs' shipped defaults, so its behaviour is unchanged. It legitimately needs its own correction: ApplyLogitAdjustment() only runs inside a training run, so a deployed model that was reloaded carries no logit offsets and would otherwise stream 31:1 data into itself uncorrected. The weights-filename fingerprint is BYTE-IDENTICAL. The focal slot was a double fed to a %d conversion and had always emitted a literal 0; the |MR: segment is written as the constant its shipped defaults produced. Dropping either would have re-keyed every model and forced a from-scratch retrain of the one topology currently converged and trading. Also removed as orphans: FOCAL_GAMMA_PRESET, MAX_OVERSAMPLE_REPLICAS, OVERSAMPLE_PARITY_FRACTION, PLATEAU_GAMMA_STEP, and the now-unreachable "neutralized by prior correction" diagnostic. Both builds compile 0 errors, 0 warnings. No retrain forced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:46:57 -04:00
//| 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. |
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
//+------------------------------------------------------------------+
double CExpertSignalAIBase::AdjustedSignalFromSoftmax(void)
{
if(TempData.Total() < 3)
return 0.0;
double pBuy = TempData.At(0), pSell = TempData.At(1), pNeutral = TempData.At(2);
refactor(ai): nine class-imbalance inputs down to two The imbalance section offered nine controls for one job. Audited against the code, five of them did not do what their names said at the shipped defaults: AILogitPriorStrength DEAD - Inference.mqh's post-hoc prior early-returns whenever the adjusted loss is on, which is default. OversampleParity DEAD in training - Training.mqh gated the replay loop on !useLogitAdjustedLoss (correctly, citing Buda et al. 2018). Live only in the online-learning path. EnableMinorityReplay DEAD as replay. It survived ONLY as a focal-gamma damper - "replay minority bars through pass-2 oversampling" was a focal-loss switch. ConstrainReplay DEAD as a cap; it only chose damper 0.125 vs 0.25. UseStaticPrior An exact duplicate of FreezePriorCalibration - the two were OR'd together in the single place either is read. So they were not five mechanisms fighting; they were one mechanism plus eight knobs that mostly described machinery that no longer ran. That is worse than a real conflict, because the log agreed with the names: the label-cache line printed "reps up to 28x (90% parity) (seeding era 0's class-balance oversampling)" on every run, describing an oversampling pass that had been switched off. It is fixed here too - it cost this session a wrong diagnosis. The one genuine redundancy was focal loss, running at gamma*0.125 alongside the adjusted loss: two corrections on the same axis, the exact stacking failure this file already cited Buda et al. for in two other places, damped by a replay flag whose replay path was itself dead. Removed rather than re-tuned. The plateau ladder is unaffected - its escape is the learning-rate warm restart; the gamma anneal beside it only ever stepped toward zero. WHAT REMAINS is logit-adjusted loss (Menon et al. 2021) plus a prior freeze: LogitAdjustTau 0 = off; replaces the separate EnableLogitAdjusted- Loss boolean, since a strength dial where 0 already means off does not need an on/off switch beside it. FreezePriorCalibration unchanged. It is the only one of the six corrections with a consistency guarantee, and it is consistent for exactly the balanced-error metric checkpoint selection already ranks on - so the loss and the deploy decision optimize one thing. The online continual-learning path keeps its own alpha-balanced focal weight, now as constants pinned to the removed inputs' shipped defaults, so its behaviour is unchanged. It legitimately needs its own correction: ApplyLogitAdjustment() only runs inside a training run, so a deployed model that was reloaded carries no logit offsets and would otherwise stream 31:1 data into itself uncorrected. The weights-filename fingerprint is BYTE-IDENTICAL. The focal slot was a double fed to a %d conversion and had always emitted a literal 0; the |MR: segment is written as the constant its shipped defaults produced. Dropping either would have re-keyed every model and forced a from-scratch retrain of the one topology currently converged and trading. Also removed as orphans: FOCAL_GAMMA_PRESET, MAX_OVERSAMPLE_REPLICAS, OVERSAMPLE_PARITY_FRACTION, PLATEAU_GAMMA_STEP, and the now-unreachable "neutralized by prior correction" diagnostic. Both builds compile 0 errors, 0 warnings. No retrain forced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:46:57 -04:00
//--- Strict majority, ties to Neutral - the same rule as ApplyClassificationSoftmax(). The returned
//--- magnitude is a genuine probability, which the confidence floor and ConfidenceTier() read.
if(pBuy > pSell && pBuy > pNeutral)
return pBuy;
if(pSell > pBuy && pSell > pNeutral)
return -pSell;
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
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;
}
refactor(ai): nine class-imbalance inputs down to two The imbalance section offered nine controls for one job. Audited against the code, five of them did not do what their names said at the shipped defaults: AILogitPriorStrength DEAD - Inference.mqh's post-hoc prior early-returns whenever the adjusted loss is on, which is default. OversampleParity DEAD in training - Training.mqh gated the replay loop on !useLogitAdjustedLoss (correctly, citing Buda et al. 2018). Live only in the online-learning path. EnableMinorityReplay DEAD as replay. It survived ONLY as a focal-gamma damper - "replay minority bars through pass-2 oversampling" was a focal-loss switch. ConstrainReplay DEAD as a cap; it only chose damper 0.125 vs 0.25. UseStaticPrior An exact duplicate of FreezePriorCalibration - the two were OR'd together in the single place either is read. So they were not five mechanisms fighting; they were one mechanism plus eight knobs that mostly described machinery that no longer ran. That is worse than a real conflict, because the log agreed with the names: the label-cache line printed "reps up to 28x (90% parity) (seeding era 0's class-balance oversampling)" on every run, describing an oversampling pass that had been switched off. It is fixed here too - it cost this session a wrong diagnosis. The one genuine redundancy was focal loss, running at gamma*0.125 alongside the adjusted loss: two corrections on the same axis, the exact stacking failure this file already cited Buda et al. for in two other places, damped by a replay flag whose replay path was itself dead. Removed rather than re-tuned. The plateau ladder is unaffected - its escape is the learning-rate warm restart; the gamma anneal beside it only ever stepped toward zero. WHAT REMAINS is logit-adjusted loss (Menon et al. 2021) plus a prior freeze: LogitAdjustTau 0 = off; replaces the separate EnableLogitAdjusted- Loss boolean, since a strength dial where 0 already means off does not need an on/off switch beside it. FreezePriorCalibration unchanged. It is the only one of the six corrections with a consistency guarantee, and it is consistent for exactly the balanced-error metric checkpoint selection already ranks on - so the loss and the deploy decision optimize one thing. The online continual-learning path keeps its own alpha-balanced focal weight, now as constants pinned to the removed inputs' shipped defaults, so its behaviour is unchanged. It legitimately needs its own correction: ApplyLogitAdjustment() only runs inside a training run, so a deployed model that was reloaded carries no logit offsets and would otherwise stream 31:1 data into itself uncorrected. The weights-filename fingerprint is BYTE-IDENTICAL. The focal slot was a double fed to a %d conversion and had always emitted a literal 0; the |MR: segment is written as the constant its shipped defaults produced. Dropping either would have re-keyed every model and forced a from-scratch retrain of the one topology currently converged and trading. Also removed as orphans: FOCAL_GAMMA_PRESET, MAX_OVERSAMPLE_REPLICAS, OVERSAMPLE_PARITY_FRACTION, PLATEAU_GAMMA_STEP, and the now-unreachable "neutralized by prior correction" diagnostic. Both builds compile 0 errors, 0 warnings. No retrain forced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:46:57 -04:00
//--- (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)
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
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;
}
//+------------------------------------------------------------------+
feat(ai): logit-adjusted loss, replacing oversampling and the post-hoc prior Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add tau*log(prior_c) to each class logit inside the training gradient. Softmax CE on adjusted logits is consistent for BALANCED error - the metric checkpoint selection already ranks on - so the loss and the deploy decision finally optimize the same thing. The engine already computed a true softmax + categorical-CE gradient and wrote it over the per-neuron sigmoid delta, so this is an offset added to three logits in the two places that gradient is built (backProp scalar path and backPropOCL). No backend, kernel or DLL change; the forward pass and every inference path are untouched, which is the point - the network learns to absorb the offset, so its raw argmax becomes the balanced-optimal decision with nothing applied at inference. Replaces rather than stacks. Minority replay is disabled while this is on, and the post-hoc inference prior is forced off. Stacking is not a theoretical worry: simulated on the measured 1118/1119/34298 distribution in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced, Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance - and BOTH together score 45.4% with Neutral recall at 0%, worse than either alone. Buda et al. 2018 predicts exactly that. Motivation from the six-chart run: every topology took one direction to ~50% recall and abandoned the other, the direction chosen arbitrarily (the batch-norm control went Buy 1% / Sell 42%, the inverse of the other five). One era in 1,301 cleared the per-class recall floor. Fingerprinted conditionally, so the converged 60.7% models on disk keep their filenames and stay loadable as the fallback. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:05:14 -04:00
//| Installs the training-time logit offsets - see the declaration. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ApplyLogitAdjustment(void)
{
if(CheckPointer(Net) == POINTER_INVALID)
return;
refactor(ai): nine class-imbalance inputs down to two The imbalance section offered nine controls for one job. Audited against the code, five of them did not do what their names said at the shipped defaults: AILogitPriorStrength DEAD - Inference.mqh's post-hoc prior early-returns whenever the adjusted loss is on, which is default. OversampleParity DEAD in training - Training.mqh gated the replay loop on !useLogitAdjustedLoss (correctly, citing Buda et al. 2018). Live only in the online-learning path. EnableMinorityReplay DEAD as replay. It survived ONLY as a focal-gamma damper - "replay minority bars through pass-2 oversampling" was a focal-loss switch. ConstrainReplay DEAD as a cap; it only chose damper 0.125 vs 0.25. UseStaticPrior An exact duplicate of FreezePriorCalibration - the two were OR'd together in the single place either is read. So they were not five mechanisms fighting; they were one mechanism plus eight knobs that mostly described machinery that no longer ran. That is worse than a real conflict, because the log agreed with the names: the label-cache line printed "reps up to 28x (90% parity) (seeding era 0's class-balance oversampling)" on every run, describing an oversampling pass that had been switched off. It is fixed here too - it cost this session a wrong diagnosis. The one genuine redundancy was focal loss, running at gamma*0.125 alongside the adjusted loss: two corrections on the same axis, the exact stacking failure this file already cited Buda et al. for in two other places, damped by a replay flag whose replay path was itself dead. Removed rather than re-tuned. The plateau ladder is unaffected - its escape is the learning-rate warm restart; the gamma anneal beside it only ever stepped toward zero. WHAT REMAINS is logit-adjusted loss (Menon et al. 2021) plus a prior freeze: LogitAdjustTau 0 = off; replaces the separate EnableLogitAdjusted- Loss boolean, since a strength dial where 0 already means off does not need an on/off switch beside it. FreezePriorCalibration unchanged. It is the only one of the six corrections with a consistency guarantee, and it is consistent for exactly the balanced-error metric checkpoint selection already ranks on - so the loss and the deploy decision optimize one thing. The online continual-learning path keeps its own alpha-balanced focal weight, now as constants pinned to the removed inputs' shipped defaults, so its behaviour is unchanged. It legitimately needs its own correction: ApplyLogitAdjustment() only runs inside a training run, so a deployed model that was reloaded carries no logit offsets and would otherwise stream 31:1 data into itself uncorrected. The weights-filename fingerprint is BYTE-IDENTICAL. The focal slot was a double fed to a %d conversion and had always emitted a literal 0; the |MR: segment is written as the constant its shipped defaults produced. Dropping either would have re-keyed every model and forced a from-scratch retrain of the one topology currently converged and trading. Also removed as orphans: FOCAL_GAMMA_PRESET, MAX_OVERSAMPLE_REPLICAS, OVERSAMPLE_PARITY_FRACTION, PLATEAU_GAMMA_STEP, and the now-unreachable "neutralized by prior correction" diagnostic. Both builds compile 0 errors, 0 warnings. No retrain forced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:46:57 -04:00
if(m_logitAdjustTau <= 0.0)
feat(ai): logit-adjusted loss, replacing oversampling and the post-hoc prior Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add tau*log(prior_c) to each class logit inside the training gradient. Softmax CE on adjusted logits is consistent for BALANCED error - the metric checkpoint selection already ranks on - so the loss and the deploy decision finally optimize the same thing. The engine already computed a true softmax + categorical-CE gradient and wrote it over the per-neuron sigmoid delta, so this is an offset added to three logits in the two places that gradient is built (backProp scalar path and backPropOCL). No backend, kernel or DLL change; the forward pass and every inference path are untouched, which is the point - the network learns to absorb the offset, so its raw argmax becomes the balanced-optimal decision with nothing applied at inference. Replaces rather than stacks. Minority replay is disabled while this is on, and the post-hoc inference prior is forced off. Stacking is not a theoretical worry: simulated on the measured 1118/1119/34298 distribution in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced, Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance - and BOTH together score 45.4% with Neutral recall at 0%, worse than either alone. Buda et al. 2018 predicts exactly that. Motivation from the six-chart run: every topology took one direction to ~50% recall and abandoned the other, the direction chosen arbitrarily (the batch-norm control went Buy 1% / Sell 42%, the inverse of the other five). One era in 1,301 cleared the per-class recall floor. Fingerprinted conditionally, so the converged 60.7% models on disk keep their filenames and stay loadable as the fallback. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:05:14 -04:00
{
//--- 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.
fix: the imbalance correction never ran during the auto-tune search Neutral collapse on all four topologies by era 5 with a 2:6 barrier (recall Buy 0% / Sell 0% / Neutral 100%), and the panel stuck on "measuring...". One root cause, and it was not the barrier. The labels were fine: Buy 25.4% / Sell 22.0% / Neutral 52.5%, which is exactly gambler's ruin for m=2,k=6 (2/8 = 25% per side), with only 0.1% of Neutral coming from the vertical barrier - so the new m*k horizon scaling is right, arguably generous. What was broken: Train()'s era-start block wrapped UpdateClassPriors() in `if(!m_evalMode)`. The auto-tune GA scores every candidate in eval mode, and AutoTuneIndicators ships ON, so on a default configuration EVERY era of the search ran with unmeasured priors. ApplyLogitAdjustment() requires measured priors; without them it calls ClearLogitAdjustment() and returns. So the entire search trained under PLAIN cross-entropy. With a 52.5% majority class the optimum of plain CE is "always predict Neutral", and that is precisely what all four models found. The panel followed: its counters only advance on bars the model CALLED Buy or Sell, so a collapsed model leaves them at zero and the line reads "measuring..." forever. This was latent, not new. It has been true for every auto-tuned run, but it was invisible while the labels were near-balanced - last night's accidental 1:1 barrier gave 43/40/17, where plain CE has no majority to collapse into. Widening the stop to 2*ATR (correctly - 1*ATR is too tight to survive noise) moved Neutral to the majority and exposed it. The guard's stated fear cannot happen. These priors are measured from the LABEL distribution, and the tuner only perturbs indicator periods (MA/RSI/MACD/Ichimoku/AD). The barrier label depends on ATR, SL_Mode and TP_Mode - none of which the search touches - so every candidate sees byte-identical labels and identical priors. There is nothing to contaminate. What the guard actually protected was the .stats write, and that is gated separately: eval candidates never checkpoint and never persist. Also, because this is the THIRD quiet no-op to cost a run in this codebase (after the fictional oversampling log line and the shadow-blend skip): - ApplyLogitAdjustment() now WARNS when it declines to install, instead of silently clearing. A mechanism that cannot announce it is not running is indistinguishable from one that is. - The panel distinguishes "measuring..." (before era 1, nothing scored yet - an honest warm-up) from "no directional calls yet" (eras trained, zero calls - a finding, not a wait). Both builds compile 0 errors / 0 warnings. No retrain forced by this commit itself, but the collapsed models must be discarded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 00:46:24 -04:00
//--- 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.
feat(ai): logit-adjusted loss, replacing oversampling and the post-hoc prior Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add tau*log(prior_c) to each class logit inside the training gradient. Softmax CE on adjusted logits is consistent for BALANCED error - the metric checkpoint selection already ranks on - so the loss and the deploy decision finally optimize the same thing. The engine already computed a true softmax + categorical-CE gradient and wrote it over the per-neuron sigmoid delta, so this is an offset added to three logits in the two places that gradient is built (backProp scalar path and backPropOCL). No backend, kernel or DLL change; the forward pass and every inference path are untouched, which is the point - the network learns to absorb the offset, so its raw argmax becomes the balanced-optimal decision with nothing applied at inference. Replaces rather than stacks. Minority replay is disabled while this is on, and the post-hoc inference prior is forced off. Stacking is not a theoretical worry: simulated on the measured 1118/1119/34298 distribution in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced, Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance - and BOTH together score 45.4% with Neutral recall at 0%, worse than either alone. Buda et al. 2018 predicts exactly that. Motivation from the six-chart run: every topology took one direction to ~50% recall and abandoned the other, the direction chosen arbitrarily (the batch-norm control went Buy 1% / Sell 42%, the inverse of the other five). One era in 1,301 cleared the per-class recall floor. Fingerprinted conditionally, so the converged 60.7% models on disk keep their filenames and stay loadable as the fallback. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:05:14 -04:00
if(m_priorBuy <= 0.0 || m_priorSell <= 0.0 || m_priorNeutral <= 0.0)
{
fix: the imbalance correction never ran during the auto-tune search Neutral collapse on all four topologies by era 5 with a 2:6 barrier (recall Buy 0% / Sell 0% / Neutral 100%), and the panel stuck on "measuring...". One root cause, and it was not the barrier. The labels were fine: Buy 25.4% / Sell 22.0% / Neutral 52.5%, which is exactly gambler's ruin for m=2,k=6 (2/8 = 25% per side), with only 0.1% of Neutral coming from the vertical barrier - so the new m*k horizon scaling is right, arguably generous. What was broken: Train()'s era-start block wrapped UpdateClassPriors() in `if(!m_evalMode)`. The auto-tune GA scores every candidate in eval mode, and AutoTuneIndicators ships ON, so on a default configuration EVERY era of the search ran with unmeasured priors. ApplyLogitAdjustment() requires measured priors; without them it calls ClearLogitAdjustment() and returns. So the entire search trained under PLAIN cross-entropy. With a 52.5% majority class the optimum of plain CE is "always predict Neutral", and that is precisely what all four models found. The panel followed: its counters only advance on bars the model CALLED Buy or Sell, so a collapsed model leaves them at zero and the line reads "measuring..." forever. This was latent, not new. It has been true for every auto-tuned run, but it was invisible while the labels were near-balanced - last night's accidental 1:1 barrier gave 43/40/17, where plain CE has no majority to collapse into. Widening the stop to 2*ATR (correctly - 1*ATR is too tight to survive noise) moved Neutral to the majority and exposed it. The guard's stated fear cannot happen. These priors are measured from the LABEL distribution, and the tuner only perturbs indicator periods (MA/RSI/MACD/Ichimoku/AD). The barrier label depends on ATR, SL_Mode and TP_Mode - none of which the search touches - so every candidate sees byte-identical labels and identical priors. There is nothing to contaminate. What the guard actually protected was the .stats write, and that is gated separately: eval candidates never checkpoint and never persist. Also, because this is the THIRD quiet no-op to cost a run in this codebase (after the fictional oversampling log line and the shadow-blend skip): - ApplyLogitAdjustment() now WARNS when it declines to install, instead of silently clearing. A mechanism that cannot announce it is not running is indistinguishable from one that is. - The panel distinguishes "measuring..." (before era 1, nothing scored yet - an honest warm-up) from "no directional calls yet" (eras trained, zero calls - a finding, not a wait). Both builds compile 0 errors / 0 warnings. No retrain forced by this commit itself, but the collapsed models must be discarded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 00:46:24 -04:00
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.");
}
feat(ai): logit-adjusted loss, replacing oversampling and the post-hoc prior Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add tau*log(prior_c) to each class logit inside the training gradient. Softmax CE on adjusted logits is consistent for BALANCED error - the metric checkpoint selection already ranks on - so the loss and the deploy decision finally optimize the same thing. The engine already computed a true softmax + categorical-CE gradient and wrote it over the per-neuron sigmoid delta, so this is an offset added to three logits in the two places that gradient is built (backProp scalar path and backPropOCL). No backend, kernel or DLL change; the forward pass and every inference path are untouched, which is the point - the network learns to absorb the offset, so its raw argmax becomes the balanced-optimal decision with nothing applied at inference. Replaces rather than stacks. Minority replay is disabled while this is on, and the post-hoc inference prior is forced off. Stacking is not a theoretical worry: simulated on the measured 1118/1119/34298 distribution in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced, Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance - and BOTH together score 45.4% with Neutral recall at 0%, worse than either alone. Buda et al. 2018 predicts exactly that. Motivation from the six-chart run: every topology took one direction to ~50% recall and abandoned the other, the direction chosen arbitrarily (the batch-norm control went Buy 1% / Sell 42%, the inverse of the other five). One era in 1,301 cleared the per-class recall floor. Fingerprinted conditionally, so the converged 60.7% models on disk keep their filenames and stay loadable as the fallback. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:05:14 -04:00
Net.ClearLogitAdjustment();
return;
}
fix(ai): cap logit-adjustment strength to the head's usable logit range tau=1.0 inverted the collapse instead of curing it. The head is SIGMOID, so each output is bounded to [0,1] and the widest logit gap the net can express between two classes is CLASS_LOGIT_SCALE * (1-0) = 6. The offsets are tau*log(prior_c), whose spread on this 30:1 imbalance is 3.42 - so tau=1.0 spent 57% of the ENTIRE expressible range on the prior correction. The network did the only thing available to it: saturate Buy/Sell outputs to 1.0 to overcome a -3.42 training handicap. The offsets are absent at inference, so that surplus made every bar directional. Measured across all five still-training charts: Neutral recall 0%, directional calls on ~100% of bars, win rate 5-7% against a ~6% base rate - no information whatsoever - while balanced accuracy read a flattering 58-64% because two of its three terms sat near 95%. OOS accuracy 6%. Menon et al. assume an unbounded logit head where a 3.42 shift is negligible against the reachable range. It is not negligible here, so the strength is now expressed RELATIVE to the range actually available: tau_eff = min(tau_cfg, LOGIT_ADJUST_MAX_RANGE_FRACTION * SCALE / spread) At 20% that gives tau 0.35 on this data. Deliberately a fraction rather than a tau ceiling: it stays correct if CLASS_LOGIT_SCALE changes, if the head becomes unbounded, or on any symbol whose imbalance differs. The input remains effective below the cap, so dialling it down needs no rebuild. Simulated at a signal strength where the task is genuinely learnable, the precision/recall frontier is monotone: tau 1.0 -> 49.6% call rate at 6.4% precision (base rate 6.1%, i.e. worthless); tau 0.35 -> 2.0% at 15.5%; tau 0.15 -> 0.2% at 33.3%. The capped value lands in the same regime the pre-logit-adjustment run occupied (1-6% of bars at 20-35% win rate). Also logs the measured priors, the spread, and whether the cap bound. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:20:07 -04:00
//--- 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);
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;
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) + "% | log-prior spread " +
DoubleToString(spread, 2) + " 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)"));
}
feat(ai): logit-adjusted loss, replacing oversampling and the post-hoc prior Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add tau*log(prior_c) to each class logit inside the training gradient. Softmax CE on adjusted logits is consistent for BALANCED error - the metric checkpoint selection already ranks on - so the loss and the deploy decision finally optimize the same thing. The engine already computed a true softmax + categorical-CE gradient and wrote it over the per-neuron sigmoid delta, so this is an offset added to three logits in the two places that gradient is built (backProp scalar path and backPropOCL). No backend, kernel or DLL change; the forward pass and every inference path are untouched, which is the point - the network learns to absorb the offset, so its raw argmax becomes the balanced-optimal decision with nothing applied at inference. Replaces rather than stacks. Minority replay is disabled while this is on, and the post-hoc inference prior is forced off. Stacking is not a theoretical worry: simulated on the measured 1118/1119/34298 distribution in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced, Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance - and BOTH together score 45.4% with Neutral recall at 0%, worse than either alone. Buda et al. 2018 predicts exactly that. Motivation from the six-chart run: every topology took one direction to ~50% recall and abandoned the other, the direction chosen arbitrarily (the batch-norm control went Buy 1% / Sell 42%, the inverse of the other five). One era in 1,301 cleared the per-class recall floor. Fingerprinted conditionally, so the converged 60.7% models on disk keep their filenames and stay loadable as the fallback. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:05:14 -04:00
//--- 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];
fix(ai): cap logit-adjustment strength to the head's usable logit range tau=1.0 inverted the collapse instead of curing it. The head is SIGMOID, so each output is bounded to [0,1] and the widest logit gap the net can express between two classes is CLASS_LOGIT_SCALE * (1-0) = 6. The offsets are tau*log(prior_c), whose spread on this 30:1 imbalance is 3.42 - so tau=1.0 spent 57% of the ENTIRE expressible range on the prior correction. The network did the only thing available to it: saturate Buy/Sell outputs to 1.0 to overcome a -3.42 training handicap. The offsets are absent at inference, so that surplus made every bar directional. Measured across all five still-training charts: Neutral recall 0%, directional calls on ~100% of bars, win rate 5-7% against a ~6% base rate - no information whatsoever - while balanced accuracy read a flattering 58-64% because two of its three terms sat near 95%. OOS accuracy 6%. Menon et al. assume an unbounded logit head where a 3.42 shift is negligible against the reachable range. It is not negligible here, so the strength is now expressed RELATIVE to the range actually available: tau_eff = min(tau_cfg, LOGIT_ADJUST_MAX_RANGE_FRACTION * SCALE / spread) At 20% that gives tau 0.35 on this data. Deliberately a fraction rather than a tau ceiling: it stays correct if CLASS_LOGIT_SCALE changes, if the head becomes unbounded, or on any symbol whose imbalance differs. The input remains effective below the cap, so dialling it down needs no rebuild. Simulated at a signal strength where the task is genuinely learnable, the precision/recall frontier is monotone: tau 1.0 -> 49.6% call rate at 6.4% precision (base rate 6.1%, i.e. worthless); tau 0.35 -> 2.0% at 15.5%; tau 0.15 -> 0.2% at 33.3%. The capped value lands in the same regime the pre-logit-adjustment run occupied (1-6% of bars at 20-35% win rate). Also logs the measured priors, the spread, and whether the cap bound. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:20:07 -04:00
offsets[0] = tauEff * lb;
offsets[1] = tauEff * ls;
offsets[2] = tauEff * lnn;
feat(ai): logit-adjusted loss, replacing oversampling and the post-hoc prior Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add tau*log(prior_c) to each class logit inside the training gradient. Softmax CE on adjusted logits is consistent for BALANCED error - the metric checkpoint selection already ranks on - so the loss and the deploy decision finally optimize the same thing. The engine already computed a true softmax + categorical-CE gradient and wrote it over the per-neuron sigmoid delta, so this is an offset added to three logits in the two places that gradient is built (backProp scalar path and backPropOCL). No backend, kernel or DLL change; the forward pass and every inference path are untouched, which is the point - the network learns to absorb the offset, so its raw argmax becomes the balanced-optimal decision with nothing applied at inference. Replaces rather than stacks. Minority replay is disabled while this is on, and the post-hoc inference prior is forced off. Stacking is not a theoretical worry: simulated on the measured 1118/1119/34298 distribution in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced, Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance - and BOTH together score 45.4% with Neutral recall at 0%, worse than either alone. Buda et al. 2018 predicts exactly that. Motivation from the six-chart run: every topology took one direction to ~50% recall and abandoned the other, the direction chosen arbitrarily (the batch-norm control went Buy 1% / Sell 42%, the inverse of the other five). One era in 1,301 cleared the per-class recall floor. Fingerprinted conditionally, so the converged 60.7% models on disk keep their filenames and stay loadable as the fallback. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:05:14 -04:00
Net.SetLogitAdjustment(offsets);
}
//+------------------------------------------------------------------+
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
//| 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