Warrior_EA/Expert/AIBase/Labels.mqh

327 行
18 KiB
MQL5

refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
//+------------------------------------------------------------------+
//| Warrior_EA |
//| AnimateDread |
//| |
//| ZigZag pivot labelling and the async label-cache prebuild. |
//| |
//| 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_LABELS_MQH
#define WARRIOR_AIBASE_LABELS_MQH
//+------------------------------------------------------------------+
//| (Re)sizes the label AND feature caches and clears them if `bars` |
//| (or the now-relative index frame) has changed since the last |
//| build - see the member declaration comments for why this is the |
//| correct invalidation trigger. Returns true if a rebuild happened. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::EnsureBarCachesCapacity(int bars)
{
if(bars == m_labelCacheBars && m_Time.GetData(0) == m_labelCacheAnchorTime)
return false;
ArrayResize(m_labelCacheBuy, bars);
ArrayResize(m_labelCacheSell, bars);
ArrayResize(m_labelCacheHasValue, bars);
ArrayInitialize(m_labelCacheHasValue, false);
ArrayResize(m_featureCache, bars * m_neuronsCount);
ArrayResize(m_featureCacheHasValue, bars);
ArrayResize(m_featureCacheValid, bars);
ArrayInitialize(m_featureCacheHasValue, false);
m_labelCacheBars = bars;
m_labelCacheAnchorTime = m_Time.GetData(0);
return true;
}
//+------------------------------------------------------------------+
//| Lazy cache-miss fallback for a bar the eager prebuild pass (see |
//| AdvanceZigZagLabelState()) didn't cover - e.g. a new candle that |
//| closed after prebuild already completed. A genuine ZigZag pivot |
//| can only be confirmed once price has deviated enough FROM it on a |
//| LATER bar (see AdvanceZigZagLabelState()), which for a bar this |
//| close to "now" may not have happened yet - exactly like a live |
//| ZigZag indicator's most recent leg is always provisional/ |
//| repainting until confirmed. Rather than guess, this always labels |
//| Neutral; the sequential prebuild scan is what actually assigns |
//| Buy/Sell once there's enough subsequent price action to know. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ComputeLabelForBar(int i, int bars, bool &buy, bool &sell)
{
buy = false;
sell = false;
}
//+------------------------------------------------------------------+
//| Confirms (or Neutral-labels) whichever candidate bar is exactly |
//| m_swingConfirmationBars bars behind the one currently being |
//| visited, by reading the real ADZigZag indicator's own verdict on |
//| it directly - see m_swingConfirmationBars' declaration comment |
//| for why that delay exists (a ZigZag's recent legs can repaint). |
//| Buy = candidate bar is a confirmed bottom (ADZigZagBuffer == its |
//| own Low), Sell = confirmed top (== its own High), else Neutral - |
//| this is the "will the next candle be the ZigZag reversal" label |
//| itself, at whatever now-relative index the feature window ends |
//| at (unchanged from the old fractal-based labeling's index |
//| convention - see BufferTempData()'s callers for how a training |
//| example's feature window and its label index line up). |
//| MUST be called in strict oldest-to-newest order (decreasing |
//| now-relative index i) - AdvanceLabelCachePrebuild() (the only |
//| caller) already guarantees this, though unlike the old fractal |
//| scan this replaced, no state actually carries between calls here. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::AdvanceZigZagLabelState(int i, int bars)
{
int idx = i + MathMax(m_swingConfirmationBars, 1);
if(idx >= bars || m_labelCacheHasValue[idx])
return;
bool buy = false, sell = false;
double zz = m_ADZigZag.GetData(0, idx);
if(zz != 0.0)
{
double hi = m_High.GetData(idx);
double lo = m_Low.GetData(idx);
if(zz >= hi - _Point)
sell = true; // confirmed top -> Sell
else if(zz <= lo + _Point)
buy = true; // confirmed bottom -> Buy
}
m_labelCacheBuy[idx] = buy;
m_labelCacheSell[idx] = sell;
m_labelCacheHasValue[idx] = true;
}
//+------------------------------------------------------------------+
//| Nearest confirmed ZigZag pivot at fromIdx or older (now-relative |
//| index, so "older" means scanning with INCREASING p - see this |
//| file's now-relative-index convention, same as AdvanceZigZagLabel- |
//| State() above). Capped at SWING_SCAN_CAP_BARS so a long quiet |
//| stretch with no qualifying pivot can't turn this into an unbounded |
//| scan; returns false (no pivot found) rather than looping forever |
//| if the cap is hit or history runs out first. |
//| |
//| Caller's responsibility, not this method's: applying the |
//| m_swingConfirmationBars repainting embargo to fromIdx before |
//| calling. This method itself just finds the nearest nonzero |
//| ADZigZag buffer entry at/after whatever index it's given - it has |
//| no opinion on whether that index is safe to read yet. The ONE |
//| caller that needs the embargo (BufferTempDataCompute()'s |
//| m_useSwingContext block, looking up "the pivot as of THIS bar") |
//| applies it before the first call; the second call in that same |
//| block (finding the PRIOR completed leg, starting from pivotIdx+1) |
//| doesn't need to re-apply it - anything at or before an already- |
//| confirmed pivot is necessarily even older, hence already confirmed |
//| too. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::FindConfirmedZigZagPivot(int fromIdx, int &pivotIdx, double &pivotPrice, bool &pivotIsLow)
{
for(int p = MathMax(fromIdx, 0); p < fromIdx + SWING_SCAN_CAP_BARS; p++)
{
if(m_Open.GetData(p) == EMPTY_VALUE)
return false; // ran off the end of available history
double zz = m_ADZigZag.GetData(0, p);
if(zz == 0.0)
continue;
pivotIdx = p;
pivotPrice = zz;
pivotIsLow = (zz <= m_Low.GetData(p) + _Point);
return true;
}
return false;
}
//+------------------------------------------------------------------+
//| Kicks off the one-time eager label-cache pre-build for a fresh |
//| start (see m_labelCachePrebuilt's declaration comment). Computes |
//| the bar count/OOS split exactly as Train()'s era-start block |
//| would, then arms AdvanceLabelCachePrebuild() to do the actual |
//| chunked scan on this and subsequent Train() calls. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::StartLabelCachePrebuild(void)
{
int barsNow = (int)MathMin(Bars(m_symbol.Name(), PERIOD_CURRENT, dtStudied, TimeCurrent()) + m_historyBars, Bars(m_symbol.Name(), PERIOD_CURRENT));
if(!ResizeBuffers(barsNow) || !RefreshData())
return; // couldn't prep buffers yet - m_labelCachePrebuilt stays false, retried next call
EnsureBarCachesCapacity(barsNow);
int totalIter = (int)MathMax(barsNow - MathMax(m_historyBars, 0), 0);
m_labelPrebuildBars = barsNow;
m_labelPrebuildOosCutoff = (int)(MathMax(0, MathMin(100, m_oosSplitPct)) / 100.0 * totalIter);
m_labelPrebuildIndex = (int)(barsNow - MathMax(m_historyBars, 0) - 1);
m_labelPrebuildBuyCount = 0;
m_labelPrebuildSellCount = 0;
m_labelPrebuildNeutralCount = 0;
m_labelPrebuildActive = true;
}
//+------------------------------------------------------------------+
//| Advances the eager label-cache pre-build by up to a time budget, |
//| then yields (same chunking pattern as the era loop). Mirrors the |
//| era loop's own labeling eligibility gate (minus the dPrevSignal |
//| check, meaningless pre-first-feedForward). On completion, seeds |
//| m_prevEraTrueBuyCount/Sell/Neutral from the upfront IS-only tally |
//| so era 0 gets real class-balance oversampling instead of reps=1. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::AdvanceLabelCachePrebuild(void)
{
const uint PREBUILD_TIME_BUDGET_MS = 80;
uint chunkStartTick = GetTickCount();
int i;
for(i = m_labelPrebuildIndex; i >= 2; i--)
{
if(GetTickCount() - chunkStartTick >= PREBUILD_TIME_BUDGET_MS)
{
m_labelPrebuildIndex = i;
return;
}
if(!(i < (int)(m_labelPrebuildBars - MathMax(m_historyBars, 0) - 1) && m_Time.GetData(i) > dtStudied))
continue;
// Unlike the old per-bar-independent ComputeLabelForBar(), a proper ZigZag pivot can only be
// confirmed by a LATER (more recent, lower-index) bar than the candidate itself - see
// AdvanceZigZagLabelState()'s declaration comment. So a bar's final Buy/Sell/Neutral label is
// NOT necessarily known the moment we visit it here; it may get retroactively overwritten by a
// later iteration of this same loop. The IS/OOS Buy/Sell/Neutral tally below is therefore done
// in a separate final pass AFTER the whole scan completes, not incrementally per-bar like the
// old cache-hit-or-compute code here used to.
if(!m_labelCacheHasValue[i])
AdvanceZigZagLabelState(i, m_labelPrebuildBars);
}
//--- Sequential scan complete - every bar's own exact-pivot label is final. Widen each confirmed
//--- pivot into its +-LABEL_WINDOW_BARS neighborhood (see that constant's declaration comment)
//--- before the tally pass below, so both the logged "IS true-label distribution" and era 0's
//--- oversampling ratio reflect what the network is actually trained/scored against. Spreads from a
//--- frozen SNAPSHOT of the just-resolved genuine pivots, never from the live m_labelCache* arrays
//--- being written below - reading live here would let a just-written window cell (i-1, i-2, ...)
//--- get visited later in this same decreasing-i pass, look indistinguishable from a genuine pivot,
//--- and re-spread from itself - a single real pivot cascading across the entire array instead of
//--- staying bounded to +-LABEL_WINDOW_BARS (observed in practice: one training run's IS tally came
//--- back Buy 5000 / Sell 4657 / Neutral 8 out of ~9665 bars).
if(LABEL_WINDOW_BARS > 0)
{
bool origBuy[], origSell[];
ArrayCopy(origBuy, m_labelCacheBuy);
ArrayCopy(origSell, m_labelCacheSell);
int maxIdx = m_labelPrebuildBars - 1;
for(i = maxIdx; i >= 2; i--)
{
if(!origBuy[i] && !origSell[i])
continue; // not a genuine confirmed pivot in the original scan - nothing to spread from
bool isBuy = origBuy[i];
for(int w = 1; w <= LABEL_WINDOW_BARS; w++)
{
int lo = i - w, hi = i + w;
// Only claim a neighbor that wasn't ITSELF a genuine pivot in the ORIGINAL snapshot - a
// real pivot (of either class) always keeps its own exact label; overlapping window-bleed
// from two nearby pivots may freely overwrite each other (harmless - neither is ground truth).
if(lo >= 2 && !origBuy[lo] && !origSell[lo])
{
m_labelCacheBuy[lo] = isBuy;
m_labelCacheSell[lo] = !isBuy;
m_labelCacheHasValue[lo] = true;
}
if(hi <= maxIdx && !origBuy[hi] && !origSell[hi])
{
m_labelCacheBuy[hi] = isBuy;
m_labelCacheSell[hi] = !isBuy;
m_labelCacheHasValue[hi] = true;
}
}
}
}
//--- Final tally pass (IS-only, matches isOOS = (i < oosCutoff) used by the era loop) to seed era
//--- 0's oversampling ratio - now over the widened labels, not just the exact pivot bars.
for(i = m_labelPrebuildBars - MathMax(m_historyBars, 0) - 1; i >= MathMax(2, m_labelPrebuildOosCutoff); i--)
{
if(!m_labelCacheHasValue[i])
continue; // e.g. bar was outside the dtStudied/window-edge eligibility gate above
if(m_labelCacheBuy[i])
m_labelPrebuildBuyCount++;
else
if(m_labelCacheSell[i])
m_labelPrebuildSellCount++;
else
m_labelPrebuildNeutralCount++;
}
//--- Prebuild complete - seed era 0's oversampling ratio from the real upfront tally instead of
//--- leaving it at the reps=1 fallback (see m_prevEraTrueBuyCount's declaration comment). Consumed
//--- (and cleared) by Train()'s era-start block on era 0 specifically - see m_prebuildSeedPending.
m_prevEraTrueBuyCount = m_labelPrebuildBuyCount;
m_prevEraTrueSellCount = m_labelPrebuildSellCount;
m_prevEraTrueNeutralCount = m_labelPrebuildNeutralCount;
m_prebuildSeedPending = true;
m_labelCachePrebuilt = true;
m_labelPrebuildActive = false;
//--- Measured-imbalance visibility: the oversampling rate is derived per era from tallies like
//--- these (no hand-tuned rate anywhere - see MAX_OVERSAMPLE_REPLICAS's declaration comment), so
//--- log what this window actually measured and the parity reps it will produce, on any
//--- market/timeframe.
int prebuildMinDir = (int)MathMin(m_labelPrebuildBuyCount, m_labelPrebuildSellCount);
int prebuildMaxCls = (int)MathMax(m_labelPrebuildNeutralCount, MathMax(m_labelPrebuildBuyCount, m_labelPrebuildSellCount));
string prebuildRatioInfo = (prebuildMinDir > 0 && prebuildMaxCls > 0)
? " | measured imbalance ~" + DoubleToString((double)prebuildMaxCls / prebuildMinDir, 1) + ":1 -> reps up to " +
IntegerToString((int)MathMin(MAX_OVERSAMPLE_REPLICAS, MathMax(1, MathRound((double)prebuildMaxCls / prebuildMinDir * m_oversampleParity)))) +
"x (" + DoubleToString(m_oversampleParity * 100.0, 0) + "% parity)"
: " | measured imbalance n/a (a directional class has no labeled bars in this window)";
Print(ID + ": label cache pre-built - IS true-label distribution -> Buy: " + IntegerToString(m_labelPrebuildBuyCount) +
" | Sell: " + IntegerToString(m_labelPrebuildSellCount) + " | Neutral: " + IntegerToString(m_labelPrebuildNeutralCount) +
prebuildRatioInfo +
(m_eraCount == 0 ? " (seeding era 0's class-balance oversampling)"
: " (mid-run rebuild after new-bar cache invalidation - era " + IntegerToString(m_eraCount) + " resumes on the relabeled window)"));
//--- Cold-start fix: a freshly-initialized (random-weight) network's argmax is close to uniform
//--- noise across the 3 classes, so on this typically heavily-skewed label distribution it fires
//--- far more non-majority-class calls at the very start of era 0 than the true base rate warrants,
//--- until enough backProp steps correct it. Now that the real prior is known, push the output
//--- layer's bias toward whichever class actually dominates - only the bias term moves, the
//--- per-input weights stay randomly initialized and still carry the real learning signal. Only
//--- meaningful for the 3-output classification head, and only for a fresh net (this whole prebuild
//--- path is skipped entirely when a trained net was loaded from disk - see m_labelCachePrebuilt).
//--- m_eraCount==0 gate: the prebuild can also re-run MID-run now (new-bar cache invalidation -
//--- see Train()'s era-start wipe check); stomping a partially-trained net's output biases with
//--- +-3.0 cold-start values there would erase real learned calibration, so fresh runs only.
if(m_outputNeuronsCount == 3 && m_eraCount == 0)
{
int dominant = 2; // Neutral
int dominantCount = m_labelPrebuildNeutralCount;
if(m_labelPrebuildBuyCount > dominantCount)
{
dominant = 0;
dominantCount = m_labelPrebuildBuyCount;
}
if(m_labelPrebuildSellCount > dominantCount)
{
dominant = 1;
dominantCount = m_labelPrebuildSellCount;
}
int totalLabeled = m_labelPrebuildBuyCount + m_labelPrebuildSellCount + m_labelPrebuildNeutralCount;
if(totalLabeled > 0 && (double)dominantCount / totalLabeled > 0.40)
{
const double BIAS_MAGNITUDE = 3.0; // sigmoid(+-3) ~= 0.95/0.05 - comfortably outweighs a
// fresh network's random per-input weighted-sum noise
double biasValues[3] = { -BIAS_MAGNITUDE, -BIAS_MAGNITUDE, -BIAS_MAGNITUDE };
biasValues[dominant] = BIAS_MAGNITUDE;
if(Net.SeedOutputLayerBias(biasValues))
PrintVerbose(ID + ": seeded output layer bias toward " + EnumToString((ENUM_SIGNAL)(dominant == 0 ? Buy : dominant == 1 ? Sell : Neutral)) +
" (era 0 cold-start fix)");
}
}
}
//+------------------------------------------------------------------+
//| Confirmed ZigZag label for an already-non-repainting bar - see |
//| the header declaration and AdvanceZigZagLabelState() (identical |
//| verdict). Caller must guarantee idx >= m_swingConfirmationBars so |
//| the leg at idx can no longer repaint; this method does not re- |
//| check that (it has no more information than the raw buffer). |
//+------------------------------------------------------------------+
ENUM_SIGNAL CExpertSignalAIBase::ConfirmedZigZagLabel(int idx)
{
double zz = m_ADZigZag.GetData(0, idx);
if(zz != 0.0)
{
double hi = m_High.GetData(idx);
double lo = m_Low.GetData(idx);
if(zz >= hi - _Point)
return Sell; // confirmed top
if(zz <= lo + _Point)
return Buy; // confirmed bottom
}
return Neutral;
}
#endif // WARRIOR_AIBASE_LABELS_MQH