Warrior_EA/Expert/AIBase/Lifecycle.mqh
AnimateDread b3b7e7bceb fix: excursion window must not depend on the barrier it sizes
DIRECTION IS NOT THERE, and this run is what establishes it. Three symbols:

  raw ASYMMETRY   clears on all three (p=0.0199 / 0.0050 / 0.0050)
  norm ASYMMETRY  collapses on all three (p=0.3433 / 0.5075 / 0.2736),
                  USDCAD landing BELOW its own null
  RANGE control   strengthens to 3-5x its null everywhere

Divide sigma out and the apparent directional signal vanishes entirely. What
cleared was volatility leaking through an unnormalised difference. Note this
would have passed any replication test: three instruments at p=0.005 is exactly
the evidence one would accept before committing to a rebuild, and the confound
reproduces perfectly. Replication was never going to catch it - only the
normalisation could.

Two defects of mine, both surfaced by the same run.

1. THE GEOMETRY DERIVATION WAS DIVERGING, NOT CONVERGING. It produced a
   14.57*ATR stop and a 29.14*ATR target that only 5.7% of bars ever reach.
   Excursions were measured over the barrier horizon; the horizon scales with
   the target; the target is a quantile of the excursions - so target ->
   horizon -> excursions -> target ran away, and "settled" only because the
   horizon ladder caps at 384 bars. A saturated runaway, which the iteration
   guard could not catch because it watches for OSCILLATION.
   Fixed at the root: excursions now accumulate only over m_swingMedianBars -
   the UNSCALED median ZigZag leg, a property of the instrument that owes
   nothing to the barrier. The barrier walk still runs the full horizon,
   because that is how long the trade is held; only the MEASUREMENT used to
   size the barrier is confined to a geometry-independent window.
   (The Min_Risk_Reward_Ratio warning fired correctly and is what flagged it -
   the diagnostic worked while the derivation behind it did not.)

2. THE CONFOUND VERDICT WAS UNREACHABLE. `sizeCleared && !asymCleared` was
   tested first and is true whenever size clears - i.e. always - so the branch
   that NAMES the volatility confound never printed; all three symbols showed
   the generic size-not-direction message instead. Verdict chain rewritten with
   the specific case first, and the dangling elses my first patch introduced
   removed.

FORCES A FULL RETRAIN (the excursion window changes every derived barrier).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:57:23 -04:00

662 lines
30 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_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_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_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_oosConfidenceSum(0),
m_confidenceCalScale(1.0),
m_minDirectionalRecallPct(40),
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_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_labelPrebuildTimeoutCount(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_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_haveOosCheckpoint(false),
m_oosStable(false),
m_objectiveMet(false),
m_erasSinceBestBalanced(0),
m_plateauStage(0),
m_syncWaitStartTick(0),
m_warmupPassesRemaining(0),
m_labelCacheBars(0),
m_labelCacheAnchorTime(0),
m_labelCachePrebuilt(false),
m_lastExcUp(0.0),
m_lastExcDown(0.0),
m_derivedSlMult(0.0),
m_derivedTpMult(0.0),
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_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_miBestColumn(0.0),
m_miLabelEntropy(0.0),
m_miStrideBars(0),
m_miNullBlocks(0),
m_miReportDone(false),
m_miReportDeferrals(0),
m_barrierScanSlMult(0.0),
m_barrierScanTpMult(0.0),
m_barrierScanLiveLabels(false),
m_barrierScanTimeouts(0),
m_barrierHorizonClamped(false)
{
//--- 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;
//--- 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 the live 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::ConfidenceTier(void)
{
//--- 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);
double t = (CalibratedConfidenceMagnitude() - floorConf) / span;
int tier = (int)MathFloor(t * 4.0);
return MathMax(0, MathMin(tier, 3));
}
//+------------------------------------------------------------------+
//| 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;
}
}
//+------------------------------------------------------------------+
//| Set the specified pattern's weight to the specified value |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ApplyPatternWeight(int patternNumber, int weight)
{
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.
g_LiveAISignedConfidence = 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));
//--- 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)
{
if(newBarPending)
RefreshConvergedSignal();
}
else
if(!m_trainingStopRequested && !bEventStudy && newBarPending)
bEventStudy = EventChartCustom(ChartID(), 1, (long)MathMax(0, MathMin(iTime(m_symbol.Name(), PERIOD_CURRENT, (int)(100 * Net.recentAverageSmoothingFactor * (m_trainingComplete ? 1 : 10))), dtStudied)), 0, "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).
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";
simpleLive += "Current signal: " + liveSigPlain;
SetStatusLabel(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.
SetStatusLabel(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.
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)
{
if(id == 1001)
{
TuneIndicatorsAndTrain(lparam);
bEventStudy = false;
OnTickHandler();
}
}
//+------------------------------------------------------------------+
//| Claim m_activeFileName for this chart, terminal-wide. |
//| |
//| Two charts running the same AIType 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 AIType default), 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 AIType or any retrain-affecting input on THIS chart so it trains its own model, or" +
" remove one of the two charts. Note AIType defaults to " + EnumToString(AI_HYBRID) +
" - a chart whose AIType was never actually changed lands 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