forked from animatedread/Warrior_EA
1dda479 clamped the training sweep. It left five other paths asking the indicators
for a depth they cannot serve, and on a live account the quiet ones are worse than
the stall was - a stalled chart is visible, a chart trading on a degraded feature
window is not.
ServableBars(want, context) is now the single gate, and all six go through it:
training sweep clamp, floored at TRAIN_MIN_CLAMPED_BARS (below that a small
positive BarsCalculated is warm-up, which m_coldSweepTick owns)
label prebuild clamp - labels come from price/ADZigZag and would survive a
capped MA, but ResizeBuffers sizes EVERY buffer and a failed
CopyBuffer leaves m_MA EMPTY for the next reader, so this path
could silently re-break the block Train()'s clamp just fixed
live inference HOLD. Below `need` the swing block takes its degraded path and
inference runs on a different feature distribution than the model
was fitted on. This EA sizes real positions off that output, so
no signal beats a mismatched one
online learning HOLD, same reason and worse - this path WRITES to a live trading
model, so a mismatched (features, label) pair is not a wrong arrow,
it is a wrong weight update that compounds every bar
chart rescan clamp - SIGNAL_RESCAN_LOOKBACK_BARS is 5000 and MT5's smallest
"Max bars in chart" is also 5000, so this one is genuinely
reachable; uncapped it repaints the window all-Neutral
research export clamp before the emptiness test, so a capped symbol exports the
depth it has rather than writing a CSV with a dead feature block -
an artefact that looks complete and is silently wrong
Both HOLDs are insurance, not expected states: `need` tops out near 1,152 bars
(16 + 750 + 384 + 2) against a 5,000 floor on the terminal setting. They exist so
the failure mode is unreachable rather than merely unlikely.
Not changed: a genuinely SHORT price history still takes the old degraded path at
every site. That is pre-existing behaviour and narrowing it would mute charts that
trade today, so it stays a separate decision rather than a side effect of this fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
682 lines
40 KiB
MQL5
682 lines
40 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) + "%");
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Arms the one-shot pattern-database backfill (see the declaration |
|
|
//| comment). Called right after FinalizeTrainRun() has restored the |
|
|
//| DEPLOYED checkpoint, so the walk below scores with the exact |
|
|
//| weights that are about to trade live - not the last era's, which |
|
|
//| the plateau ladder may have superseded. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::StartPatternDatabaseBackfill(int bars, int totalIter, int oosCutoff)
|
|
{
|
|
if(m_dbBackfillDone || m_dbBackfillActive)
|
|
return;
|
|
if(!UseDatabaseRanking || MQLInfoInteger(MQL_TESTER) || MQLInfoInteger(MQL_OPTIMIZATION) || MQLInfoInteger(MQL_FORWARD))
|
|
return;
|
|
if(GetFilterID() == "NULL" || oosCutoff <= 0 || CheckPointer(Net) == POINTER_INVALID)
|
|
return;
|
|
//--- READS THE CALIBRATION BAND - never the window pass 3 grades. The deployed checkpoint is CHOSEN
|
|
//--- as the best-scoring era on the OOS window, so win rates measured back over that window are
|
|
//--- inflated by the selection, and these rows become filter weights: the selection set consumed
|
|
//--- twice, beside a deploy gate that applies a family-wise correction for exactly that effect.
|
|
//---
|
|
//--- The calibration band already has every property that needs (see the layout map on
|
|
//--- CalibPurgeBars): never trained on, never graded by pass 3 - which walks [0, oosCutoff) and so
|
|
//--- never reaches it - never seen by the deploy gate, and purged by a full label horizon on BOTH
|
|
//--- sides. A reserved slice carved out of the OOS window was tried for a few hours on 2026-08-16
|
|
//--- and removed: it bought the same property at the cost of 20% of the gate's sample, and in its
|
|
//--- first placement it also blanked ~10 months of chart arrows, because arrows are only ever drawn
|
|
//--- on bars pass 3 grades. This band costs the gate nothing and is larger besides.
|
|
//---
|
|
//--- One acknowledged impurity, and it is small: m_dirConfThreshold is FITTED on this band, and the
|
|
//--- walk applies that threshold when deciding which bars fired - so coverage here is mildly
|
|
//--- optimistic. That is one scalar fitted under a coverage floor, against checkpoint selection
|
|
//--- across hundreds of eras. Stated rather than hidden; see the completion log line.
|
|
int calibLo = CalibLoIndex(oosCutoff);
|
|
int calibHi = CalibHiIndex(totalIter, oosCutoff);
|
|
if(CalibBandBars(totalIter, oosCutoff) <= 0 || calibHi <= calibLo)
|
|
{
|
|
m_dbBackfillDone = true;
|
|
Print(ID + ": pattern-database backfill SKIPPED - this era carved no calibration band (study"
|
|
" window too short for OOS + two " + IntegerToString(CalibPurgeBars()) + "-bar purges + a"
|
|
" band). Filter weights will build from real fills instead. Lengthen the study period or"
|
|
" lower the OOS split % to enable it.");
|
|
return;
|
|
}
|
|
//--- ONE-SHOT ACROSS ATTACHES, not merely across this object's lifetime. m_dbBackfillDone is an
|
|
//--- in-memory flag, so every later attach that trains this configuration through to convergence
|
|
//--- again would walk the same OOS bars and write a second full set of rows - RegisterSignal()
|
|
//--- (Expert\ExpertSignalCustom.mqh) inserts unconditionally, with no key and no duplicate check.
|
|
//--- The ranking would then be counting the SAME bar several times, once per model that ever
|
|
//--- deployed here, weighting a superseded model's opinion exactly as heavily as the live one's.
|
|
//--- The marker stamps the era whose weights were used, so a redeploy of the same era is skipped
|
|
//--- and a genuinely retrained model (different era) is allowed through; reset-weights deletes it
|
|
//--- alongside the other sidecars (see LoadAndCompareTopologyConfiguration's discard block).
|
|
long deployedEra = (m_ensembleMember && g_ensBestEra >= 0) ? g_ensBestEra : (long)m_eraCount;
|
|
int markerFlags = m_activeFileCommon ? FILE_COMMON : 0;
|
|
string markerFile = m_activeFileName + ".dbfill";
|
|
if(FileIsExist(markerFile, markerFlags))
|
|
{
|
|
//--- FILE_SHARE_READ|FILE_SHARE_WRITE on every open, without exception - a sibling chart holding
|
|
//--- this file open must not turn a skip-check into a hard failure (see the optimizer-cache
|
|
//--- corruption this rule came from).
|
|
int mh = FileOpen(markerFile, markerFlags | FILE_TXT | FILE_READ | FILE_SHARE_READ | FILE_SHARE_WRITE);
|
|
if(mh != INVALID_HANDLE)
|
|
{
|
|
long stampedEra = StringToInteger(FileReadString(mh));
|
|
FileClose(mh);
|
|
if(stampedEra == deployedEra)
|
|
{
|
|
m_dbBackfillDone = true;
|
|
PrintVerbose(ID + ": pattern-database backfill already done for era " +
|
|
IntegerToString((int)deployedEra) + " - skipping (its rows are still in the DB).");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
m_dbBackfillEra = deployedEra;
|
|
dbm.OpenDatabase();
|
|
//--- Frozen batch-norm statistics, exactly like pass 3's OOS scoring walk - see its comment for why
|
|
//--- an unfrozen forward pass would let the running stats drift while scoring.
|
|
Net.SetBatchNormFrozen(true);
|
|
m_dbBackfillBars = bars;
|
|
//--- Walks [calibLo, calibHi) from its OLDEST bar down to its newest - i.e. oldest -> newest in
|
|
//--- TIME, which is the order ProcessSignal's outdated-row guard requires. Both ends are clamped:
|
|
//--- the start against the feature-window bound, the stop against 2, so a degenerate band can only
|
|
//--- ever produce an empty walk, never one that wanders into the bars pass 3 grades.
|
|
m_dbBackfillStartIndex = (int)MathMin(calibHi - 1, bars - MathMax(m_historyBars, 0) - 2);
|
|
m_dbBackfillStopIndex = (int)MathMax(2, calibLo);
|
|
m_dbBackfillIndex = m_dbBackfillStartIndex;
|
|
m_dbBackfillFired = 0;
|
|
m_dbBackfillActive = (m_dbBackfillStartIndex >= m_dbBackfillStopIndex);
|
|
if(!m_dbBackfillActive)
|
|
m_dbBackfillDone = true; // OOS window too short to walk - nothing to backfill, don't retry forever
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Time-boxed slice of the backfill walk - same chunking doctrine as |
|
|
//| every other long walk in this file (AdvanceOosSimulationChunk, |
|
|
//| AdvanceChartSignalRescan): a real forward pass per bar is genuine |
|
|
//| compute, so this yields on a wall-clock budget rather than running |
|
|
//| the whole OOS window in one call. Walks OLDEST -> NEWEST (mirrors |
|
|
//| pass 3's own m_oosScoreIndex descent) because ProcessSignal()'s |
|
|
//| outdated-row guard rejects a registration OLDER than a row its |
|
|
//| table already holds - inserting newest-first would have every |
|
|
//| older row rejected the instant the first one landed. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::AdvancePatternDatabaseBackfill(void)
|
|
{
|
|
const uint DB_BACKFILL_TIME_BUDGET_MS = 80;
|
|
uint chunkStartTick = GetTickCount();
|
|
dbm.BeginTransaction();
|
|
//--- ConfidenceTier() reads the live dPrevSignal field (the panel/RefreshLatestSignal's source of
|
|
//--- truth) - borrowed per bar below to get the SAME tier bucketing a live vote would have used,
|
|
//--- then restored so this backfill walk never leaks into the live-facing signal.
|
|
double savedPrevSignal = dPrevSignal;
|
|
for(; m_dbBackfillIndex >= m_dbBackfillStopIndex; m_dbBackfillIndex--)
|
|
{
|
|
if(GetTickCount() - chunkStartTick >= DB_BACKFILL_TIME_BUDGET_MS)
|
|
break;
|
|
int oi = m_dbBackfillIndex;
|
|
if(!(oi < (int)(m_dbBackfillBars - MathMax(m_historyBars, 0) - 1) &&
|
|
oi < ArraySize(m_labelCacheHasValue) && m_labelCacheHasValue[oi]))
|
|
continue;
|
|
if(!BuildFeatureWindow(oi) || !Net.feedForward(TempData))
|
|
continue;
|
|
Net.getResults(TempData);
|
|
double oSignal = (m_outputNeuronsCount == 3) ? ApplyClassificationSoftmax() : TempData[0];
|
|
double oDeploySignal = (m_outputNeuronsCount == 3) ? AdjustedSignalFromSoftmax() : oSignal;
|
|
ENUM_SIGNAL dir = DoubleToSignal(oDeploySignal);
|
|
if(dir != Buy && dir != Sell)
|
|
continue; // Neutral/abstained - live voting would not have buffered a row for this bar either
|
|
dPrevSignal = oDeploySignal;
|
|
int tier = ConfidenceTier();
|
|
bool winLong = (oi < ArraySize(m_winLongCache)) ? m_winLongCache[oi] : false;
|
|
bool winShort = (oi < ArraySize(m_winShortCache)) ? m_winShortCache[oi] : false;
|
|
bool tradeWon = (dir == Buy) ? winLong : winShort;
|
|
double atr = m_ATR.Main(oi);
|
|
double closeAt = m_Close.GetData(oi);
|
|
if(!MathIsValidNumber(atr) || atr <= 0.0 || !MathIsValidNumber(closeAt) || closeAt <= 0.0)
|
|
continue;
|
|
//--- Same fill/exit convention TripleBarrierLabel() uses: long fills at close+spread and its
|
|
//--- target/stop are entry+reward/entry-risk; short fills at close and mirrors the two.
|
|
double spread = (double)m_symbol.Spread() * m_symbol.Point();
|
|
double slMult, tpMult;
|
|
BarrierMultiples(slMult, tpMult);
|
|
double entryPrice, exitPrice;
|
|
if(dir == Buy)
|
|
{
|
|
entryPrice = closeAt + spread;
|
|
exitPrice = tradeWon ? entryPrice + tpMult * atr : entryPrice - slMult * atr;
|
|
}
|
|
else
|
|
{
|
|
entryPrice = closeAt;
|
|
exitPrice = tradeWon ? entryPrice - tpMult * atr : entryPrice + slMult * atr;
|
|
}
|
|
MqlDateTime t;
|
|
TimeToStruct(m_Time.GetData(oi), t);
|
|
string pattern = "Pattern_" + IntegerToString(tier);
|
|
string dirStr = (dir == Buy) ? "Buy" : "Sell";
|
|
string tableName = PatternTableName(GetFilterID(), pattern, dirStr);
|
|
double netVote = (dir == Buy) ? PatternWeightForTier(tier) : -PatternWeightForTier(tier);
|
|
RegisterSignal(t.year, t.mon, t.day, t.day_of_week, t.hour, t.min, tableName, pattern, dirStr,
|
|
entryPrice, exitPrice, tradeWon ? "Profit" : "Loss", netVote);
|
|
m_dbBackfillFired++;
|
|
}
|
|
dPrevSignal = savedPrevSignal;
|
|
dbm.CommitTransaction();
|
|
if(m_dbBackfillIndex >= m_dbBackfillStopIndex)
|
|
return; // more slices to come
|
|
Net.SetBatchNormFrozen(false);
|
|
m_dbBackfillActive = false;
|
|
m_dbBackfillDone = true;
|
|
g_forcePatternWeightsRefresh = true;
|
|
//--- Stamp the marker only now, on completion: a walk interrupted half way (EA removed mid-chunk)
|
|
//--- leaves NO marker, so the next attach redoes it in full rather than ranking on a partial window.
|
|
//--- The duplicate rows that costs are the lesser error - a half-filled table is silently biased
|
|
//--- toward whichever end of the OOS window happened to finish.
|
|
{
|
|
int markerFlags = m_activeFileCommon ? FILE_COMMON : 0;
|
|
int mh = FileOpen(m_activeFileName + ".dbfill",
|
|
markerFlags | FILE_TXT | FILE_WRITE | FILE_SHARE_READ | FILE_SHARE_WRITE);
|
|
if(mh != INVALID_HANDLE)
|
|
{
|
|
FileWriteString(mh, IntegerToString((int)m_dbBackfillEra));
|
|
FileClose(mh);
|
|
}
|
|
}
|
|
Print(ID + ": pattern database backfilled from " + IntegerToString(m_dbBackfillFired) + " calls on the"
|
|
" held-out CALIBRATION band (bars " + IntegerToString(m_dbBackfillStopIndex) + ".." +
|
|
IntegerToString(m_dbBackfillStartIndex) + ", era " + IntegerToString((int)m_dbBackfillEra) +
|
|
") - this IS the deploy-time warm-up: it runs with the weights FinalizeTrainRun just restored,"
|
|
" so the per-pattern win-rate history describes exactly what is about to trade and no separate"
|
|
" backtest is needed first. Those bars were never trained on, never graded by pass 3 and never"
|
|
" seen by the deploy gate. Two honest caveats: they are SIMULATED triple-barrier outcomes at"
|
|
" today's spread rather than realised fills, and m_dirConfThreshold was fitted on this same"
|
|
" band, so coverage here is mildly optimistic. Small tiers are shrunk toward the pooled rate"
|
|
" before they become weights (see WinRateFromCounts).");
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| 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;
|
|
//--- Meta target: online continual learning is direction-shaped (3-slot targets, bar labels) and
|
|
//--- the meta head's live path does not exist until S3 - hold the model fixed.
|
|
if(IsMetaTarget())
|
|
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);
|
|
//--- HOLD RATHER THAN LEARN ON A SHORT WINDOW. `need` is the minimum that reaches the confirmation
|
|
//--- frontier WITH a full feature window behind it; below it the swing block silently degrades and
|
|
//--- the features stop matching the ones the model was fitted on. Everywhere else that is a wrong
|
|
//--- arrow - here it is a wrong WEIGHT UPDATE applied to a live, trading model, and it compounds
|
|
//--- every bar. The catch-up slack on top of `need` is optional and may be clamped away; `need`
|
|
//--- itself is not, so a cap that eats into it stops the update instead of corrupting it.
|
|
int servable = ServableBars(wantBars, "online learning");
|
|
if(servable < need)
|
|
return;
|
|
wantBars = servable;
|
|
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
|