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