Warrior_EA/Expert/AIBase/Lifecycle.mqh
AnimateDread a970405042 feat(pool,mi): one feature layout fleet-wide, and the keep-screen stops self-disabling on a cold start
TWO CHANGES, BOTH RETRAIN-FORCING BY INTENT.

1. SP500 was training alone, and one alt-data column was the reason.

   The alt block's width joins the model fingerprint, and the pool reader only
   adopts peer rows whose fingerprint and width match. The exporter gives each
   instrument the series that apply to it - FX 15 columns, metals/oil 14, SP500
   13 - so the fleet ran as three incompatible pools:

     EURUSD/USDJPY/USDCAD  adopt ~57-60k peer rows each
     XAUUSD/XTIUSD         adopt 6.4k / 20.3k
     SP500                 "EVERY peer file was REJECTED, so this chart is
                            training alone" - 0 rows

   SP500 therefore trained on 2279 independent observations against a 600-wide
   input with its first layer floored at 16, printing its own "expect
   overfitting" warning. It is the one chart with no pool and the worst
   capacity ratio in the fleet by a factor of three.

   Fresh models now pin ALTDATA_FLEET_COLUMNS - the 12-column intersection -
   instead of their own file header. An existing model still adopts its .cfg
   pin, so this re-keys nothing that is already trained.

   Intersection rather than union: filling an absent series with its median
   makes that column constant per instrument, which lets a pooled model
   identify the source instrument and stop learning the shared mechanism. It
   is also 6 columns narrower. Cost is six columns whose retained information
   is UNMEASURED - the keep-screen reports a bitmask nothing has mapped back
   to names.

2. The MI keep-screen disabled itself for the whole run on any cold start.

   ReportFeatureLabelInformation set m_miReportDone on ENTRY. On a cold start
   the label cache is allocated before it is filled, so BuildMiSample finds no
   row carrying a resolved label and returns 0 - a sixth exit, and the only
   one the 8c1266d instrumentation did not cover, which is why it printed
   nothing. observed then stayed -1, the permutation loop never iterated, and
   the report emitted "-1.00000 nats over 0 permutations" beside a plausible
   "strongest single feature 0.05979" that was a STALE m_miBestColumn from an
   earlier scoring call. The first ensemble member propagated the latch to
   g_ensembleChartMiReportDone and silenced every member on the chart.

   The flag now latches only once a measurement exists. A short sample is
   reported as a deferral naming the two numbers that identify it (cached bars
   vs bars carrying a resolved label) and retried, up to
   MI_REPORT_MAX_ATTEMPTS.

Build tag -> fleet-pool-v1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:41:14 -04:00

1144 lines
57 KiB
MQL5

//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| 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: <signal> -> <value>" 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