//+------------------------------------------------------------------+ //+------------------------------------------------------------------+ //| Lifecycle.mqh | //| | //| Construction/destruction (the composition root - every | //| collaborator's Bind() call lives here), the CExpertSignal vote | //| API (LongCondition/ShortCondition/ConfidenceTier/pattern | //| weights), and tick + chart-event dispatch. The per-config chart | //| lock now lives on CConfigLock - see Expert\ConfigLock\ConfigLock.mqh. | //+------------------------------------------------------------------+ #ifndef WARRIOR_AIBASE_LIFECYCLE_MQH #define WARRIOR_AIBASE_LIFECYCLE_MQH //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ //--- These are only fallback defaults for a fresh object before the EA's OnInit() applies the //--- active input values via the public setters in Warrior_EA.mq5. The input-driven values are the //--- source of truth for the actual run configuration. CExpertSignalAIBase::CExpertSignalAIBase(void) : ID("NULL"), m_neuronsCount(0), m_minTrainYear(1970), m_optimizationAlgo(TrainingOptimizer), // see the member declaration comment //--- Placeholder only; InitNeuralNetwork() replaces it with ComputeFirstLayerWidth() before anything //--- reads it. Deliberately the floor rather than 0, so a hypothetical path that built a topology //--- without going through init would produce a small usable net instead of a zero-width layer. m_initialNeuronsCount(FIRST_LAYER_MIN_WIDTH), m_outputNeuronsCount(OUTPUT_CLASSIFICATION), //--- Frozen. Nothing reads these to build a topology any more - the taper derives its own //--- endpoints (BuildFreshTopology) - but they still occupy positional slots in the .cfg sidecar //--- and the weights fingerprint. m_minNeuronsCount(MIN_NEURONS_20), m_neuronsReduction(RF_70), m_hiddenLayersCount(3), m_lstmHiddenSize(32), m_convFilterCount(16), m_historyBars(14), m_fractalPeriods(5), m_pattern_0(25), m_pattern_1(50), m_pattern_2(75), m_pattern_3(100), m_ensembleMember(false), m_ensemblePanelSlot(-1), m_useVolumes(true), m_useTime(true), m_useATR(true), m_useMA(false), m_useSwingContext(false), m_useNews(false), m_useCrossAsset(false), m_useSpreadFeature(false), m_crossAssetPairsPinned(""), m_crossAssetCfgSaved(false), m_useAltData(false), m_altDataEnabled(true), m_altDataLateWarned(false), m_altDataNamesPinned(""), m_newsFeatureWindowMinutes(60), m_autoTuneIndicators(false), m_indicatorsPtr(NULL), Net(NULL), TempData(NULL), dError(-1), dUndefine(0), dForecast(0), dPrevSignal(0), m_refreshOk(0), m_refreshFailFeatures(0), m_refreshFailShort(0), m_refreshBuy(0), m_refreshSell(0), m_refreshNeutral(0), m_voteGateBlocked(0), m_voteGatePassed(0), m_voteGateCompleteAtFirst(-1), m_voteGateLoadedAtFirst(-1), m_signalClusterWindow(6), m_nmsLiveBuyTime(0), m_nmsLiveSellTime(0), m_nmsLiveBuyAccept(false), m_nmsLiveSellAccept(false), m_nmsLiveKeptTime(0), m_nmsLiveKeptDir(Neutral), m_nmsLiveKeptConf(0), dtStudied(0), m_netDirty(true), m_eraCount(0), m_trainingComplete(false), m_inferenceOnly(false), m_modelLoadedFromDisk(false), m_topologySuperseded(false), m_mqlInferenceValidated(false), m_freezePriorCalibration(false), bEventStudy(false), m_oosSplitPct(30), dOosError(-1), dOosForecast(0), m_oosSamples(0), m_cumIsCorrect(0), m_cumIsTotal(0), m_cumOosCorrect(0), m_cumOosTotal(0), m_oosOutSpreadSum(0), m_oosOutCount(0), m_oosNeutralStrict(0), m_oosNeutralTie(0), m_oosTieBuySell(0), m_oosRailBars(0), m_countBuySignals(0), m_countSellSignals(0), m_countNeutralSignals(0), m_trueBuyCount(0), m_trueSellCount(0), m_trueNeutralCount(0), m_logitAdjustLogged(false), m_logitAdjustSkipWarned(false), m_prevEraTrueBuyCount(0), m_prevEraTrueSellCount(0), m_prevEraTrueNeutralCount(0), m_confidenceCalScale(1.0), m_lastProgressLogTick(0), m_priorBuy(0.0), m_priorSell(0.0), m_priorNeutral(0.0), m_oosNmsFired(0), m_oosNmsHits(0), m_oosNmsLastBuyIdx(-1), m_oosNmsLastSellIdx(-1), m_oosNmsKeptIdx(-1), m_oosNmsKeptConf(0.0), m_oosNmsKeptDir(Neutral), m_lastBuyFiredPrecPct(-1), m_lastSellFiredPrecPct(-1), m_lastBuyFired(0), m_lastSellFired(0), m_swingConfirmationBars(100), m_maxErasPerRun(300), //--- The arrow-restore queue and the rescan queue/tally now default-construct on CChartUI itself //--- (m_chartUI, declared below) - see its own constructor. m_trainRunActive(false), m_eraResumePending(false), m_resumeBars(0), m_resumeTotalIter(0), m_resumeOosCutoff(0), m_resumeBarIndex(0), m_resumeAddLoop(false), m_isTrainQueueCount(0), m_isTrainCursor(0), m_isPass2Active(false), m_isPass2Done(false), m_isPass3Active(false), m_oosScoreIndex(0), m_oosScoreStartIndex(0), m_isCalibActive(false), m_isCalibDone(false), m_calibIndex(0), m_calibStartIndex(0), //--- The excursion head's own state (m_excNet and its accumulators) now default-constructs on //--- CExcursionHead (S4 - see its own constructor), same doctrine as CChartUI below. //--- m_lastStatusLabelUpdateTick and the four m_lastDisplay* fields now default-construct on //--- CChartUI (see its own constructor); m_lastBuyRecallPct/m_lastSellRecallPct stay here - they //--- are read by other subsystems too, not exclusive to the panel. m_lastBuyRecallPct(-1), m_lastSellRecallPct(-1), m_lastBarTime(0), m_modelEta(InitialEtaForOptimizer()), m_etaCeiling(InitialEtaForOptimizer()), m_erasSinceCooldown(0), m_bestOosForecast(-1), m_bestSelectionScore(-1), m_bestPassedRecall(false), m_bestBothSidesLive(false), m_eraStartTick(0), m_passFeatUs(0), m_passNetUs(0), m_passHeartbeatPrints(0), m_passWindowOk(0), m_passWindowFail(0), m_consecutiveRegressions(0), m_featureFailBlock(""), m_featureFailIdx(-1), m_windowFailSlot(-2), m_windowFailTotal(0), m_lastHeartbeatTick(0), m_passProgressPct(0), m_passLabel("starting"), m_lastEraCompleteTick(0), m_lastStallReportTick(0), m_lastEraWindowBars(-1), m_haveOosCheckpoint(false), m_bestDirPrecPct(-1.0), m_bestChancePrecPct(-1.0), m_bestDirCalls(0), m_deployCandidateEras(0), m_oosStable(false), m_objectiveMet(false), m_erasSinceBest(0), m_plateauStage(0), m_bestIsError(-1.0), m_erasSinceBestIsError(0), m_isErrorPlateaued(false), m_restartBoostErasLeft(0), m_syncWaitStartTick(0), m_warmupPassesRemaining(0), m_coldSweepTick(0), m_barrierEraSeen(-1), m_barrierEraTick(0), m_barrierExcluded(false), m_barrierPhaseProgress(false), m_barrierHoldReportTick(0), m_inferenceDepthRefusalWarned(false), m_prebuildBlockWarned(false), m_labelCacheBars(0), m_labelCacheAnchorTime(0), m_labelCachePrebuilt(false), //--- 0 samples => MeanLabelLifespan() returns 1.0 => EffectiveSampleSize() is the identity, so an //--- un-prebuilt model behaves exactly as it did before the overlap correction existed rather than //--- shrinking its own samples on a guess. See m_lastLabelLifespan. m_labelOverlap is a class //--- member and default-constructs itself (CLabelOverlap::CLabelOverlap() calls Reset()) - it has //--- no init-list form here, same as m_ladder and every other object member below. m_lastLabelLifespan(0), //--- -1 = the deploy gate has not run its arithmetic yet this era; the era line then omits the bar //--- rather than printing a stale one from a previous era. m_lastEdgeFloorPct(-1.0), m_lastPrecSE(-1.0), m_lastEffN(-1.0), m_lastPoolPasses(false), m_lastPoolReport(""), //--- 0.0 = unthresholded until the first pass 2 fits it (see DIR_CONF_THRESHOLD_BINS). Deliberately //--- the permissive value: a model that has not measured its own operating point must not silently //--- abstain on everything. m_dirConfThreshold(0.0), m_bestDirConfThreshold(0.0), m_dirConfPrimaryBars(0), m_dirConfSparseWarned(false), m_labelPrebuildActive(false), m_prebuildSeedPending(false), m_labelPrebuildBars(0), m_labelPrebuildOosCutoff(0), m_labelPrebuildIndex(-1), m_labelPrebuildBuyCount(0), m_labelPrebuildSellCount(0), m_labelPrebuildNeutralCount(0), //--- The shadow net, the OOS continual-learning simulation state, the pattern-database backfill //--- state, and the online-learning watermark/guardrail/counters now default-construct on //--- COnlineLearning (m_onlineLearning, declared below) - see its own constructor. m_tuneTrialIndex(-1), m_tuneBestOosForecast(-1), m_tuneLastTrialWasWin(true), m_tuneHaveBestCheckpoint(false), m_tuneStartTrainBar(0), m_tuneFilterDone(false), m_trainingPaused(false), m_trainingStopRequested(false), m_activeFileCommon(true), //--- m_configLockName now default-constructs on CConfigLock (m_configLock, declared below) - see //--- its own constructor. m_isInitialized(false), m_shutdownInProgress(false), //--- m_lastArrowsSaved and the purge-mismatch latch now default-construct on CChartUI. m_miBestColumn(0.0), m_miLabelEntropy(0.0), m_miStrideBars(0), m_miNullBlocks(0), m_miReportDone(false), m_tiersSelfRanked(false), m_overlaySnapBars(0), m_prospectiveSigSnap(-2.0), m_deployedRebuildStage(0), m_dispSignal(0.0), m_dispValid(false), m_dispStamp(0), m_dispEra(-1), m_lastEnsRefusalKey(0), m_miReportDeferrals(0), m_miReportAttempts(0) { //--- Claim this instance's study-event id - see STUDY_EVENT_ID_BASE (ExpertSignalAIBase.mqh) for why //--- these are per instance and offset above the Controls library's event codes. //--- BIND THE VIEW FIRST. Collaborators are handed TrainingData() and nothing else, so an unbound //--- adapter would answer every question with a safe default and a diagnostic would quietly report //--- nothing at all - which is worse than one that fails loudly. m_trainingData.Bind(GetPointer(this)); //--- ...and every collaborator gets the VIEW, never `this`. That is what stops a module from //--- quietly growing a second dependency on the signal the way the AIBase\*.mqh files all did. m_baselines.Bind(GetPointer(m_trainingData)); m_chartView.Bind(GetPointer(this)); m_chartUI.Bind(GetPointer(m_chartView)); m_persistenceView.Bind(GetPointer(this)); m_modelPersistence.Bind(GetPointer(m_persistenceView)); m_onlineLearningView.Bind(GetPointer(this)); m_onlineLearning.Bind(GetPointer(m_onlineLearningView)); m_topologyView.Bind(GetPointer(this)); m_topology.Bind(GetPointer(m_topologyView)); m_featuresView.Bind(GetPointer(this)); m_featureBuilder.Bind(GetPointer(m_featuresView)); m_configLockView.Bind(GetPointer(this)); m_configLock.Bind(GetPointer(m_configLockView)); m_studyEventId = (ushort)(STUDY_EVENT_ID_BASE + g_warriorStudyEventSeq++); m_studyArmedTick = 0; m_ensembleIndex = -1; // not an ensemble member until EnsembleMember(true) registers one (the flag itself is in the init list) //--- per-era stash the ensemble verdict reads (see EnsembleStashEraStats); -1/false = "no era yet" m_eraStatPrecPct = -1.0; m_eraStatChancePct = -1.0; m_certifiedPrecPct = -1.0; m_certifiedChancePct = -1.0; m_eraStatCalls = 0; m_eraStatTradeable = false; m_eraStatTwoSided = false; m_eraStatScore = 0.0; m_eraStatBlended = 0.0; m_eraStatThreshold = 0.0; m_checkpointEra = -1; //--- indicator tuning defaults live in CADIndicatorTuner's own constructor (Expert\ADIndicatorTuner.mqh), //--- which runs automatically for the m_indicatorTuner member above. } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CExpertSignalAIBase::~CExpertSignalAIBase(void) { //--- deliberately NOT calling PersistOnShutdown() here: OnDeinit() (Warrior_EA.mq5) already //--- calls it explicitly for every signal, one call stack frame shallower, BEFORE //--- Expert.Deinit() tears these objects down. if(CheckPointer(Net) != POINTER_INVALID) delete Net; if(CheckPointer(TempData) != POINTER_INVALID) delete TempData; //--- Excursion head teardown (m_excNet/m_excTgt/m_excOut) now happens in CExcursionHead's own //--- destructor (S4), same as CChartUI/CModelPersistence needing none here. The shadow net and the //--- OOS-simulation net teardown now happens in COnlineLearning's own destructor, same doctrine. //--- Unconditional now that the warm-reload "leave the arrows up" branch is gone (see //--- ShutdownChartCleanup). Cheap and idempotent: OnDeinit already purged, so this normally deletes //--- nothing - it exists for the teardown paths that never reach OnDeinit (a failed OnInit). PurgeChart(); //--- Last, and cheap by design (one global-variable delete): the claim must outlive every save above //--- it, or a chart re-attaching during this teardown could start writing the same files mid-save. ReleaseConfigLock(); } //+------------------------------------------------------------------+ //| Sets the file/id identity a subclass constructor would otherwise | //| repeat verbatim (ID, m_id, m_folderPath, m_fileName, pattern count)| //+------------------------------------------------------------------+ void CExpertSignalAIBase::SetIdentity(string id, string shortId, int patternCount = 4) { ID = id; m_id = shortId; m_folderPath = eaName + "\\" + "Neural Networks" + "\\" + "State" + "\\" + m_id + "\\"; m_fileName = m_folderPath + _Symbol + "_" + IntegerToString(_Period); m_pattern_count = patternCount; } //+------------------------------------------------------------------+ //| CONTROL-PANEL COMMAND. One switch, so a button can never reach | //| some models and miss others: the panel resolves the direction | //| once and every model in the tree is told the same thing. | //+------------------------------------------------------------------+ bool CExpertSignalAIBase::OnSignalCommand(const ENUM_SIGNAL_COMMAND cmd) { switch(cmd) { case SIGCMD_PAUSE_TRAINING: PauseTraining(); return true; case SIGCMD_RESUME_TRAINING: ResumeTraining(); return true; case SIGCMD_STOP_TRAINING: StopTraining(); return true; case SIGCMD_START_TRAINING: StartTraining(); return true; //--- These three report SUCCESS rather than "I understood the command", because the panel //--- counts them back to the operator and a failed deploy or a failed rebuild is exactly what //--- they need told about. case SIGCMD_DEPLOY: return DeployNow(); case SIGCMD_RESET_WEIGHTS: return ResetWeights(); case SIGCMD_SAVE_WEIGHTS: return SaveWeightsNow(); case SIGCMD_LOAD_WEIGHTS: return LoadWeightsNow(); case SIGCMD_RETRAIN_DEPLOYED: RetrainDeployed(); return true; //--- Queues a rescan; returns whether one was actually queued, which is what tells the EA to //--- defer the "arrows shown" alert until every queued scan has drained. case SIGCMD_RESCAN_SIGNALS: return StartChartSignalRescan(); case SIGCMD_REPORT_IDENTITY: Print(" " + RegistryLine()); return true; } return false; } //+------------------------------------------------------------------+ //| Panel button labels ask these; see ENUM_SIGNAL_TRAIT. | //+------------------------------------------------------------------+ bool CExpertSignalAIBase::HasSignalTrait(const ENUM_SIGNAL_TRAIT trait) { switch(trait) { case SIGTRAIT_TRAINABLE: return true; case SIGTRAIT_TRAINING_PAUSED: return m_trainingPaused; case SIGTRAIT_TRAINING_STOPPED: return m_trainingStopRequested; case SIGTRAIT_TRAINING_COMPLETE: return m_trainingComplete; //--- "still trainable AND has never checkpointed an era that cleared the per-class recall //--- floor" - the same bar the plateau ladder refuses to cross on its own. case SIGTRAIT_DEPLOY_SKIPS_RECALL: return (!m_trainingComplete && !m_bestPassedRecall); case SIGTRAIT_RESCAN_PENDING: return RescanPending(); } return false; } //+------------------------------------------------------------------+ //| "Voting" that price will grow. | //+------------------------------------------------------------------+ int CExpertSignalAIBase::LongCondition(void) { int result = 0; //--- Readiness gate: live trading still requires a converged model, but an inference-only tester //--- run may replay a model that was ACTUALLY loaded from disk even if its persisted //--- trainingComplete flag is false. NoteVoteGate(DoubleToSignal(dPrevSignal) == Buy); if(!m_trainingComplete && !(m_inferenceOnly && m_modelLoadedFromDisk)) return 0; //--- No alternation gate any more - see the removal note at m_voteGateBlocked's declaration. //--- Under triple-barrier labels consecutive same-direction setups are ordinary and correct. if(dPrevSignal == -2) return 0; //--- NO confidence floor here, by design - see m_minSignalConfidence's former declaration site. A //--- weak call is not blocked at the AI's own boundary; it votes at its tier weight (as low as //--- m_pattern_0) and is then filtered by Min vote to open, exactly like a weak classic vote. if(DoubleToSignal(dPrevSignal) == Buy) { int tier = ConfidenceTier(); result = PatternWeightForTier(tier); m_active_pattern = "Pattern_" + IntegerToString(tier); m_active_direction = "Buy"; } return(result); } //+------------------------------------------------------------------+ //| "Voting" that price will fall. | //+------------------------------------------------------------------+ int CExpertSignalAIBase::ShortCondition(void) { int result = 0; //--- Readiness gate - see LongCondition's matching comment. NoteVoteGate(DoubleToSignal(dPrevSignal) == Sell); if(!m_trainingComplete && !(m_inferenceOnly && m_modelLoadedFromDisk)) return 0; //--- "not yet studied" sentinel, and no confidence floor - see LongCondition's matching comments. if(dPrevSignal == -2) return 0; if(DoubleToSignal(dPrevSignal) == Sell) { int tier = ConfidenceTier(); result = PatternWeightForTier(tier); m_active_pattern = "Pattern_" + IntegerToString(tier); m_active_direction = "Sell"; } return result; } //+------------------------------------------------------------------+ //| Buckets a confidence magnitude into one of 4 equal bands between | //| the head's own structural floor and 1.0 - see m_pattern_0's | //| declaration comment for the resulting tier/weight table. Neither | //| head has a configurable floor: the boundary is 1/3 for the 3-class| //| softmax and 0.5 for regression (DoubleToSignal's own threshold), | //| both arithmetic properties of the head rather than settings. | //+------------------------------------------------------------------+ int CExpertSignalAIBase::ConfidenceTierFor(const double signal) { //--- The head's own STRUCTURAL decision boundary - the lowest confidence magnitude that head can //--- possibly report for a directional call - not a user setting: - 3-class classification: the //--- winning class of a 3-way softmax is arithmetically >= 1/3, since three probabilities //--- summing to 1 cannot all be below it. double floorConf = (m_outputNeuronsCount == 3) ? (1.0 / 3.0) : 0.5; double span = MathMax(1.0 - floorConf, 0.0001); //--- RAW magnitude, not the calibrated one (fixed 2026-08-16). double rawMag = MathAbs(signal); if(!MathIsValidNumber(rawMag)) return 0; double t = (MathMin(1.0, rawMag) - floorConf) / span; int tier = (int)MathFloor(t * 4.0); return MathMax(0, MathMin(tier, 3)); } //+------------------------------------------------------------------+ //| The live bar's tier - the only caller shape that existed before | //| ConfidenceTierFor() was split out, kept so LongCondition()/ | //| ShortCondition() read exactly as they did. | //+------------------------------------------------------------------+ int CExpertSignalAIBase::ConfidenceTier(void) { return ConfidenceTierFor(dPrevSignal); } //+------------------------------------------------------------------+ //| The signed vote this member would cast for a given decision, in | //| the units CExpertSignalCustom::Direction() sums - see the | //| declaration comment for why this is a shared function and not | //| two parallel expressions. | //+------------------------------------------------------------------+ double CExpertSignalAIBase::LiveVoteContribution(const double signal) { //--- A MEMBER THAT HAS NEVER RANKED ITSELF DOES NOT VOTE. Until RankTiersFromOos() runs once, //--- m_pattern_0..3 hold the constructor's stock 25/50/75/100 ladder - and since 4858507 the vote //--- currency is a WIN RATE, so an unranked tier-3 call enters the mean claiming a 100% win rate //--- against ranked members contributing ~25. That is not a strong opinion, it is the wrong unit: //--- one unranked member drags the whole ensemble over any threshold. It bites on every fresh //--- deploy AND every resume, because the tier weights are not persisted in the .nnw - they exist //--- only as the output of a completed pass 3. Found 2026-08-23 on USDJPY, whose measured ceiling //--- is ~19 and which was firing anyway. if(!m_tiersSelfRanked) return 0.0; ENUM_SIGNAL s = DoubleToSignal(signal); if(s != Buy && s != Sell) return 0.0; // abstention - live drops it from the sum AND the divisor double w = (double)PatternWeightForTier(ConfidenceTierFor(signal)); if(!MathIsValidNumber(w) || w <= 0.0) return 0.0; // a pattern ranked to weight 0 contributes nothing and is not a voter //--- THE CURRENCY IS EDGE OVER CHANCE, NOT AN ABSOLUTE WIN RATE (2026-08-26). //--- //--- A tier weight is a raw win rate, and a raw win rate means nothing without the chance rate it //--- is measured against. 30% is a strong call under a 14% base rate and a catastrophic one under //--- 50% - yet both entered the mean as "30". That is why the threshold had to be re-tuned every //--- time the LABEL changed (25 was permissive at ~70% win rates under the old direction label and //--- a near-unanimity rule at ~30% under the pivot-event one), and why one chart's 25% was never //--- the same statement as another's. //--- //--- Subtracting the member's own chance rate fixes both: a chance-level call contributes 0 on its //--- own, the units become percentage points of demonstrated edge, and the number is comparable //--- across charts, labels and regimes. The threshold no longer needs re-tuning when any of those //--- move - and since it is DERIVED rather than configured, the sweep re-picks the rung by itself. //--- //--- CLAMPED AT ZERO, deliberately. A below-chance tier is anti-informative, and treating it as an //--- inverted oracle (contributing negatively, i.e. voting the other way) would be acting on a //--- broken model's output rather than discarding it. Zero means "this call carries nothing"; the //--- member stays in the divisor because it DID look - only a member with no demonstrated skill at //--- all leaves the denominator, via VoteCapableWeight(). double chancePct = m_eraStatChancePct; if(chancePct < 0.0) return 0.0; // no reference rate yet: an unmeasurable edge is not a strong one double edge = w - chancePct; if(edge <= 0.0) return 0.0; double contribution = m_weight * edge; if(!MathIsValidNumber(contribution)) return 0.0; return (s == Buy) ? contribution : -contribution; } //+------------------------------------------------------------------+ //| Returns the given tier's current pattern weight (0-100) | //+------------------------------------------------------------------+ int CExpertSignalAIBase::PatternWeightForTier(int tier) { switch(tier) { case 0: return m_pattern_0; case 1: return m_pattern_1; case 2: return m_pattern_2; default: return m_pattern_3; } } //+------------------------------------------------------------------+ //| COPY THE PER-BAR CACHE INTO THE OVERLAY'S SNAPSHOT, and tell the | //| EA this member is ready to be swept. | //| | //| Display-side code reads the SNAPSHOT rather than m_arrowSignalCache| //| because the cache is wiped at every era start - a sweep reading it | //| directly would draw from a half-rebuilt array. See | //| project_chart_filtered_view: the display may forward the live net, | //| but must never read a live TRAINING cache. | //| | //| TWO CALLERS, AND THE SECOND ONE IS THE POINT. RankTiersFromOos | //| calls it at pass-3 completion, which covers a model that is still | //| training. A DEPLOYED model runs no further eras, so on a restart | //| its snapshot was empty and stayed empty forever - the sweep had | //| nothing to replay, drew nothing, and the vote arrows could never | //| come back (user report 2026-08-25, "still no signals drawn on | //| chart"). A completed chart rescan now publishes here too: the | //| rescan runs the DEPLOYED net forward over history, which is | //| exactly the same quantity pass 3 would have produced, obtained | //| without training. | //+------------------------------------------------------------------+ void CExpertSignalAIBase::PublishOverlaySnapshotFromCache(void) { int snapN = MathMin(ArraySize(m_arrowSignalCache), SIGNAL_RESCAN_LOOKBACK_BARS + 16); if(snapN > 0) { ArrayResize(m_overlaySigSnap, snapN); ArrayCopy(m_overlaySigSnap, m_arrowSignalCache, 0, 0, snapN); } m_overlaySnapBars = snapN; m_prospectiveSigSnap = -2.0; for(int pi = 1; pi <= 16 && pi < snapN; pi++) if(m_overlaySigSnap[pi] != -2.0 && MathIsValidNumber(m_overlaySigSnap[pi])) { m_prospectiveSigSnap = m_overlaySigSnap[pi]; break; } //--- ...and tell the EA THIS member's snapshot is fresh. The sweep waits for every enrolled //--- member's bit - see g_warriorOverlayReadyMask for why a rate limit was not enough. if(m_ensembleIndex >= 0 && m_ensembleIndex < ENS_MAX_MEMBERS) g_warriorOverlayReadyMask |= (((uint)1) << m_ensembleIndex); g_warriorOverlayArmRequest = true; } //+------------------------------------------------------------------+ //| THE REPLAY PASS: score the deployed net's own history, then rank. | //| | //| A converged model's tier ladder is produced by pass 3 and by | //| nothing else, so before this existed a deployed model that had | //| lost its ladder (a .stats predating WST7, or a wipe) could only | //| get one back by RETRAINING - hours of work to recompute numbers | //| that are a pure function of weights already on disk. | //| | //| This is the same measurement without the training. The rescan has | //| already run the DEPLOYED net over every bar in the window and left | //| its prior-corrected decision in m_arrowSignalCache; the label | //| prebuild has filled m_labelCacheBuy/Sell for the same bars. So the | //| ladder is one walk over two arrays that already agree on indexing. | //| | //| It feeds RankTiersFromOos() rather than reimplementing it. The | //| shrinkage, the chance reference and the module trust weight are | //| subtle enough that a second copy would drift from the first, and | //| a ladder measured by a slightly different rule is worse than no | //| ladder - it would be silently incomparable with every ladder any | //| training run ever produced. | //| | //| INDEXING: both arrays are series-indexed (0 = newest). Bar 0 is | //| skipped because it is still forming. | //+------------------------------------------------------------------+ //+------------------------------------------------------------------+ //| The resolved swing-pivot label at one bar, for the head's | //| combined-vote scoring during the overlay sweep. Inline label | //| resolution, NOT the prebuilt cache - identical reasoning to the | //| comment inside ScoreReplayFromCache below: the cache's window is | //| anchored at dtStudied and need not overlap the sweep's. | //+------------------------------------------------------------------+ bool CExpertSignalAIBase::ReplayTruthAt(const int idx, ENUM_SIGNAL &truth) { truth = SwingPivotDirectionLabel(idx); if(m_lastLabelLifespan <= 0) { truth = Neutral; return false; // pivot pair not committed: no label exists for this bar } return true; } void CExpertSignalAIBase::ScoreReplayFromCache(void) { int n = ArraySize(m_arrowSignalCache); if(n <= 1) { Print(ID + ": replay scoring skipped - the rescan left no per-bar signals to score."); return; } //--- Same counters pass 3 fills, cleared the same way. RankTiersFromOos reads ONLY these plus the //--- per-class totals below, which is what makes this substitution exact. ArrayInitialize(m_oosTierFired, 0); ArrayInitialize(m_oosTierHits, 0); m_oos.Reset(); int scored = 0, unresolved = 0; for(int i = 1; i < n; i++) { double sig = m_arrowSignalCache[i]; if(sig == -2.0 || !MathIsValidNumber(sig)) continue; // the net never scored this bar (window could not be built) //--- THE LABEL IS RESOLVED INLINE, NOT READ FROM THE PREBUILT CACHE. The cache's window is //--- anchored at dtStudied, which for a CONVERGED model is deliberately left at its watermark //--- (inference recency) - a few bars ago. Scoring against it produced "0 labelled bars" on //--- 24/24 models (2026-08-25 15:13 session) while the rescan sat on ~5000 scored predictions: //--- the two windows simply never overlapped. SwingPivotDirectionLabel is a pure function of //--- the ZigZag/Close/ATR buffers the rescan just refreshed over EXACTLY this window, and //--- m_lastLabelLifespan == 0 is its own unresolved flag - the same finality gate the cache //--- uses, applied directly. The cache itself is deliberately not touched: it belongs to the //--- incremental training path, whose window this is not. ENUM_SIGNAL truth = SwingPivotDirectionLabel(i); if(m_lastLabelLifespan <= 0) { unresolved++; continue; // pivot pair not committed: no label exists for this bar } ENUM_SIGNAL pred = DoubleToSignal(sig); scored++; //--- Per-class totals: RankTiersFromOos derives its zero-skill reference from these //--- (50% x the directional base rate), so they must be counted over the SAME population the //--- tiers were counted over, not over a wider one. switch(truth) { case Buy: m_oos.buyTotal++; break; case Sell: m_oos.sellTotal++; break; default: m_oos.neutralTotal++; break; } //--- FIRED = a directional call under the live decision rule, which is exactly the population //--- the tier weights are meant to describe. A Neutral is an abstention, neither right nor wrong. if(pred != Buy && pred != Sell) continue; int tier = ConfidenceTierFor(sig); if(tier < 0 || tier > 3) continue; m_oosTierFired[tier]++; if(pred == truth) m_oosTierHits[tier]++; } int fired = 0, hits = 0; for(int t = 0; t < 4; t++) { fired += m_oosTierFired[t]; hits += m_oosTierHits[t]; } Print(ID + StringFormat(": replay scored %d labelled bar(s) against the deployed weights" " (%d unresolved bars excluded) - %d directional call(s), %d correct" " (%.1f%%). No training was run.", scored, unresolved, fired, hits, (fired > 0 ? 100.0 * hits / fired : 0.0))); //--- TWO DISTINCT EMPTY OUTCOMES, and naming the wrong one cost a session: "no labels overlapped" //--- is a windowing/data fault to be fixed, while "labels present, every call Neutral" is a //--- calibration verdict to be respected. The first version of this reported the second for both. if(scored <= 0) { Print(ID + ": WARNING - the replay found NO RESOLVED LABELS in the rescan window, so nothing" " could be scored. That is a data/windowing fault (ZigZag pivots missing over the whole" " window?), not a verdict on the model. No ladder was ranked."); return; } if(fired <= 0) { Print(ID + ": WARNING - the replay produced NO directional calls on " + IntegerToString(scored) + " labelled bar(s), so no ladder can be ranked. This model calls Neutral everywhere; that" " is a calibration outcome, not a drawing or persistence fault, and it will stay silent" " until retrained."); return; } //--- ...and rank exactly as an era end would, including publishing the overlay snapshot and //--- arming the sweep, which is the whole reason this returns nothing. RankTiersFromOos(); } //+------------------------------------------------------------------+ //| TURN THIS ERA'S HELD-OUT OUTCOMES INTO THE VOTE WEIGHTS. | //+------------------------------------------------------------------+ void CExpertSignalAIBase::RankTiersFromOos(void) { //--- SNAPSHOT FIRST, unconditionally - this runs at pass-3 completion, the single moment the //--- arrow cache is complete for the era. PublishOverlaySnapshotFromCache(); int pooledFired = 0, pooledHits = 0; for(int t = 0; t < 4; t++) { pooledFired += m_oosTierFired[t]; pooledHits += m_oosTierHits[t]; } //--- Nothing fired this era (all-Neutral, or a stopped/cap-hit era): leave the previous era's //--- weights standing rather than collapsing every tier to a prior built on no evidence at all. if(pooledFired <= 0) return; double pooledPct = 100.0 * pooledHits / pooledFired; //--- WHAT A MEMBER WITH NO EVIDENCE IS WORTH: the coin-flip rate on this era's OOS bars, which is //--- what the gate calls zero skill. Shrinking toward it means "few fires -> speaks at chance", //--- where shrinking toward the member's own pooled rate would mean "few fires -> speaks at //--- whatever those few fires said", which is no shrinkage at all. int zsBars = m_oos.Bars(); double chancePct = (zsBars > 0) ? 50.0 * ((double)m_oos.buyTotal + (double)m_oos.sellTotal) / zsBars : pooledPct; double pooledEffN = MathMax(0.0, EffectiveSampleSize((double)pooledFired)); double trustPct = ShrunkRatePct(pooledEffN * ((double)pooledHits / pooledFired), pooledEffN, chancePct, MODULE_PRIOR_EFF_N); //--- WHY NOT WinRateFromCounts(), which is the estimator the classic ladders use: it returns //--- NO_DATA_WIN_RATE for anything under MIN_TRADES_FOR_WIN_RATE raw trades, BEFORE it shrinks. string line = ""; for(int t = 0; t < 4; t++) { int fired = m_oosTierFired[t]; int hits = m_oosTierHits[t]; double w = trustPct; if(fired > 0) { double effN = MathMax(0.0, EffectiveSampleSize((double)fired)); double effHits = effN * ((double)hits / fired); //--- Same estimator the classic ladders use (ShrunkRatePct), with the prior deliberately //--- far smaller: it is counted in the same EFFECTIVE units as the evidence, and a tier //--- holds ~8-15 of those, so the classic path's 100 would drown every tier in the pool. //--- Toward the SHRUNK pooled rate, not the raw one: a member whose pooled evidence is //--- thin must not hand its tiers a confident prior it does not have itself. w = ShrunkRatePct(effHits, effN, trustPct, TIER_PRIOR_EFF_N); } //--- Rounded to the nearest INTEGER, not to the nearest 10 as NormalizeWinRate() does. After //--- shrinkage the tiers legitimately sit within a few points of each other, and decade rounding //--- would collapse them back into one number - undoing the separation this exists to produce. int wi = (int)MathMax(0, MathMin(100, MathRound(w))); ApplyTierWeight(t, wi); line += StringFormat(" T%d=%d(%d fires, %.1f eff)", t, wi, fired, (fired > 0 ? EffectiveSampleSize((double)fired) : 0.0)); } //--- MODULE WEIGHT = how much this model's opinion COUNTS in the weighted mean, which since the //--- 2026-08-18 currency change is a trust weight and no longer a discount on the estimate. The //--- pooled holdout win rate is the honest measure of that trust. Weight(MathMax(0.0, MathMin(1.0, trustPct / 100.0))); m_tiersSelfRanked = true; //--- ...AND WHETHER IT MAY VOTE AT ALL, from the same two numbers. The ladder says how much a //--- member's opinion counts; this says whether it is admitted to the divisor (HasDemonstratedEdge //--- -> VoteCapableWeight/ReconstructionWeight). Recorded here rather than at the era end because //--- the DEPLOYED REPLAY path arrives here too, and that path is the only measurement a converged //--- model will ever make. m_certifiedPrecPct = pooledPct; m_certifiedChancePct = chancePct; //--- THROTTLED (2026-08-19): the re-rank happens (and must happen) every era, but saying so //--- every era was ~950 near-identical lines/member/day once the system was confirmed working. //--- The weights it prints are visible live on the member HUD lines anyway. if(TrainLogDue()) Print(ID + StringFormat(": tier weights re-ranked from %d held-out fires (%.1f effective," " pooled %.1f%% raw -> %.1f%% shrunk toward the %.1f%% coin-flip rate on" " %.0f prior-equivalent calls) ->%s | module weight %.2f. These are the" " weights the NEXT era votes with.", pooledFired, pooledEffN, pooledPct, trustPct, chancePct, MODULE_PRIOR_EFF_N, line, ModuleWeight())); } //+------------------------------------------------------------------+ //| Set the specified pattern's weight to the specified value | //+------------------------------------------------------------------+ void CExpertSignalAIBase::ApplyPatternWeight(int patternNumber, int weight) { //--- THE SIGNAL DB DOES NOT OUTRANK THE HOLDOUT. UpdateSignalsWeights() calls this hourly for //--- every filter it can find rows for, and for an AI filter those rows are LIVE-journaled fires //--- accumulated across eras - i.e. across models. Without this the ranking pass would silently //--- undo every era's self-ranking within the hour. if(m_tiersSelfRanked) return; switch(patternNumber) { case 0: Pattern_0(weight); break; case 1: Pattern_1(weight); break; case 2: Pattern_2(weight); break; case 3: Pattern_3(weight); break; default: break; } } //+------------------------------------------------------------------+ //| OnTick function | //+------------------------------------------------------------------+ void CExpertSignalAIBase::OnTickHandler(void) { ScheduleTrainingIfNeeded(); } //+------------------------------------------------------------------+ //| Schedules the next training pass (if one is due) and refreshes | //| the per-tick status label. Factored out of OnTickHandler() so | //| Warrior_EA.mq5's always-on timer (see PollTraining()) can drive | //| this on a fixed wall-clock schedule too - training must not stall | //| just because the market is closed and no ticks are arriving. | //+------------------------------------------------------------------+ void CExpertSignalAIBase::ScheduleTrainingIfNeeded(void) { //--- stopped: no new training passes get scheduled at all (StartTraining() re-arms this). //--- paused: still schedule so bEventStudy/dtStudied bookkeeping stays current, but Train() itself //--- blocks at the next era boundary until resumed - keeps in-memory state coherent either way. //--- complete: training already converged - a plain new bar must NOT re-enter Train()'s full era //--- loop, which would otherwise reset the best-checkpoint/g_eta-decay tracking and run real //--- Net.backProp() passes again, forever, once per bar, on an already-converged model (see //--- RefreshConvergedSignal()'s declaration comment). Just keep the live signal current instead. //--- publish this signal's current signed confidence - see g_LiveAISignedConfidence in //--- Variables\ConfidenceBridge.mqh. Cheap: SignedAIConfidence() just reads the already-computed //--- dPrevSignal. //--- TELEMETRY SINCE 2026-08-25. The last consumer that could act on this - the confidence-adaptive //--- trailing stop - was removed with the rest of the confidence-scaled trade management, so what //--- this feeds now is the per-trade journal columns and the confidence-vs-outcome buckets in //--- Database\TradeJournalReport.mqh. It is kept running rather than deleted precisely because //--- those buckets are the only way the question "is this number worth anything?" ever gets an //--- answer, and a trade cannot be scored against a conviction nobody recorded. //--- ENSEMBLE: the AVERAGE across members, not this member's own reading. Every member ran this line //--- unconditionally, every tick, so the global was simply whichever member's OnTick happened to run //--- last. So on a four-model chart an LSTM entry could have its stop moved on the Perceptron's //--- opinion alone, purely by scheduling order. (User-identified 2026-08-17.) That bug is now //--- unreachable twice over - the aggregate is a mean, AND nothing acts on it - but the shape stays //--- correct so that a future consumer inherits a defined number rather than a race. //--- Averaging matches how the ensemble actually trades: the open decision is the weighted-average //--- vote, and a member that abstains contributes 0 and dilutes, exactly as it does there. Members //--- still training read 0 from SignedAIConfidence(), so a half-trained ensemble reads WEAKER rather //--- than louder, which is the safe direction for anything that could ever close a position. //--- PUBLISH ONLY THIS SIGNAL'S OWN VOTE. Combining is the orchestrator's job, never a member's - see //--- the vote board in Variables\ConfidenceBridge.mqh for the scheduling bug this replaces and for why //--- "have a member average its siblings" was the wrong shape of fix in a codebase whose whole point is //--- that signals do not reach into each other. A solo AI signal owns slot 0 and the aggregate is then //--- just its own value, so the non-ensemble path is unchanged. PublishAIVote(m_ensembleMember ? m_ensembleIndex : 0, SignedAIConfidence()); //--- ...and this member's own training state, on the same slot and the same cadence. Published //--- HERE, beside the vote, so the number and the word describing it can never come from different //--- moments - see WarriorChartModelsDeployed() in ExpertSignalCustom.mqh for the contradiction //--- that produced. PublishModelConverged(m_ensembleMember ? m_ensembleIndex : 0, m_trainingComplete); datetime lastBarDate = (datetime)SeriesInfoInteger(m_symbol.Name(), m_period, SERIES_LASTBAR_DATE); //--- A failed lookup (0) must not silently read as "dtStudied is already caught up, nothing //--- pending" - that would freeze this function into never re-triggering training/signal refresh //--- again until some other path happens to bump dtStudied. bool newBarPending = (dPrevSignal == -2 || lastBarDate <= 0 || ((m_inferenceOnly ? m_lastBarTime : dtStudied) < lastBarDate)); //--- A MODEL THAT HAS NOT FINISHED TRAINING IS ALWAYS PENDING. Gating them on the watermark //--- meant training could only advance when a new BAR closed. On H1 that is one 120ms chunk per //--- hour. A POST-TRAINING WALK IS ALSO PENDING. bool postTrainWalkPending = (m_onlineLearning.SimRunActive() || m_onlineLearning.BackfillActive()); bool trainingPending = !(m_trainingComplete || m_inferenceOnly) || postTrainWalkPending; //--- m_inferenceOnly (single backtest) takes the converged/inference branch even if the seeded model //--- wasn't flagged complete, so a backtest never drops into Train()'s era loop - see m_inferenceOnly. if((m_trainingComplete || m_inferenceOnly) && !m_trainingStopRequested && !m_trainRunActive && !postTrainWalkPending) { if(newBarPending) RefreshConvergedSignal(); } else { //--- Lost-event watchdog: an armed event that never arrived (chart-event queue overflow) would //--- otherwise leave bEventStudy true forever and silently stall training - the accidental rescue //--- (any sibling's event clearing this flag) went away with the per-instance ids. See //--- STUDY_EVENT_LOST_MS for why a false trip is not a realistic concern. if(bEventStudy && GetTickCount() - m_studyArmedTick > STUDY_EVENT_LOST_MS) { Print(ID + ": study event armed " + IntegerToString(STUDY_EVENT_LOST_MS / 1000) + "s ago never arrived (chart-event queue overflow?) - re-arming."); bEventStudy = false; } if(!m_trainingStopRequested && !bEventStudy && (newBarPending || trainingPending)) ArmStudyEvent((long)MathMax(0, MathMin(iTime(m_symbol.Name(), PERIOD_CURRENT, (int)(100 * Net.recentAverageSmoothingFactor * (m_trainingComplete ? 1 : 10))), dtStudied)), "New Bar"); } //--- Train() (see its declaration comment) now yields every ~TRAIN_TIME_BUDGET_MS instead of //--- blocking for a whole era, so while a run is active this per-tick line would otherwise //--- overwrite Train()'s own full-detail status label on every single tick between chunks - //--- flickering between the two instead of showing one steady picture. if(m_ensembleMember && EnsembleEraBarrierHolds()) return; if(!m_trainRunActive) { //--- Compact, accurate end-state text. bool onlineActive = m_onlineLearning.Enabled() && !m_inferenceOnly && !MQLInfoInteger(MQL_TESTER) && !MQLInfoInteger(MQL_OPTIMIZATION) && !MQLInfoInteger(MQL_FORWARD) && CheckPointer(Net) != POINTER_INVALID && !Net.CpuInference(); //--- Simple end-state panel (default, VerboseMode off): plain-language status + the model's //--- compounded/persistent Buy/Sell win-rate (directional accuracy, Neutral excluded - persisted in //--- .stats WST5, so it survives a fresh chart reload and is meaningful the moment a drop-and-go user //--- attaches the EA) + the current call. The verbose era/forecast dump below stays for power users. if(!VerboseMode) { string statusPlain; if(m_trainingComplete) statusPlain = onlineActive ? "Live - learning from new bars" : "Ready for live trading"; else if(m_trainingStopRequested) statusPlain = "Paused - progress saved"; else if(m_trainingPaused) statusPlain = "Paused"; else statusPlain = "Getting ready..."; ENUM_SIGNAL liveSig = DoubleToSignal(dPrevSignal); string liveSigPlain = (liveSig == Buy) ? "Buy" : (liveSig == Sell) ? "Sell" : "Neutral (no trade)"; string simpleLive; //--- The ensemble headline is the FIRST line, so lead with the signal - on the combined //--- panel each member's one line must answer "what is this model saying right now". Branched //--- FIRST, not built-then-overwritten: ComputeCompoundedAccuracyLine() is a real string build //--- (OosTally lookups, formatting) whose result the ensemble branch used to discard outright. if(m_ensembleMember) { simpleLive = statusPlain + " -> " + liveSigPlain; //--- one-line accuracy on the member headline (user request 2026-08-16: the solo panel //--- shows accuracy, the ensemble panel did not). if(m_cumOosTotal > 0) simpleLive += StringFormat(" | precision %d%%", (int)MathRound(m_cumOosCorrect * 100.0 / m_cumOosTotal)); } else { simpleLive = DisplayName() + " - " + statusPlain + "\n"; //--- Only show the accuracy line once at least one signal has been validated (compounded //--- counts persist across restarts, so a deployed model shows real numbers immediately, //--- not "measuring"). if(m_cumIsTotal > 0 || m_cumOosTotal > 0) simpleLive += ComputeCompoundedAccuracyLine() + "\n"; } PublishStatus(simpleLive); return; } string completeText = onlineActive ? "Complete - live (adapting to new bars)" : "Complete - ready for live (inference)"; string trainingState = m_trainingStopRequested ? (m_trainingComplete ? completeText : "Stopped - resumable (weights kept)") : (m_trainingPaused ? "Paused" : (m_trainingComplete ? completeText : "In progress")); //--- same "Forecast: -> " line the active training loop's status label ends on //--- (see the classLine-terminated StringFormat below), instead of a raw bEventStudy/dPrevSignal/ //--- dtStudied debug dump - this is what stays on screen once training stops/pauses/completes. PublishStatus(StringFormat( ID + " : Era %d -> Training %s\n" + "Forecast: %s -> %.2f", m_eraCount, trainingState, EnumToString(DoubleToSignal(dPrevSignal)), dPrevSignal)); } } //+------------------------------------------------------------------+ //| Timer-driven equivalent of OnTickHandler()'s scheduling, called | //| from Warrior_EA.mq5's always-on OnTimer() so training keeps | //| progressing purely on wall-clock time - no dependency on ticks, | //| which simply don't arrive while the market is closed. | //+------------------------------------------------------------------+ void CExpertSignalAIBase::PollTraining(void) { //--- Drain a slice of the queued chart-arrow restore FIRST, and skip training work on any tick //--- where restoring is still in flight. //--- STOP BEFORE ANY OF IT. Drawing arrows for a chart that is unloading is the worst case of the //--- three: it lengthens the very sweep that has to finish. if(ShutdownRequested()) return; if(m_chartUI.ArrowRestorePending()) { AdvanceChartSignalRestore(); return; } //--- Same one-thread reasoning as the arrow restore above: a manual rescan (Show Signals) also competes //--- for the single MQL5 thread, and its per-bar feedForward is real compute rather than a cheap object //--- write, so it must finish its own slices before training resumes rather than interleaving with it. if(m_chartUI.RescanPending()) { AdvanceChartSignalRescan(); return; } if(m_isInitialized && AdvanceDeployedRebuild()) return; if(m_isInitialized) ScheduleTrainingIfNeeded(); } //+------------------------------------------------------------------+ //| A DEPLOYED MODEL REBUILDS ITS OWN VOTE, WITHOUT RETRAINING. | //| | //| Everything a converged model needs in order to vote - the tier | //| ladder, the module trust weight, the overlay snapshot the arrows | //| are drawn from - is produced by a completed pass 3 and by nothing | //| else. A converged model runs no passes. So a model that lost those | //| (a .stats predating WST7, a wipe, a fresh deploy from a build that | //| never stored them) was permanently mute: no vote, no trades, no | //| arrows, and a win rate stuck on "measuring...". | //| | //| It never needed a retrain. Every one of those numbers is a pure | //| function of weights already on disk plus labels derivable from the | //| chart, so this replays them: run the deployed net over history | //| (the existing chunked rescan), then score each prediction against | //| the swing label RESOLVED INLINE for the same bar and rank the | //| ladder from the result (ScoreReplayFromCache, via the rescan's | //| completion hook). | //| | //| There is deliberately NO label-prebuild stage any more. The first | //| version had one, and it is exactly why that version scored zero | //| bars on 24/24 models: the prebuild's window is anchored at | //| dtStudied, which a converged model keeps at its recency watermark | //| - so the "label cache" covered a handful of just-closed bars whose | //| pivots cannot have committed yet, while the rescan sat on five | //| thousand scored predictions it could never be matched against. | //| The label is a pure function of buffers the rescan itself | //| refreshes; going through a cache built for a different window was | //| indirection that changed the answer. | //+------------------------------------------------------------------+ bool CExpertSignalAIBase::AdvanceDeployedRebuild(void) { if(m_deployedRebuildStage >= 2) return false; //--- WHO NEEDS THIS: a converged model that cannot currently vote (no ladder) or cannot currently //--- be drawn (no snapshot). Never in the tester, never for an inference-only run, and never while //--- a training run is live - a training pass produces all of this itself, correctly, and racing it //--- would have two writers on the same counters. if(m_deployedRebuildStage == 0) { if(!m_trainingComplete || m_inferenceOnly || m_trainRunActive || m_trainingPaused || MQLInfoInteger(MQL_TESTER) || MQLInfoInteger(MQL_OPTIMIZATION) || MQLInfoInteger(MQL_FORWARD)) return false; if(m_tiersSelfRanked && m_overlaySnapBars > 0) { m_deployedRebuildStage = 2; // nothing missing - never look again return false; } Print(ID + ": deployed but " + (!m_tiersSelfRanked ? "WITHOUT A TIER LADDER (so it cannot vote)" : "without a historical vote snapshot (so it cannot draw arrows)") + " - replaying history against the deployed weights to rebuild it. No training will run;" " these numbers are a function of the weights already on disk."); m_deployedRebuildStage = 1; } //--- Run the deployed net over the window. StartChartSignalRescan queues; the drain is handled by //--- the RescanPending() branch above this in PollTraining, which is why returning true here is //--- correct - the next slices go there, and completion arrives via OnChartRescanComplete. if(m_deployedRebuildStage == 1 && !m_chartUI.RescanPending()) { if(!StartChartSignalRescan()) { Print(ID + ": WARNING - could not start the replay rescan (no servable history, or the" " model is not ready to infer). This member stays silent; it will rebuild on the" " next attach or the next training pass."); m_deployedRebuildStage = 2; return false; } } return true; } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CExpertSignalAIBase::OnChartEventHandler(const int id, const long &lparam, const double &dparam, const string &sparam) { //--- Match THIS instance's id only. See STUDY_EVENT_ID_BASE (ExpertSignalAIBase.mqh) for the //--- full failure shape. if(id == CHARTEVENT_CUSTOM + m_studyEventId) { //--- The study event IS the training driver, so a queued one landing after the stop request would //--- open a whole era inside the teardown window. Checked here as well as in Warrior_EA.mq5's //--- OnChartEvent because CExpertCustom re-posts these between members. if(ShutdownRequested()) return; TuneIndicatorsAndTrain(lparam); bEventStudy = false; OnTickHandler(); } } //--- AcquireConfigLock/ReleaseConfigLock bodies now live on CConfigLock (m_configLock) - see //--- Expert\ConfigLock\ConfigLock.mqh's class comment, including g_initFatalReason's declaration. #endif