Warrior_EA/Expert/AIBase/Lifecycle.mqh
AnimateDread f64e0f8b67 feat(ensemble): per-NN inputs replace the preset selector - the meta head becomes the vote's gate
User design (2026-08-19): 'remove the enum menu that selects neural networks... individual
inputs for every NN just like classic signals... the META NN should be integrated into the
voting decision pipeline when enabled... as a bonus meta labelling is applied to enabled NNs.'

- AI_CHOICE is GONE (tombstoned per the stale-.set doctrine). Use_MLP/Use_CONV/Use_LSTM/
  Use_CONVLSTM are ordinary bools like the classic votes; the ensemble arithmetic adapts to
  any subset because the consensus divisor is the enabled capable weight. Two or more
  enabled = ensemble (|ENS1 token + joint gate, exactly the old AI_HYBRID fingerprints, so
  existing weight files keep loading); one = the old solo preset; none = classic-only.
- Use_MetaLabeling un-couples META from the direction NNs (the old selector made them
  mutually exclusive). S3 ships: CSignalMETA::LiveMetaGate scores each vote-cleared entry
  (shared window at bar 1 + proposal descriptor: side, net vote, live geometry, spread/ATR;
  pattern one-hot ZEROED - ranking, not calibrated probability, documented in the body) and
  vetoes below the cost-adjusted break-even. Entries only; fail-open everywhere, loudly.
- COEXISTENCE HAZARDS closed: VoteCapableWeight()=0 and ProspectiveVote()=false for the
  meta target - solo-only until today, a trained META would otherwise sit in the consensus
  divisor as a permanent abstainer and shrink every vote by its module weight.
- CERTIFIED == TRADED: the ensemble era verdict replays the identical veto through the same
  g_warriorMetaGate pointer over its OOS fired bars (bar re-resolved from the row's own
  time; fail-open counted as fires and reported: 'metaGate: N approved, M vetoed, K
  unscored'). The overlay deliberately does NOT replay it (veto-filter-in-replay class,
  calendar-cliff precedent) - documented at the sweep site. Solo charts' own gate does not
  model the veto - the standing solo-gate caveat, documented at the input.
- DB continuity: the pattern/journal DB fingerprint's first slot was (int)AIType;
  DbLegacyAiSlot() maps every legacy-expressible config to its OLD value (new 2-3 member
  subsets get 100+bitmask, outside the legacy range) so no existing database re-keys.
  filterID becomes the enabled roster via one EnabledNNSummary().
- HUD: the meta line shows the gate (armed/(trn), last P vs BE, ok/veto tally); the
  armed/disarmed announcement fires on state change via one latch (MetaGateArmedNow), not
  only when an entry happens to be proposed.

NOT COMPILED - user compiles in MetaEditor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:01:02 -04:00

1144 lines
57 KiB
MQL5

//+------------------------------------------------------------------+
//| Lifecycle.mqh |
//| |
//| Construction/destruction, the CExpertSignal vote API |
//| (LongCondition/ShortCondition/ConfidenceTier/pattern weights), |
//| tick + chart-event dispatch, and the per-config chart lock. |
//| |
//| PARTIAL IMPLEMENTATION FILE - not standalone. |
//| CExpertSignalAIBase method BODIES only. The class declaration |
//| lives in Expert\ExpertSignalAIBase.mqh, which includes this file |
//| at the bottom, after the declaration. Do not include it |
//| anywhere else and do not compile it on its own. |
//+------------------------------------------------------------------+
#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. Held at their historical defaults so both stay byte-stable; changing either value
//--- would re-key every model on disk for no behavioural reason whatsoever.
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_trainTarget(0),
m_ensembleMember(false),
m_ensemblePanelSlot(-1),
m_fracLegCount(0),
m_metaCandCount(0),
m_useVolumes(true),
m_useTime(true),
m_useATR(true),
m_useMA(false),
m_useRSI(false),
m_useMACD(false),
m_useIchimoku(false),
m_useSwingContext(false),
m_useNews(false),
m_useCrossAsset(false),
m_useSpreadFeature(false),
m_spreadSeriesBars(0),
m_spreadSeriesAnchor(0),
m_crossAssetAnchor(0),
m_crossAssetPairsPinned(""),
m_crossAssetCfgSaved(false),
m_useAltData(false),
m_altDataEnabled(true),
m_altDataLateWarned(false),
m_altDataNamesPinned(""),
m_newsFeatureWindowMinutes(60),
m_useADCumulativeDelta(false),
m_useADShorteningOfThrust(false),
m_useADWyckoffEventStream(false),
m_useADWyckoffFailedStructure(false),
m_useADWyckoffSignificantBarInversion(false),
m_autoTuneIndicators(false),
m_indicatorsPtr(NULL),
Net(NULL),
m_shadowNet(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_eraCount(0),
m_trainingComplete(false),
m_inferenceOnly(false),
m_modelLoadedFromDisk(false),
m_topologySuperseded(false),
m_mqlInferenceValidated(false),
m_shadowBootstrapAttempted(false),
m_enableOnlineLearning(true),
m_freezePriorCalibration(false),
m_onlineLearnedUpToTime(0),
m_onlineRollingAcc(-1.0),
m_onlineSamples(0),
m_onlineBarsSincePersist(0),
m_onlineBlendFrozen(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_spreadAtr(0.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_logitAdjustTau(1.0),
m_logitAdjustLogged(false),
m_logitAdjustSkipWarned(false),
m_prevEraTrueBuyCount(0),
m_prevEraTrueSellCount(0),
m_prevEraTrueNeutralCount(0),
m_oosBuyHits(0),
m_oosBuyTotal(0),
m_oosSellHits(0),
m_oosSellTotal(0),
m_oosNeutralHits(0),
m_oosNeutralTotal(0),
m_oosBuyPredicted(0),
m_oosBuyPredictedHits(0),
m_oosSellPredicted(0),
m_oosSellPredictedHits(0),
m_oosNeutralPredicted(0),
m_oosNeutralPredictedHits(0),
m_oosBuyPredictedWins(0),
m_oosSellPredictedWins(0),
m_oosWinLongTotal(0),
m_oosWinShortTotal(0),
m_oosConfidenceSum(0),
m_confidenceCalScale(1.0),
m_minDirectionalRecallPct(40),
//--- No vote-driven exit until the inputs say otherwise - matches Min_Vote_Close's shipped Disabled.
m_exitVoteThreshold(0.0),
m_exitHoldToBarrier(false),
m_simRSum(0.0),
m_simRSumSq(0.0),
m_simTrades(0),
m_simVoteExits(0),
m_simBarrierWins(0),
m_exitReplayReported(false),
m_lastRecallFloorPct(0.0),
m_detectabilityReported(false),
m_priorBuy(0.0),
m_priorSell(0.0),
m_priorNeutral(0.0),
m_oosBuyFired(0),
m_oosBuyFiredHits(0),
m_oosSellFired(0),
m_oosSellFiredHits(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_maxClassSampleWeight(1.5),
m_swingConfirmationBars(100),
m_barrierHorizonBars(BARRIER_HORIZON_FALLBACK),
m_barrierHorizonResolved(false),
m_barrierFallbackWarned(false),
m_lastBarrierTimedOut(false),
m_lastLabelWeekendCut(false),
m_metaGateArmed(false),
m_metaGateLastP(-1.0),
m_metaGateLastBe(-1.0),
m_metaGateApproved(0),
m_metaGateVetoed(0),
m_barrierHorizonLegStarved(false),
m_horizonStarvedWarned(false),
m_geometryCfgSaved(false),
m_geometryAdopted(false),
m_dirEvidence(false),
m_dirEvidenceWhy("not measured yet"),
m_lastBarrierBothWon(false),
m_lastBarrierBothWonTied(false),
m_labelPrebuildTimeoutCount(0),
m_labelPrebuildWeekendCutCount(0),
m_labelPrebuildBothWonCount(0),
m_labelPrebuildBothWonTieCount(0),
m_maxErasPerRun(300),
m_arrowRestoreIndex(0),
m_arrowRestorePending(false),
m_arrowRestoreStartMs(0),
m_rescanIndex(0),
m_rescanHi(0),
m_rescanBarsNow(0),
m_rescanPending(false),
m_rescanStartMs(0),
m_rescanRawBuy(0),
m_rescanRawSell(0),
m_rescanRawNeutral(0),
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),
m_excNet(NULL),
m_excTgt(NULL),
m_excOut(NULL),
m_excHeadFailed(false),
m_excBaseTotal(0),
m_excScored(0),
m_excScoredD(0),
m_excMonoViol(0),
m_excTrainTick(0),
m_excUs(0),
m_excTrailHead(0),
m_excTrailCount(0),
m_excTrailN(0),
m_excTrailScored(0),
m_lastStatusLabelUpdateTick(0),
m_lastBuyRecallPct(-1),
m_lastSellRecallPct(-1),
m_lastDisplayNeuron0(0),
m_lastDisplayNeuron1(0),
m_lastDisplayNeuron2(0),
m_lastDisplaySignal(0),
m_lastBarTime(0),
m_modelEta(InitialEtaForOptimizer()),
m_etaCeiling(InitialEtaForOptimizer()),
m_erasSinceCooldown(0),
m_bestOosForecast(-1),
m_bestBalancedOos(-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_featureFailTransient(false),
m_featureFailBlock(""),
m_featureFailIdx(-1),
m_windowFailSlot(-2),
m_windowFailTotal(0),
m_featureWidthWarned(false),
m_featureHealthReported(false),
m_lastHeartbeatTick(0),
m_passProgressPct(0),
m_passLabel("starting"),
m_lastEraCompleteTick(0),
m_lastStallReportTick(0),
m_haveOosCheckpoint(false),
m_bestDirPrecPct(-1.0),
m_bestChancePrecPct(-1.0),
m_bestDirCalls(0),
m_deployCandidateEras(0),
m_oosStable(false),
m_objectiveMet(false),
m_erasSinceBestBalanced(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_indicatorDepthCapBars(0),
m_indicatorDepthDeadWarned(false),
m_handleRepairTick(0),
m_barrierEraSeen(-1),
m_barrierEraTick(0),
m_barrierExcluded(false),
m_barrierHoldReportTick(0),
m_inferenceDepthRefusalWarned(false),
m_prebuildBlockWarned(false),
m_depthSettleStart(0),
m_depthProbeTick(0),
m_depthProbeLast(0),
m_depthProbeStable(0),
m_labelCacheBars(0),
m_labelCacheAnchorTime(0),
m_labelCachePrebuilt(false),
m_lastExcUp(0.0),
m_lastExcDown(0.0),
//--- 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_lastLabelLifespan(0),
m_labelLifespanSum(0.0),
m_labelLifespanCount(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_lastRungLifespan(0.0),
m_poolWriteWarned(false),
m_lastPoolPasses(false),
m_lastPoolReport(""),
m_derivedSlMult(0.0),
m_derivedTpMult(0.0),
//--- 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_geometryDerived(false),
m_geometryDerivePasses(0),
m_swingMedianBars(0),
m_labelPrebuildActive(false),
m_prebuildSeedPending(false),
m_labelPrebuildBars(0),
m_labelPrebuildOosCutoff(0),
m_labelPrebuildIndex(-1),
m_labelPrebuildBuyCount(0),
m_labelPrebuildSellCount(0),
m_labelPrebuildNeutralCount(0),
m_simOosNet(NULL),
m_simOosRunActive(false),
m_simOosCutoff(0),
m_simOosBarIndex(-1),
m_simOosForecast(0),
m_simOosSamples(0),
m_dbBackfillActive(false),
m_dbBackfillDone(false),
m_dbBackfillIndex(0),
m_dbBackfillStartIndex(0),
m_dbBackfillStopIndex(2),
m_dbBackfillBars(0),
m_dbBackfillFired(0),
m_dbBackfillEra(-1),
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(""),
m_isInitialized(false),
m_shutdownInProgress(false),
m_lastArrowsSaved(0),
m_purgeMismatchWarned(false),
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_dispSignal(0.0),
m_dispValid(false),
m_dispStamp(0),
m_dispEra(-1),
m_lastEnsRefusalKey(0),
m_miReportDeferrals(0),
m_barrierScanSlMult(0.0),
m_barrierScanTpMult(0.0),
m_barrierScanLiveLabels(false),
m_barrierScanTimeouts(0),
m_barrierHorizonClamped(false)
{
//--- 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.
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_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. Doing it again here nested inside that same teardown cascade doubled the
//--- stack depth of an already-deep recursive save (layers -> neurons -> connections) right at the
//--- point in MT5's lifecycle (EA recompile while attached) that has the least stack headroom, and
//--- reliably crashed the terminal with a stack overflow. Keep this destructor cheap.
if(CheckPointer(Net) != POINTER_INVALID)
delete Net;
if(CheckPointer(m_shadowNet) != POINTER_INVALID)
delete m_shadowNet;
if(CheckPointer(TempData) != POINTER_INVALID)
delete TempData;
if(CheckPointer(m_simOosNet) != POINTER_INVALID)
delete m_simOosNet;
//--- Excursion head: never persisted (Stage 1 is a measurement), so teardown is the whole lifecycle.
if(CheckPointer(m_excNet) != POINTER_INVALID)
delete m_excNet;
if(CheckPointer(m_excTgt) != POINTER_INVALID)
delete m_excTgt;
if(CheckPointer(m_excOut) != POINTER_INVALID)
delete m_excOut;
//--- 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;
}
//+------------------------------------------------------------------+
//| "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. Without that exception the tester could seed/refresh dPrevSignal from the deployed model
//--- and draw chart arrows from those weights, yet this gate would still hard-zero the trading vote.
//--- A fresh random topology still cannot trade in the tester because m_modelLoadedFromDisk stays false.
//--- Census: this gate is invisible to the refresh-path counters and is a live candidate for the
//--- all-bars-zero-direction backtest - see m_voteGateBlocked.
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.
//--- "not yet studied" sentinel - dPrevSignal == -2 is not a real Sell. Its MAGNITUDE is 2, so it
//--- passed straight through the confidence floor that used to sit here (|-2| exceeds any 0..1
//--- threshold); only the m_trainingComplete gate above was keeping it out. Checked explicitly now
//--- that the floor is gone, rather than left resting on that.
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. Nothing can ever be read below this.
//--- - single-neuron regression: 0.5, DoubleToSignal()'s own decision boundary.
//--- Quartiling from HERE (rather than from an input, as the classification branch used to) is what
//--- makes the tier boundaries a fixed property of the model instead of something that silently moves
//--- whenever the trader adjusts an unrelated vote threshold - and it is what lets the same tier
//--- weights mean the same thing on both heads. See m_pattern_0's declaration comment for the weights.
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). The bounds above describe the range
//--- the HEAD can emit - a 3-way softmax winner is arithmetically >= 1/3 - but
//--- CalibratedConfidenceMagnitude() multiplies by m_confidenceCalScale, which is clamped to
//--- [0.3, 1.5]. That lower clamp sits BELOW this floor of 1/3, so the moment calibration bottoms
//--- out the quartiling is fed values it considers impossible: t goes negative, MathFloor takes it
//--- further negative, and MathMax(0, ...) pins EVERY call to tier 0.
//---
//--- Not hypothetical - it is what the live SP500 H4 run does. m_confidenceCalScale is EMA'd toward
//--- empiricalAccuracy / avgClaimedConfidence (see its update in Training.mqh); with the model
//--- over-calling Neutral, 3-class agreement accuracy sits near 10% against a claimed confidence
//--- near 0.9, so the ratio is ~0.11 and clamps to the 0.3 floor every era. Logged result:
//--- "tier prec T0:72%(828) T1:n/a(0) T2:n/a(0) T3:n/a(0)" - 828 calls, one bucket, and the four
//--- tier weights plus the whole per-tier pattern-DB ranking reduced to a single number.
//---
//--- Tiering wants RELATIVE confidence bucketing, which is exactly what the raw head output gives,
//--- on precisely the [floorConf, 1] range these bounds were written for. Calibration is still the
//--- right thing for consumers that need an absolute probability - AIConfidence() for MM sizing and
//--- SignedAIConfidence() for the vote both keep using it, unchanged.
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. |
//| |
//| Mirrors the live path line for line: |
//| LongCondition/ShortCondition -> PatternWeightForTier(tier) |
//| CExpertSignalCustom::Direction() -> m_weight * (long - short) |
//| Both m_weight and the four tier weights are rewritten from the |
//| signal DB by UpdateSignalsWeights(), so this number MOVES as |
//| ranking lands. That is deliberate and it is the point: the gate |
//| has to score the vote the EA would actually have cast, not a |
//| frozen idealisation of it. |
//+------------------------------------------------------------------+
double CExpertSignalAIBase::LiveVoteContribution(const double signal)
{
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
double contribution = m_weight * w;
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;
}
}
//+------------------------------------------------------------------+
//| TURN THIS ERA'S HELD-OUT OUTCOMES INTO THE VOTE WEIGHTS. |
//| |
//| The problem this solves, in the user's own words (2026-08-18): |
//| "NNs are different from classic signals - 0.33 could change |
//| meaning as the neurons weights change". Exactly so. A classic |
//| Pattern_2 is a fixed geometric condition, so win rates for it can |
//| be accumulated over years and stay meaningful. An AI Pattern_2 |
//| means "confidence landed in tier 2", and tier 2 under era 100's |
//| weights is a different statement from tier 2 under era 500's. Any |
//| accumulated ledger of AI rows therefore describes models that no |
//| longer exist, and averages them together. |
//| |
//| WHY THIS DOES NOT WRITE TO THE SIGNAL DB, which was the obvious |
//| reading of "fill the database during training": the DB's value is |
//| accumulation, and accumulation is precisely what is wrong here. |
//| Synthetic rows would also collide with the per-table row cap and |
//| mix measured-on-holdout outcomes into the same tables the LIVE |
//| ledger uses. What the DB actually supplies is a measured win rate |
//| per pattern - and pass 3 already computes exactly that, on held- |
//| out bars, thousands at a time instead of a handful of live trades.|
//| So the AI ranks itself from that, once per era, replacing rather |
//| than accumulating - which makes the weights describe the CURRENT |
//| weights by construction. |
//| |
//| SHRINKAGE IS NOT OPTIONAL HERE. The OOS window holds ~63 |
//| EFFECTIVE observations (overlapping triple-barrier labels - see |
//| EffectiveSampleSize), so four tiers hold ~8 apiece and a raw |
//| per-tier ratio would be noise dressed as a probability. Each tier |
//| is shrunk toward this model's POOLED holdout win rate with |
//| MIN_TRADES_FOR_WIN_RATE pseudo-trades - the same estimator, and |
//| the same prior strength, UpdateSignalsWeights() applies to the |
//| classic ladders. A thin tier therefore reads as the model's own |
//| overall win rate and the tiers separate only as evidence earns it.|
//| That is the correct behaviour, not a failure to differentiate. |
//| |
//| NO SAME-ERA CIRCULARITY, and it falls out of the ordering rather |
//| than needing a guard: weights are computed at the END of era N, |
//| so the vote scored during era N was cast with era N-1's weights. |
//| The deploy gate never grades a vote whose weights were fitted on |
//| the very bars it is scoring. Residual leakage remains and is not |
//| papered over - it is the same OOS bars each era, under a |
//| different model - which is why this feeds the VOTE and not the |
//| gate's own pass/fail arithmetic. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::RankTiersFromOos(void)
{
//--- SNAPSHOT FIRST, unconditionally - this runs at pass-3 completion, the single moment the
//--- arrow cache is complete for the era, and everything display-side (the overlay sweep, the
//--- prospective readout) reads the snapshot instead of the cache precisely because the cache is
//--- about to be wiped when the next era starts. Deliberately BEFORE the pooledFired early
//--- return: an all-Neutral era still scored every bar, and "the model says Neutral everywhere"
//--- is a snapshot worth showing, not an absence of one. Raw signals, not votes: a tier re-rank
//--- between eras then reprices them at read time (LiveVoteContribution) for free.
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 a fresh snapshot exists, so the overlay redraws from it. Every member
//--- sets this each era; the EA's rate limit collapses the burst into one sweep.
g_warriorOverlayArmRequest = true;
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;
//--- 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. That
//--- floor is right there - a classic pattern with 12 live rows should be left at its default, not
//--- re-weighted - but here it would fire on every tier every era and hand all four the pooled rate,
//--- so the tiers could never separate and the whole mechanism would be inert. Shrinkage is the
//--- answer to a small sample; a floor in front of it means the shrinkage never runs.
//---
//--- ...and the sample is measured EFFECTIVE, not raw. Triple-barrier labels overlap, so N fires
//--- resolving over a mean lifespan of L bars are worth about N/L independent observations (see
//--- EffectiveSampleSize / [[project_label_overlap_effective_n]]). A tier showing 800 raw fires may
//--- carry ~12 real ones. Shrinking on the raw count would treat that as overwhelming evidence and
//--- reproduce exactly the "clears by N sigma" error that overlap invalidated everywhere else.
string line = "";
for(int t = 0; t < 4; t++)
{
int fired = m_oosTierFired[t];
int hits = m_oosTierHits[t];
double w = pooledPct;
if(fired > 0)
{
double effN = MathMax(0.0, EffectiveSampleSize((double)fired));
double effHits = effN * ((double)hits / fired);
//--- Beta prior of TIER_PRIOR_EFF_N pseudo-observations centred on this model's pooled
//--- holdout rate. Deliberately small: the prior is counted in the same EFFECTIVE units as
//--- the evidence, and a tier holds ~8-15 of those, so a prior of 100 (the classic path's)
//--- would drown every tier in the pool. At 10 a tier with ~10 effective observations sits
//--- half on its own evidence and half on the pool, which is an honest reading of what one
//--- era's holdout can support.
w = (effHits + TIER_PRIOR_EFF_N * (pooledPct / 100.0)) * 100.0 / (effN + 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, pooledPct / 100.0)));
m_tiersSelfRanked = true;
//--- 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 (pooled %.1f%%) ->%s"
" | module weight %.2f. These are the weights the NEXT era votes with.",
pooledFired, pooledPct, 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. Once this model has measured its own tiers on
//--- held-out bars (RankTiersFromOos), that measurement describes the weights actually loaded and
//--- the DB's does not, so the DB call is declined rather than allowed to overwrite it.
//--- 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/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 for the intelligent trailing (and any other
//--- live-confidence consumer) - see g_LiveAISignedConfidence in Variables\ConfidenceBridge.mqh.
//--- Cheap: SignedAIConfidence() just reads the already-computed dPrevSignal.
//--- 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 - and its two consumers are the AI early-exit route (CExpertSignalCustom::LiveSignedConfidence)
//--- and the intelligent trailing stop. So on a four-model chart an LSTM entry could be closed, and its
//--- stop moved, on the Perceptron's opinion alone, purely by scheduling order. Not the vote, not a
//--- weighted blend - an arbitrary member. (User-identified 2026-08-17.)
//--- 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 an exit trigger.
//--- Currently latent, and worth keeping that way deliberately: Min_Vote_Close ships Disabled (101,
//--- unreachable on both scales it drives) and TrailingStrategy is off, so neither consumer fires
//--- today. This is fixed now precisely because the plan is to enable vote exits once the models are
//--- accurate - at which point a scheduling-order exit would be actively harmful and very hard to see.
//--- 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());
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. Treat a failed lookup as pending instead (same >0-guard
// philosophy as the SERIES_FIRSTDATE lookup elsewhere in this class) so a transient history-sync
// hiccup costs one extra harmless check, not a silent stall.
bool newBarPending = (dPrevSignal == -2 || lastBarDate <= 0 || ((m_inferenceOnly ? m_lastBarTime : dtStudied) < lastBarDate));
//--- A MODEL THAT HAS NOT FINISHED TRAINING IS ALWAYS PENDING. The watermark test above answers
//--- "has a new bar closed since the last one we processed", which is the right question for a
//--- CONVERGED model (one inference refresh per bar, see the branch below) and the wrong one for a
//--- training run: Train() is chunked - it does ~TRAIN_TIME_BUDGET_MS of work and yields, needing
//--- thousands of calls to finish a single era - yet every one of those calls has to be armed from
//--- here. Gating them on the watermark meant training could only advance when a new BAR closed.
//--- On H1 that is one 120ms chunk per hour.
//--- The trap is that dtStudied is two different things: Train() sets it to the training WINDOW
//--- START (TrainWindowStart, ~2008) while FinalizeTrainRun() sets it to the last bar SCANNED
//--- (~now). So the moment any run finalized, dtStudied >= lastBarDate and this function went
//--- silent until the next candle - no era lines, no heartbeats, no stall branch, nothing, because
//--- Train() was not being CALLED at all. Observed 2026-08-10: four charts, 28 minutes of silence
//--- between two bursts exactly one H1 bar apart, and the TRAIN STALL line that finally caught it
//--- reported runActive=Y only because m_trainRunActive had been set microseconds earlier in that
//--- same call. Before 0c85c54 this was survivable rather than correct: eras were nearly free (the
//--- saved watermark left almost no bars eligible), so one call per bar still looked like progress.
//--- There is never a reason to withhold a Train() call from an unconverged model - pause/stop are
//--- handled by m_trainingPaused/m_trainingStopRequested, which Train() checks for itself.
//--- A POST-TRAINING WALK IS ALSO PENDING. Both of these are armed at the moment convergence is
//--- declared (Training.mqh's era-end block: StartOosContinualSimulation + StartPatternDatabaseBackfill)
//--- and both advance ONLY from inside Train(), one time-boxed chunk per call - so they need calls
//--- armed from here exactly like a training run does. Without this term they never got any: the
//--- convergence that arms them also sets m_trainingComplete, and FinalizeTrainRun() clears
//--- m_trainRunActive one line earlier, so the branch below took the converged path from that instant
//--- on and ArmStudyEvent() - the only per-tick arming site in the EA - was never reached again.
//--- Train() was simply never called, so both walks sat at their start index forever: the
//--- continual-learning OOS simulation never produced its "simulation complete" line, and the
//--- pattern-database backfill never wrote a row (the DB it exists to fill stayed empty, which is
//--- indistinguishable from the feature being absent). Only a manual Resume/Retrain click - which
//--- arms an event through a different path - could ever unstick them. Found 2026-08-16.
bool postTrainWalkPending = (m_simOosRunActive || m_dbBackfillActive);
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. Only write this terse
//--- summary when nothing else is actively updating the status label (idle/stopped/paused/cooldown).
//--- ...and the same reasoning covers a member held at the ENSEMBLE ERA BARRIER, which the
//--- !m_trainRunActive test above does not: a held member returns from Train() before it ever sets
//--- m_trainRunActive, so BOTH writers considered themselves the only one updating the label and
//--- fought over it every tick. That is the "Getting ready..." <-> "Waiting at era N for slower
//--- ensemble members" flicker reported on 2026-08-17 - and it appeared on Perceptron but not
//--- Convolutional purely because Convolutional had a run active from a completed era and Perceptron,
//--- resumed from disk, never did. Train()'s message is the specific one, so it wins.
if(m_ensembleMember && EnsembleEraBarrierHolds())
return;
if(!m_trainRunActive)
{
//--- Compact, accurate end-state text. The completed state distinguishes a model that is genuinely
//--- adapting live (online learning active - a live chart with EnableOnlineLearning, not the tester)
//--- from one running pure inference (the Strategy Tester, or online learning off), so the label is
//--- literally true either way and never over-promises "keeps learning" when it doesn't - see
//--- OnlineLearnStep()'s gate for exactly when adaptation runs.
bool onlineActive = m_enableOnlineLearning && !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 = 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";
//--- 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".
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). Same counters and same
//--- always-show-the-break-even doctrine as ComputeCompoundedAccuracyLine - a win rate
//--- without its geometry's base rate reads as skill when it is chance.
if(m_cumOosTotal > 0)
{
double slBeH = 0.0, tpBeH = 0.0;
BarrierMultiples(slBeH, tpBeH);
simpleLive += StringFormat(" | win %d%%", (int)MathRound(m_cumOosCorrect * 100.0 / m_cumOosTotal));
if(slBeH > 0.0 && tpBeH > 0.0)
simpleLive += StringFormat(" (need %d%%)", (int)MathRound(100.0 * slBeH / (slBeH + tpBeH)));
}
}
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. Both compete for the one MQL5 thread; letting the arrows finish
//--- quickly (a few hundred ms of slices) means the user sees a complete chart almost immediately,
//--- whereas interleaving them with 80ms training chunks would stretch the restore over minutes.
//--- STOP BEFORE ANY OF IT. Everything below either draws objects on the chart (the arrow restore and
//--- the rescan) or starts a training chunk, and OnDeinit is about to delete every one of those objects
//--- again - while the budget it has to do it in is already running. 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_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_rescanPending)
{
AdvanceChartSignalRescan();
return;
}
if(m_isInitialized)
ScheduleTrainingIfNeeded();
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::OnChartEventHandler(const int id,
const long &lparam,
const double &dparam,
const string &sparam)
{
//--- Match THIS instance's id only. CExpertCustom broadcasts every chart event to every filter, so
//--- matching a shared id here (the old `id == 1001`) made each posted event run a train chunk in
//--- ALL ensemble members - N members posting per round times N members handling each = N*N chunks,
//--- and the chart thread never idled long enough to deliver the control panel's clicks and drags.
//--- 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();
}
}
//+------------------------------------------------------------------+
//| Claim m_activeFileName for this chart, terminal-wide. |
//| |
//| Two charts running the same NN set with the same retrain-affecting|
//| inputs resolve to the SAME .nnw/.cfg/.stats/checkpoint set. Both |
//| then train independently and save over each other, so whichever |
//| writes last wins and the other's eras are discarded - silently, |
//| because every individual file operation succeeds. A five-chart |
//| comparison run on 2026-07-29 lost both its HYBRID models this way |
//| (one chart left at the default NN set), and the only evidence |
//| anywhere was that model path appearing twice as often in the log. |
//| |
//| A terminal-wide global variable is the right lock rather than a |
//| lock FILE: GlobalVariableTemp() is an atomic create-if-absent, |
//| and a TEMPORARY variable dies with the terminal, so a crash can |
//| never leave a stale lock that blocks the next start. Within one |
//| session a stale entry is still possible (an EA removed without a |
//| clean deinit), so the owner's chart id is stored and revalidated. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::AcquireConfigLock(void)
{
//--- FNV-1a over the resolved filename: every retrain-affecting input is already folded into that
//--- name, so equal names mean genuinely equal configs and nothing else has to be compared. Hashed
//--- because MQL5 caps global-variable names at 63 characters and the path alone exceeds that.
uint h = 2166136261;
int len = StringLen(m_activeFileName);
for(int i = 0; i < len; i++)
{
h ^= (uint)StringGetCharacter(m_activeFileName, i);
h *= 16777619;
}
string name = "WarriorAI_" + m_id + "_" + StringFormat("%08x", h);
long self = ChartID();
//--- Atomic: true means it did not exist and is now ours.
if(GlobalVariableTemp(name))
{
GlobalVariableSet(name, (double)self);
m_configLockName = name;
return true;
}
long owner = (long)GlobalVariableGet(name);
//--- Our own entry: this chart is re-initializing after a parameter change or a recompile whose
//--- OnDeinit never reached ReleaseConfigLock(). Reclaim it instead of refusing to start.
if(owner == self)
{
m_configLockName = name;
return true;
}
//--- Owner recorded but its chart no longer runs an expert - take the claim over. owner == 0 is
//--- deliberately NOT treated as stale: it means another instance created the variable microseconds
//--- ago and has not stamped its id yet, which is a live claim, not a dead one.
bool ownerAlive = false;
if(owner != 0)
{
long id = ChartFirst();
while(id >= 0)
{
if(id == owner)
{
ownerAlive = (StringLen(ChartGetString(id, CHART_EXPERT_NAME)) > 0);
break;
}
id = ChartNext(id);
}
}
if(owner != 0 && !ownerAlive)
{
GlobalVariableSet(name, (double)self);
m_configLockName = name;
return true;
}
Print(ID + ": REFUSED to start - another chart is already training this exact configuration. Both" +
" would save into the same files (" + m_activeFileName + ".nnw plus its .cfg/.stats/checkpoints)" +
" and overwrite each other's progress with no error reported anywhere. Owner: " +
(owner != 0 ? "chart " + IntegerToString(owner) + " (" + ChartSymbol(owner) + " " +
EnumToString((ENUM_TIMEFRAMES)ChartPeriod(owner)) + ")" : "another chart, still initializing") +
". Change the enabled NN set (Use_MLP/Use_CONV/Use_LSTM/Use_CONVLSTM) or any retrain-affecting" +
" input on THIS chart so it trains its own model, or remove one of the two charts. Note the" +
" private build defaults ALL FOUR direction NNs on - two same-symbol charts left at their" +
" defaults land here.");
return false;
}
//+------------------------------------------------------------------+
//| Drop this instance's claim (see AcquireConfigLock). |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ReleaseConfigLock(void)
{
if(StringLen(m_configLockName) == 0)
return;
//--- Only delete a claim we still hold: if a later instance took this entry over via the stale-owner
//--- path above, deleting it here would silently hand the config to a third chart.
if((long)GlobalVariableGet(m_configLockName) == ChartID())
GlobalVariableDel(m_configLockName);
m_configLockName = "";
}
#endif