BuildFeatureWindow() replaces eight hand-rolled copies of the same loop
and feeds the window OLDEST BAR FIRST. Every copy fed it newest-first,
because MQL5 timeseries indices run backwards and `r + b` with b ascending
walks into the past.
Harmless for PAI and CONV - a dense layer learns a weight per position
either way, a conv learns time-mirrored kernels. Not harmless for the
recurrent stacks:
- LSTM_SeqStepForward reads `inputs + t*Iw`, so step t is block t.
- It writes output[] only when t == steps-1: the visible output IS the
last hidden state.
- c_t = f*c_{t-1} + i*g decays toward the start of the sequence.
lstm_seq_flowcheck.cpp measured block 0's influence on the output at
1.2e-2 of block T-1's, at the shipped forget bias of 1.0.
So the bar being PREDICTED sat at the far end of the decay and the output
was handed to the OLDEST bar in the window - the exact inverse of what the
window is for. ~80x backwards on LSTM and HYBRID, on all three tiers
(OpenCL kernel, CPU DLL, pure-MQL5 inference), which is why it never
surfaced as a backend discrepancy.
This does not create edge - the MI diagnostics read at the noise floor
(p=0.4975) with a working positive control. It makes the one hypothesis
those diagnostics explicitly do NOT cover testable: they are marginal and
per-bar, and state they "cannot rule out one that only exists in
combination or across time". The sequence model is the instrument for
across-time structure and it has been crippled, so that hypothesis has
never been honestly tested.
Fingerprint gets an unconditional |WIN:2 - the vector keeps its shape and
its features, so a stale .nnw would load cleanly and run a model fitted to
one ordering against the other, silently. Re-keying every config is the
point, not collateral damage. FORCES A FULL RETRAIN.
Also: the now-relative bar caches are re-keyed on the two live paths.
EnsureBarCachesCapacity() was only ever called from training paths, but
once m_trainingComplete is set ScheduleTrainingIfNeeded() routes every bar
to RefreshConvergedSignal() and Train() is never re-entered - so nothing
cleared the feature cache again for the life of the process. A chart that
trained to convergence kept replaying the rows computed for the last
training era's bar grid: the live signal froze at its convergence-time
value, and OnlineLearnStep() backpropped those stale features against
freshly resolved labels. Backtests were never affected (an inference-only
process never allocates the arrays, so every read recomputes).
Compiles clean: 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
472 lines
27 KiB
MQL5
472 lines
27 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| Warrior_EA |
|
|
//| AnimateDread |
|
|
//| |
|
|
//| Live continual learning, the EMA shadow net, and the OOS continu|
|
|
//| |
|
|
//| PARTIAL IMPLEMENTATION FILE - not standalone. |
|
|
//| This holds 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. |
|
|
//| |
|
|
//| Split out purely to make the 8216-line original navigable; the |
|
|
//| code inside was moved verbatim, not rewritten. |
|
|
//+------------------------------------------------------------------+
|
|
#ifndef WARRIOR_AIBASE_ONLINELEARNING_MQH
|
|
#define WARRIOR_AIBASE_ONLINELEARNING_MQH
|
|
//+------------------------------------------------------------------+
|
|
//| Clones the just-converged Net into a separate CNet (m_simOosNet) |
|
|
//| and arms a chunked bar-by-bar walk through the OOS window - see |
|
|
//| AdvanceOosSimulationChunk(). Evaluation-only: the clone's learned |
|
|
//| weights are never written back to Net or any persisted file. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::StartOosContinualSimulation(int bars, int oosCutoff)
|
|
{
|
|
if(m_simOosRunActive)
|
|
{
|
|
delete m_simOosNet;
|
|
m_simOosNet = NULL;
|
|
m_simOosRunActive = false;
|
|
}
|
|
if(oosCutoff <= 0)
|
|
return; // nothing to walk this run
|
|
//--- Clone via the full Save()/Load() pair. Load() calls InitOpenCL()/InitDirectML() before
|
|
//--- reconstructing layers, so a bare "new CNet(NULL)" ends up with a GPU/DirectML backend matching
|
|
//--- production. Any lighter-weight restore that reused the CALLER's opencl/directml pointers would
|
|
//--- be wrong here: on a fresh CNet(NULL) (whose constructor no-ops for a NULL description) those are
|
|
//--- unset, and the clone would come out degenerate. (A file-based checkpoint pair used to sit beside
|
|
//--- Save/Load and had exactly that flaw; it has been removed - the in-run snapshot is now the
|
|
//--- in-memory CNet::CaptureWeights/RestoreWeights.)
|
|
//--- Co-locate this ephemeral clone temp with the active model (COMMON on a live chart, LOCAL in the
|
|
//--- tester sandbox) instead of always LOCAL. Uses m_activeFileName for the same reason (the active
|
|
//--- model's base name, whichever context we're in).
|
|
string simFile = m_activeFileName + "_simoos.tmp";
|
|
int simFlags = m_activeFileCommon ? FILE_COMMON : 0;
|
|
double ip[];
|
|
if(!Net.Save(simFile, 0.0, 0.0, 0.0, dtStudied, m_activeFileCommon, m_eraCount, m_trainingComplete, ip))
|
|
return;
|
|
m_simOosNet = new CNet(NULL);
|
|
double loadE, loadU, loadF;
|
|
datetime loadTime;
|
|
long loadEra;
|
|
bool loadComplete;
|
|
double loadIp[];
|
|
bool loaded = m_simOosNet.Load(simFile, loadE, loadU, loadF, loadTime, m_activeFileCommon, loadEra, loadComplete, loadIp, true /*quiet: this evaluation-only sim is optional - on a miss it simply doesn't run*/);
|
|
FileDelete(simFile, simFlags);
|
|
if(!loaded)
|
|
{
|
|
delete m_simOosNet;
|
|
m_simOosNet = NULL;
|
|
return;
|
|
}
|
|
m_simOosCutoff = oosCutoff;
|
|
m_simOosBarIndex = oosCutoff - 1;
|
|
m_simOosForecast = 0;
|
|
m_simOosSamples = 0;
|
|
m_simOosRunActive = true;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Advances the evaluation-only continual-learning OOS walk by up |
|
|
//| to TRAIN_TIME_BUDGET_MS of work, then yields (same chunking |
|
|
//| pattern as the real era loop's m_eraResumePending). For each bar, |
|
|
//| oldest-OOS to newest: predict with the clone's CURRENT weights, |
|
|
//| score against the cached true label, THEN let the clone learn |
|
|
//| from it (single pass, no oversampling replay) - simulating how |
|
|
//| the model would adapt bar-by-bar in real forward trading. Never |
|
|
//| touches Net, never Saves the clone - purely an evaluation metric.|
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::AdvanceOosSimulationChunk(void)
|
|
{
|
|
const uint SIM_TIME_BUDGET_MS = 80;
|
|
uint chunkStartTick = GetTickCount();
|
|
//--- Mirror OnlineLearnStep()'s pinned rate for the duration of this chunk, and hand the shared
|
|
//--- global back on BOTH exit paths - this simulation is only a valid forecast of live continual
|
|
//--- learning if it steps at the same size, and `eta` is shared by every signal instance.
|
|
double savedEta = eta;
|
|
eta = m_modelEta * ONLINE_LEARN_ETA_SCALE;
|
|
int i;
|
|
for(i = m_simOosBarIndex; i >= 0; i--)
|
|
{
|
|
if(GetTickCount() - chunkStartTick >= SIM_TIME_BUDGET_MS)
|
|
{
|
|
m_simOosBarIndex = i;
|
|
eta = savedEta;
|
|
return;
|
|
}
|
|
if(i >= ArraySize(m_labelCacheHasValue) || !m_labelCacheHasValue[i])
|
|
continue; // no cached label for this bar (e.g. right at a window edge) - nothing to learn from
|
|
//--- Window ends AT (includes) bar i - see Train()'s matching r declaration comment for why.
|
|
int r = i;
|
|
if(!BuildFeatureWindow(r))
|
|
continue;
|
|
m_simOosNet.feedForward(TempData);
|
|
m_simOosNet.getResults(TempData);
|
|
double simSignal = (m_outputNeuronsCount == 3) ? ApplyClassificationSoftmax() : TempData[0];
|
|
//--- Pre-update softmax probabilities, read before TempData is rebuilt as the target vector -
|
|
//--- feeds the same alpha-balanced focal weight the live path applies (OnlineSampleWeight).
|
|
double sBuy = (TempData.Total() > 0) ? TempData.At(0) : 0.0;
|
|
double sSell = (TempData.Total() > 1) ? TempData.At(1) : 0.0;
|
|
double sNeutral = (TempData.Total() > 2) ? TempData.At(2) : 0.0;
|
|
bool buy = m_labelCacheBuy[i];
|
|
bool sell = m_labelCacheSell[i];
|
|
ENUM_SIGNAL trueSignal = buy ? Buy : (sell ? Sell : Neutral);
|
|
bool hit = (DoubleToSignal(simSignal) == trueSignal);
|
|
m_simOosSamples++;
|
|
if(hit)
|
|
m_simOosForecast += (100 - m_simOosForecast) / Net.recentAverageSmoothingFactor;
|
|
else
|
|
m_simOosForecast -= m_simOosForecast / Net.recentAverageSmoothingFactor;
|
|
TempData.Clear();
|
|
if(m_outputNeuronsCount == 1)
|
|
TempData.Add(buy && !sell ? 1 : !buy && sell ? -1 : 0);
|
|
else
|
|
if(m_outputNeuronsCount == 3)
|
|
{
|
|
TempData.Add(buy ? LABEL_SMOOTH_HIGH : LABEL_SMOOTH_LOW);
|
|
TempData.Add(sell ? LABEL_SMOOTH_HIGH : LABEL_SMOOTH_LOW);
|
|
TempData.Add((!buy && !sell) ? LABEL_SMOOTH_HIGH : LABEL_SMOOTH_LOW);
|
|
}
|
|
m_simOosNet.backProp(TempData, OnlineSampleWeight(trueSignal, sBuy, sSell, sNeutral));
|
|
}
|
|
eta = savedEta;
|
|
delete m_simOosNet;
|
|
m_simOosNet = NULL;
|
|
m_simOosRunActive = false;
|
|
Print(ID + ": continual-learning OOS simulation complete - " + IntegerToString(m_simOosSamples) + " samples, accuracy " + DoubleToString(m_simOosForecast, 1) + "%");
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Alpha-balanced focal weight for one streamed bar - the cost-level |
|
|
//| imbalance correction used by BOTH the live continual-learning path |
|
|
//| and its OOS simulation. See the declaration comment and the |
|
|
//| ONLINE_LEARN_* block's CLASS IMBALANCE note for the derivation. |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalAIBase::OnlineSampleWeight(ENUM_SIGNAL trueSignal, double pBuy, double pSell, double pNeutral)
|
|
{
|
|
//--- Regression head has no class structure to balance.
|
|
if(m_outputNeuronsCount != 3)
|
|
return 1.0;
|
|
double weight = 1.0;
|
|
//--- alpha_c: inverse class frequency from the measured, persisted priors, normalised so the
|
|
//--- MAJORITY class is exactly 1.0 (a majority bar is never down-weighted below parity) and only
|
|
//--- minority bars are ever up-weighted. Unmeasured priors - a model deployed before any prior was
|
|
//--- recorded - skip the alpha term rather than divide by zero; focal's (1-p_t)^gamma still applies.
|
|
double priorMax = MathMax(m_priorNeutral, MathMax(m_priorBuy, m_priorSell));
|
|
bool priorsUsable = (priorMax > 0.0 && m_priorBuy > 0.0 && m_priorSell > 0.0 && m_priorNeutral > 0.0);
|
|
if(priorsUsable && trueSignal != Neutral)
|
|
{
|
|
double truePrior = (trueSignal == Buy) ? m_priorBuy : m_priorSell;
|
|
//--- Measured ratio scaled by ONLINE_LEARN_PARITY, then capped so a single rare bar can never
|
|
//--- deliver an outsized kick to an already-validated deployed model. Both were shared inputs
|
|
//--- until 2026-07-31; see the CLASS IMBALANCE note above ONLINE_LEARN_MAX_CLASS_WEIGHT for why
|
|
//--- this engine keeps its own cost-level correction now that Train() corrects in the gradient.
|
|
weight = MathMin(MathMax(1.0, (priorMax / truePrior) * ONLINE_LEARN_PARITY), ONLINE_LEARN_ALPHA_CAP);
|
|
}
|
|
//--- gamma: down-weights bars the model already gets right (the overwhelming Neutral majority), so
|
|
//--- the update concentrates on genuinely informative confirmations. Constant since 2026-07-31.
|
|
if(ONLINE_LEARN_FOCAL_GAMMA > 0.0)
|
|
{
|
|
double pt = (trueSignal == Buy) ? pBuy : (trueSignal == Sell) ? pSell : pNeutral;
|
|
pt = MathMax(0.0, MathMin(1.0, pt));
|
|
weight *= MathPow(1.0 - pt, ONLINE_LEARN_FOCAL_GAMMA);
|
|
}
|
|
return weight;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Online continual-learning step - LIVE CHART ONLY. Once a model is |
|
|
//| deployed (m_trainingComplete) it keeps learning from real market |
|
|
//| structure the same supervised way it was trained: predicting the |
|
|
//| TRIPLE-BARRIER outcome for each bar. The critical rule the user |
|
|
//| asked for is the confirmation delay - a bar's barrier label is not |
|
|
//| knowable until m_barrierHorizonBars more bars have closed after it |
|
|
//| (that is the vertical barrier itself), so the model must NEVER |
|
|
//| backprop the newest bars against an unresolved outcome, even |
|
|
//| though it happily EMITS a live signal on them. This method |
|
|
//| therefore only ever learns from the "confirmable frontier" and |
|
|
//| older: the newest bar whose now-relative index is |
|
|
//| >= m_barrierHorizonBars. Everything newer than that is inference- |
|
|
//| only until it, too, matures - identical to how training holds its |
|
|
//| recent bars in the OOS holdout and embargoes the boundary band. |
|
|
//| (Was m_swingConfirmationBars, which answered the ZigZag repainting |
|
|
//| question. That is no longer the label's lookahead - see |
|
|
//| m_barrierHorizonBars.) |
|
|
//| |
|
|
//| Mechanism per newly-matured bar (oldest->newest, exactly |
|
|
//| AdvanceOosSimulationChunk()'s predict-score-then-learn step, but |
|
|
//| on the REAL deployed Net): build the same feature window training |
|
|
//| used, feedForward, score the prediction against the confirmed |
|
|
//| label (guardrail EMA), then backProp that label. The deployed |
|
|
//| SHADOW is nudged toward Net by SHADOW_WEIGHT_TAU only while the |
|
|
//| rolling accuracy holds up; if it decays the blend FREEZES (live |
|
|
//| keeps trading the last-good shadow, Net keeps adapting so it can |
|
|
//| recover) - drift can never reach the account. State persists in the |
|
|
//| .stats sidecar so a restart neither re-learns old bars nor skips. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::OnlineLearnStep(void)
|
|
{
|
|
//--- Hard gates. m_inferenceOnly covers BOTH the single backtest and every optimization pass (see
|
|
//--- its declaration comment): in the tester the model is held FIXED, so continual learning is a
|
|
//--- live-chart-only behaviour (forward-test it on a demo account, not the Strategy Tester).
|
|
if(!m_enableOnlineLearning || m_inferenceOnly || m_trainRunActive)
|
|
return;
|
|
if(!m_trainingComplete || m_trainingStopRequested || m_trainingPaused)
|
|
return;
|
|
if(MQLInfoInteger(MQL_TESTER) || MQLInfoInteger(MQL_OPTIMIZATION) || MQLInfoInteger(MQL_FORWARD))
|
|
return; // belt-and-braces: never adapt weights inside any tester context
|
|
if(CheckPointer(Net) == POINTER_INVALID || Net.CpuInference())
|
|
return; // DLL-free inference build has no backend to backprop through
|
|
if(CheckPointer(m_shadowNet) == POINTER_INVALID)
|
|
return; // nothing deployed to blend into yet (RefreshLatestSignal bootstraps it first)
|
|
if(m_outputNeuronsCount != 1 && m_outputNeuronsCount != 3)
|
|
return;
|
|
int conf = MathMax(m_barrierHorizonBars, 1);
|
|
int barsAvail = Bars(m_symbol.Name(), PERIOD_CURRENT);
|
|
//--- Need the frontier bar (now-relative index conf) plus a full feature window BEHIND it, plus a
|
|
//--- little slack so a short catch-up walk stays in-bounds.
|
|
int need = conf + (int)m_historyBars + 2;
|
|
if(barsAvail < need)
|
|
return;
|
|
//--- Load enough history for the frontier window and a bounded catch-up; RefreshConvergedSignal()
|
|
//--- only sized buffers relative to dtStudied (newest bars), which is too shallow to reach the
|
|
//--- confirmation frontier.
|
|
int wantBars = MathMin(need + ONLINE_LEARN_MAX_CATCHUP, barsAvail);
|
|
if(!ResizeBuffers(wantBars) || !RefreshData())
|
|
return;
|
|
//--- Same now-relative invalidation RefreshConvergedSignal() does, and needed independently of it: this
|
|
//--- runs on a DEEPER bar grid (wantBars reaches the confirmation frontier, that one only reaches the
|
|
//--- newest feature window), so the two legitimately disagree about `bars` and each must re-key the
|
|
//--- cache for the grid it is about to read. Stale rows matter more here than anywhere else - this is
|
|
//--- the one path that WRITES to a live, trading model, so a mismatched (features, label) pair is not a
|
|
//--- wrong arrow, it is a wrong weight update. See RefreshConvergedSignal()'s comment for why nothing
|
|
//--- else clears this once training has completed.
|
|
//--- Note the catch-up walk below is unaffected in cost: this fires once per call, before the loop, so
|
|
//--- the overlapping windows inside the loop still share cached rows.
|
|
EnsureBarCachesCapacity(wantBars);
|
|
datetime frontierTime = m_Time.GetData(conf);
|
|
if(frontierTime <= 0)
|
|
return;
|
|
//--- First step of this deployment (or a model that never online-learned): DON'T retroactively
|
|
//--- backfill the whole history through backprop in one shot - that could shift the just-validated
|
|
//--- deployed model materially before any live confirmation. Anchor the watermark at the current
|
|
//--- frontier and begin learning from genuinely new confirmations forward.
|
|
if(m_onlineLearnedUpToTime <= 0)
|
|
{
|
|
m_onlineLearnedUpToTime = frontierTime;
|
|
return;
|
|
}
|
|
if(frontierTime <= m_onlineLearnedUpToTime)
|
|
return; // no bar has matured past the watermark since last time
|
|
//--- Seed the guardrail EMA from the model's deploy-time OOS accuracy the first time we actually
|
|
//--- learn, so the floor is meaningful from the very first update (not a cold 0 that would trip it).
|
|
if(m_onlineRollingAcc < 0.0)
|
|
m_onlineRollingAcc = (dForecast > 0.0 && dForecast <= 100.0) ? dForecast : 100.0;
|
|
//--- Guardrail floor: deploy baseline minus a margin, never below the absolute minimum.
|
|
double baseline = (dForecast > 0.0 && dForecast <= 100.0) ? dForecast : 100.0;
|
|
double accFloor = MathMax(ONLINE_LEARN_MIN_ACC, baseline - ONLINE_LEARN_ACC_MARGIN);
|
|
//--- Find the oldest not-yet-learned confirmed bar: walk from the frontier (index conf) toward older
|
|
//--- bars (increasing index) until we pass the watermark or hit the catch-up cap, then learn newest-
|
|
//--- ward from there so bars are consumed in strict chronological (oldest->newest) order.
|
|
int oldestIdx = conf;
|
|
while(oldestIdx < barsAvail - 1
|
|
&& oldestIdx < conf + ONLINE_LEARN_MAX_CATCHUP
|
|
&& m_Time.GetData(oldestIdx) > m_onlineLearnedUpToTime)
|
|
oldestIdx++;
|
|
//--- oldestIdx now points at the first bar whose time is <= watermark (already learned) or the cap;
|
|
//--- the newest UNLEARNED bar is one step newer (idx-1). Learn from idx = oldestIdx-1 down to conf.
|
|
//--- Pin the learning rate for the duration of this walk and restore it after: `eta` is a GLOBAL
|
|
//--- shared by every signal instance, so leaving it modified would corrupt another model's training
|
|
//--- chunk - see ONLINE_LEARN_ETA_SCALE's comment.
|
|
double savedEta = eta;
|
|
eta = m_modelEta * ONLINE_LEARN_ETA_SCALE;
|
|
int learned = 0;
|
|
for(int idx = oldestIdx - 1; idx >= conf; idx--)
|
|
{
|
|
datetime bt = m_Time.GetData(idx);
|
|
if(bt <= m_onlineLearnedUpToTime)
|
|
continue; // already learned (defensive; the walk above should exclude it)
|
|
//--- Build this bar's feature window - IDENTICAL to Train()/RefreshLatestSignal(): ends AT bar idx
|
|
//--- and extends m_historyBars into the past. No lookahead (all bars are older than idx).
|
|
//--- "Identical" is now enforced rather than asserted - all three go through BuildFeatureWindow().
|
|
if(!BuildFeatureWindow(idx))
|
|
{
|
|
//--- window not buildable this bar (e.g. an indicator hole) - advance the watermark past it so
|
|
//--- we don't wedge re-trying the same bar forever, but learn nothing from it.
|
|
m_onlineLearnedUpToTime = bt;
|
|
continue;
|
|
}
|
|
//--- Predict with the CURRENT (pre-update) weights, then score against the confirmed label for the
|
|
//--- rolling guardrail - exactly AdvanceOosSimulationChunk()'s predict-before-learn measurement.
|
|
Net.feedForward(TempData);
|
|
Net.getResults(TempData);
|
|
double predSignal = (m_outputNeuronsCount == 3) ? ApplyClassificationSoftmax() : TempData[0];
|
|
//--- Same target rule training used. `idx` is at or beyond the confirmation frontier (conf ==
|
|
//--- m_barrierHorizonBars, enforced above), so the forward window this reads is fully closed.
|
|
ENUM_SIGNAL trueSignal = TripleBarrierLabel(idx);
|
|
bool hit = (DoubleToSignal(predSignal) == trueSignal);
|
|
m_onlineRollingAcc += (100.0 * (hit ? 1.0 : 0.0) - m_onlineRollingAcc) / ONLINE_ACC_SMOOTH;
|
|
//--- Per-class softmax probabilities as of THIS bar's pre-update prediction. Must be read here,
|
|
//--- before TempData is rebuilt as the target vector below - ApplyClassificationSoftmax() has
|
|
//--- already normalised TempData[0..2] in place into a genuine distribution (same contract pass 2
|
|
//--- relies on for its own focal term).
|
|
double pBuy = (TempData.Total() > 0) ? TempData.At(0) : 0.0;
|
|
double pSell = (TempData.Total() > 1) ? TempData.At(1) : 0.0;
|
|
double pNeutral = (TempData.Total() > 2) ? TempData.At(2) : 0.0;
|
|
//--- Build the target vector - identical encoding to Train()/AdvanceOosSimulationChunk().
|
|
bool buy = (trueSignal == Buy);
|
|
bool sell = (trueSignal == Sell);
|
|
TempData.Clear();
|
|
if(m_outputNeuronsCount == 1)
|
|
TempData.Add(buy && !sell ? 1 : (!buy && sell ? -1 : 0));
|
|
else
|
|
{
|
|
TempData.Add(buy ? LABEL_SMOOTH_HIGH : LABEL_SMOOTH_LOW);
|
|
TempData.Add(sell ? LABEL_SMOOTH_HIGH : LABEL_SMOOTH_LOW);
|
|
TempData.Add((!buy && !sell) ? LABEL_SMOOTH_HIGH : LABEL_SMOOTH_LOW);
|
|
}
|
|
Net.backProp(TempData, OnlineSampleWeight(trueSignal, pBuy, pSell, pNeutral));
|
|
m_onlineSamples++;
|
|
//--- Deploy the improvement ONLY while accuracy holds. Warmup: allow the first few blends (the
|
|
//--- model was just validated at deploy, steps are tiny) until the EMA has enough samples to judge.
|
|
bool blendOk = (m_onlineSamples <= ONLINE_LEARN_WARMUP) || (m_onlineRollingAcc >= accFloor);
|
|
if(blendOk)
|
|
{
|
|
m_shadowNet.BlendWeightsFrom(Net, SHADOW_WEIGHT_TAU);
|
|
if(m_onlineBlendFrozen)
|
|
{
|
|
m_onlineBlendFrozen = false;
|
|
Print(ID + ": online-learning deployment RESUMED - rolling accuracy recovered to "
|
|
+ DoubleToString(m_onlineRollingAcc, 1) + "% (floor " + DoubleToString(accFloor, 1) + "%)");
|
|
}
|
|
}
|
|
else if(!m_onlineBlendFrozen)
|
|
{
|
|
m_onlineBlendFrozen = true;
|
|
Print(ID + ": online-learning deployment FROZEN - rolling accuracy " + DoubleToString(m_onlineRollingAcc, 1)
|
|
+ "% fell below floor " + DoubleToString(accFloor, 1) + "%; live keeps trading the last-good model while it adapts");
|
|
}
|
|
m_onlineLearnedUpToTime = bt;
|
|
learned++;
|
|
m_onlineBarsSincePersist++;
|
|
}
|
|
//--- Hand the shared global back exactly as found, on BOTH exit paths below - see the matching
|
|
//--- savedEta assignment above for why this must not leak out of this function.
|
|
eta = savedEta;
|
|
if(learned <= 0)
|
|
return;
|
|
//--- Periodic durable persistence so a crash loses at most ONLINE_LEARN_PERSIST_EVERY bars of
|
|
//--- adaptation (shutdown also persists via PersistOnShutdown()).
|
|
if(m_onlineBarsSincePersist >= ONLINE_LEARN_PERSIST_EVERY)
|
|
{
|
|
double ip[];
|
|
m_indicatorTuner.Flatten(ip);
|
|
bool saveOk = Net.Save(m_activeFileName + ".nnw", dError, dUndefine, dForecast, dtStudied, m_activeFileCommon, m_eraCount, m_trainingComplete, ip);
|
|
if(!saveOk)
|
|
Print(ID + ": ERROR - online-learning Net.Save failed for " + m_activeFileName + ".nnw. Retrying next persist interval instead of resetting the bars-since-persist counter.");
|
|
SaveShadowNet(ip);
|
|
if(!SaveModelStats(m_activeFileName, m_activeFileCommon))
|
|
Print(ID + ": ERROR - online-learning SaveModelStats failed for " + m_activeFileName + ".");
|
|
// Only reset the counter on a successful weight save - resetting unconditionally on a
|
|
// transient failure would silently double the effective data-loss window on the NEXT failure too.
|
|
if(saveOk)
|
|
{
|
|
m_onlineBarsSincePersist = 0;
|
|
PrintVerbose(ID + ": online-learning checkpoint saved (" + IntegerToString((int)m_onlineSamples)
|
|
+ " total updates, rolling acc " + DoubleToString(m_onlineRollingAcc, 1) + "%)");
|
|
}
|
|
}
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::SaveShadowNet(const double &indicatorParams[])
|
|
{
|
|
if(CheckPointer(m_shadowNet) == POINTER_INVALID)
|
|
return;
|
|
m_shadowNet.Save(m_activeFileName + "_shadow.nnw", dError, dUndefine, dForecast, dtStudied, m_activeFileCommon, m_eraCount, m_trainingComplete, indicatorParams);
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::EnsureShadowNet(void)
|
|
{
|
|
if(CheckPointer(m_shadowNet) != POINTER_INVALID)
|
|
return;
|
|
//--- Pure-MQL5 inference (DLL-free backtest): no era blending happens, so the shadow would just be a
|
|
//--- copy of Net - and bootstrapping one via Save/Load would spin a compute backend up on the clone,
|
|
//--- defeating the DLL-free goal. Skip it; RefreshLatestSignal() falls back to Net directly.
|
|
if(CheckPointer(Net) != POINTER_INVALID && Net.CpuInference())
|
|
return;
|
|
string shadowFile = m_activeFileName + "_shadow.nnw";
|
|
if(FileIsExist(shadowFile, m_activeFileCommon ? FILE_COMMON : 0))
|
|
{
|
|
CNet *loaded = new CNet(NULL);
|
|
if(CheckPointer(loaded) != POINTER_INVALID)
|
|
{
|
|
double loadE, loadU, loadF;
|
|
datetime loadTime;
|
|
long loadEra;
|
|
bool loadComplete;
|
|
double loadIp[];
|
|
if(loaded.Load(shadowFile, loadE, loadU, loadF, loadTime, m_activeFileCommon, loadEra, loadComplete, loadIp, true /*quiet: a miss just falls through to the clone bootstrap below*/))
|
|
{
|
|
m_shadowNet = loaded;
|
|
//--- This second CNet spins up its OWN compute backend, so on a fresh attach the log shows a
|
|
//--- second backend-init block right after the main model's. Name it here (verbose) so it
|
|
//--- reads as "the shadow net came up" rather than "the EA started twice".
|
|
PrintVerbose(ID + ": EMA shadow net restored from " + shadowFile + " (its own network instance - hence a second compute-backend init)");
|
|
return;
|
|
}
|
|
delete loaded;
|
|
}
|
|
}
|
|
//--- No compatible persisted shadow - bootstrap from Net's current weights. Clone via the full
|
|
//--- Save()/Load() pair - see StartOosContinualSimulation()'s matching comment for why a lighter
|
|
//--- restore is not safe here (it would need opencl/directml already initialized on the target
|
|
//--- CNet, which a bare "new CNet(NULL)" does not have).
|
|
if(CheckPointer(Net) == POINTER_INVALID)
|
|
return;
|
|
//--- Attempt the clone bootstrap at most once per topology (see m_shadowBootstrapAttempted). On the
|
|
//--- tester's CPU-DLL fallback a second full-net clone can fail to load; retrying every bar would
|
|
//--- rebuild the compute backend each tick and crawl. Falling back to Net is correct and lossless here.
|
|
if(m_shadowBootstrapAttempted)
|
|
return;
|
|
m_shadowBootstrapAttempted = true;
|
|
//--- Co-locate the ephemeral clone temp with the active model (COMMON on a live chart, LOCAL in the
|
|
//--- tester sandbox) instead of always LOCAL.
|
|
string cloneFile = m_activeFileName + "_shadowclone.tmp";
|
|
int cloneFlags = m_activeFileCommon ? FILE_COMMON : 0;
|
|
double ip[];
|
|
if(!Net.Save(cloneFile, 0.0, 0.0, 0.0, dtStudied, m_activeFileCommon, m_eraCount, m_trainingComplete, ip))
|
|
return;
|
|
CNet *clone = new CNet(NULL);
|
|
if(CheckPointer(clone) == POINTER_INVALID)
|
|
{
|
|
FileDelete(cloneFile, cloneFlags);
|
|
return;
|
|
}
|
|
double loadE, loadU, loadF;
|
|
datetime loadTime;
|
|
long loadEra;
|
|
bool loadComplete;
|
|
double loadIp[];
|
|
bool loaded = clone.Load(cloneFile, loadE, loadU, loadF, loadTime, m_activeFileCommon, loadEra, loadComplete, loadIp, true /*quiet: best-effort clone, the miss is handled gracefully below*/);
|
|
FileDelete(cloneFile, cloneFlags);
|
|
if(!loaded)
|
|
{
|
|
delete clone;
|
|
//--- Best-effort: without a shadow, live signals read the main Net directly (RefreshLatestSignal's
|
|
//--- deployNet fallback), which is correct and lossless - so one calm line, not an error.
|
|
//--- This used to be described as EXPECTED on a CPU-DLL box ("can't allocate a 2nd net"). It is not:
|
|
//--- the clone load was failing for the same reason the MAIN model load was - CLayer::CreateElement
|
|
//--- had stopped overriding CArrayObj::CreateElement, so every CNet::Load failed at layer 0
|
|
//--- regardless of backend (see AI\Network.mqh). With that fixed this path should be rare; if it
|
|
//--- shows up repeatedly, investigate rather than assume a hardware limit.
|
|
PrintVerbose(ID + ": EMA shadow net could not be bootstrapped - live signals use the main model directly (lossless fallback).");
|
|
return;
|
|
}
|
|
m_shadowNet = clone;
|
|
//--- See the matching note on the restore path above: a second CNet means a second compute-backend init
|
|
//--- in the log, which is expected, not a duplicated EA.
|
|
PrintVerbose(ID + ": EMA shadow net bootstrapped from the main model's current weights (its own network instance - hence a second compute-backend init)");
|
|
}
|
|
#endif // WARRIOR_AIBASE_ONLINELEARNING_MQH
|