forked from mnbvc188199/Warrior_EA
User request: "the entry/exit thresholds are manual numbers, I would like
them to be confidence percentages, so the current 20 would be only 20%
confidence in a profitable trade."
WHY 20 WAS EVER SENSIBLE. Under UseDatabaseRanking both factors of a filter's
contribution are win rates: the pattern weight is that pattern's measured win
rate (UpdateSignalsWeights -> ApplyPatternWeight) and m_weight is the filter's
average win rate over its patterns, /100. Dividing the sum by the VOTER COUNT
therefore produced a mean of PRODUCTS of two win rates - a genuinely
60%-accurate filter firing a 60% pattern scored 0.60 x 60 = 36. The number was
never on a probability scale, so its magnitude meant nothing on its own.
Dividing by Sum(m_weight) instead makes it a weighted MEAN of win rates, which
is a win rate: result = Sum(w_i*p_i)/Sum(w_i). Every voter at 60% now reads 60;
MACD's double-divergence pattern (weight 100) voting alone reads 100. m_weight
stops being a discount on the probability and becomes how much a filter's
opinion COUNTS - which is what a module weight should always have been.
Default Min_Vote_Open 20 -> 50: not a tightening, the same bar re-expressed.
ONE SCALE, EVERYWHERE - the part that made this bigger than a rescale. Three
other places compared against a 0..1 softmax confidence and would each have
become a fresh currency mismatch the moment the input changed meaning:
* the AI early-exit route (LiveSignedConfidence vs m_ai_exit_threshold) now
reads m_lastAiVote - the AI filters' own weighted mean, undiluted by the
classic side, which is the only reason that route exists - against the
same m_threshold_close the averaged vote uses. m_ai_exit_threshold is
retired rather than left dangling.
* m_oosDecisionSeries now carries the vote, not the confidence, so the exit
SIMULATION stops modelling a close rule the EA does not run.
* ExitPolicy() clamped anything > 1.0 to zero. Passing the unscaled input
through that would have silently switched vote exits off in the
simulation while live went on running them - found before it shipped;
the bound now tracks the scale.
LiveSignedConfidence() is deliberately untouched and still 0..1: MM sizing,
SL/TP scaling and the intelligent trailing want a model confidence, not a win
rate.
CALIBRATION CAVEAT, stated in the code where the claim is made: this is only a
real probability to the extent the pattern weights are. A pattern with fewer
than MIN_TRADES_FOR_WIN_RATE journaled trades keeps its DEFAULT weight - a
designed prior (25/50/75/100 for the AI tiers), not a measurement. Until the
signal DB fills, "60" means "the designed conviction of the patterns that
fired". Closing that gap is the next commit.
Also corrects VOTE_CLOSE_PRESETS' comment, which documented the two scales
this removes.
NOT COMPILED - user compiles in MetaEditor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2017 lines
123 KiB
MQL5
2017 lines
123 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| Warrior_EA |
|
|
//| AnimateDread |
|
|
//| |
|
|
//| Triple-barrier 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);
|
|
//--- Sized with the label caches they share a validity flag with, so the three can never disagree
|
|
//--- about how many bars they cover.
|
|
ArrayResize(m_excUpCache, bars);
|
|
ArrayResize(m_excDownCache, bars);
|
|
//--- Same lifetime and the same validity flag as the excursion caches beside them - see BARRIER_LADDER.
|
|
ArrayResize(m_ladderUpAt, bars * BARRIER_LADDER_COUNT);
|
|
ArrayResize(m_ladderDownAt, bars * BARRIER_LADDER_COUNT);
|
|
ArrayInitialize(m_ladderUpAt, 0);
|
|
ArrayInitialize(m_ladderDownAt, 0);
|
|
ArrayResize(m_winLongCache, bars);
|
|
ArrayResize(m_winShortCache, bars);
|
|
//--- Sized with the caches and zeroed, so a bar the scan has not reached yet reads as "no opinion"
|
|
//--- (0.0 = abstain) rather than as last era's decision - which would let a stale vote close a trade
|
|
//--- in the simulation that nothing would have closed live.
|
|
ArrayResize(m_oosDecisionSeries, bars);
|
|
ArrayInitialize(m_oosDecisionSeries, 0.0);
|
|
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 |
|
|
//| AdvanceBarrierLabelState()) didn't cover - e.g. a new candle that |
|
|
//| closed after prebuild already completed. Such a bar sits inside |
|
|
//| the unresolved horizon: its triple-barrier outcome needs |
|
|
//| m_barrierHorizonBars more closes before it is knowable at all. |
|
|
//| Rather than guess, this always labels Neutral; the sequential |
|
|
//| prebuild scan is what assigns Buy/Sell once the forward window |
|
|
//| this bar's verdict depends on has actually closed. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::ComputeLabelForBar(int i, int bars, bool &buy, bool &sell)
|
|
{
|
|
buy = false;
|
|
sell = false;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| SL/TP ATR multiples for the triple-barrier label, taken from the |
|
|
//| EA's own SL_Mode/TP_Mode (m_sl_mode/m_tp_mode, protected members |
|
|
//| of CExpertSignalCustom, set in Warrior_EA.mq5's per-topology |
|
|
//| setup block). Using the traded values is the entire point: it is |
|
|
//| what makes the era line's dir-precision a real win rate instead |
|
|
//| of a proxy for one. |
|
|
//| |
|
|
//| The INTELLIGENT modes scale with AI confidence, which does not |
|
|
//| exist when a label is computed - and must not, or the target |
|
|
//| would depend on the model's own output and the whole thing would |
|
|
//| be circular. Both therefore fall back to their ZERO-CONFIDENCE |
|
|
//| base (the trade the EA would place knowing nothing), which is |
|
|
//| also the widest stop and tightest target either mode can pick, so |
|
|
//| the label is the conservative member of the family it stands for. |
|
|
//| TP_INTELLIGENT is risk-relative by design, so its multiple is |
|
|
//| expressed against the resolved stop rather than against ATR. |
|
|
//+------------------------------------------------------------------+
|
|
//+------------------------------------------------------------------+
|
|
//| DERIVE THE BARRIER FROM WHAT PRICE ACTUALLY DOES, not from an |
|
|
//| enum. Reads the measured MFE/MAE distribution collected by the |
|
|
//| label prebuild and sets the ATR multiples from its quantiles. |
|
|
//| |
|
|
//| WHY THIS AND NOT THE GEOMETRY SCAN. The scan ranks candidate SL:TP |
|
|
//| pairings by how predictable their OUTCOME is, which is a question |
|
|
//| about direction - and direction is the one thing measured absent |
|
|
//| here (ASYMMETRY p=0.0846 on SP500 H1, against RANGE/UP/DOWN all at |
|
|
//| p=0.0050). That is why its winner fails its own gate on every run |
|
|
//| and why its "best" wanders 2:8 -> 3:8 -> 2:8 -> 2:4. Excursion |
|
|
//| SIZE, by contrast, clears at 4x its null. So derive the geometry |
|
|
//| from the quantity that is actually measurable. |
|
|
//| |
|
|
//| WHAT THIS DOES NOT DO: create expectancy. Under a driftless walk |
|
|
//| the probability of touching +k*ATR before -m*ATR is m/(m+k), which |
|
|
//| is ALSO the break-even win rate for that payoff - so no choice of |
|
|
//| geometry has an edge, and this one does not either. What it buys |
|
|
//| is a target that is actually reachable inside the horizon and a |
|
|
//| stop wide enough to survive ordinary noise, both read off the |
|
|
//| data instead of guessed. The reachability figures are printed so |
|
|
//| the choice can be audited rather than trusted. |
|
|
//+------------------------------------------------------------------+
|
|
//+------------------------------------------------------------------+
|
|
//| Expectancy of every ladder pair, measured exactly off the |
|
|
//| first-passage cache (see BARRIER_LADDER). |
|
|
//| |
|
|
//| WHY THIS OBJECTIVE. Let the model shift the win probability on the |
|
|
//| bars it selects from the base rate p0 = m/(m+k) to p0 + d. Then |
|
|
//| EV = (p0+d)*k - (1-p0-d)*m = d*(k+m), |
|
|
//| because p0*k - (1-p0)*m is zero by construction. So the stop:target|
|
|
//| RATIO is expectancy-neutral - a punishing break-even is exactly |
|
|
//| repaid by the payoff - and only two things move EV: the real edge |
|
|
//| d, and the TOTAL BARRIER WIDTH (k+m). Width matters because the |
|
|
//| spread is charged once per trade however wide the barriers are, so |
|
|
//| a narrow barrier spends a large share of its own range on costs. |
|
|
//| That is why every row below reports width in SPREADS as well as in |
|
|
//| ATR: it is the cost efficiency of the geometry, and it is knowable |
|
|
//| without knowing d. |
|
|
//| |
|
|
//| WHAT IT DOES NOT DO: measure d. Nothing here can - d is a property |
|
|
//| of the model and the features, not of the barrier - so this cannot |
|
|
//| and must not be read as "this geometry is profitable". It answers |
|
|
//| the narrower question the previous rule never asked: GIVEN an edge,|
|
|
//| which geometry converts the most of it into money, and what does |
|
|
//| each pair cost in spread and in trade frequency. |
|
|
//| |
|
|
//| The base rates are printed beside each break-even deliberately. On |
|
|
//| a driftless walk they coincide; a persistent gap is DRIFT (being |
|
|
//| long pays on an index) and must never be credited to the model - |
|
|
//| see chancePrecPct, which is measured for exactly that reason. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::ReportGeometryExpectancyScan(void)
|
|
{
|
|
int bars = m_labelCacheBars;
|
|
if(bars <= 0 || ArraySize(m_ladderUpAt) < bars * BARRIER_LADDER_COUNT)
|
|
return;
|
|
//--- IS region only, matching DeriveBarrierGeometry and BuildMiSample: a geometry chosen with the
|
|
//--- holdout in view has used the holdout for selection, and it stops being a holdout.
|
|
int oosCutoff = (int)(MathMax(0, MathMin(100, m_oosSplitPct)) / 100.0
|
|
* MathMax(bars - MathMax(m_historyBars, 0), 0));
|
|
int from = MathMax(oosCutoff, 0);
|
|
double spread = (double)m_symbol.Spread() * m_symbol.Point();
|
|
if(!MathIsValidNumber(spread) || spread < 0.0)
|
|
spread = 0.0;
|
|
//--- Spread expressed in ATR, averaged over the same bars the ladder covers - the ladder is in ATR
|
|
//--- units, so the cost has to be converted into the same units before it can be netted off a leg.
|
|
double spreadAtrSum = 0.0;
|
|
int atrN = 0;
|
|
for(int i = from; i < bars; i++)
|
|
{
|
|
if(i >= ArraySize(m_labelCacheHasValue) || !m_labelCacheHasValue[i])
|
|
continue;
|
|
double a = m_ATR.Main(i);
|
|
if(!MathIsValidNumber(a) || a <= 0.0)
|
|
continue;
|
|
spreadAtrSum += spread / a;
|
|
atrN++;
|
|
}
|
|
if(atrN < BARRIER_DERIVE_MIN_SAMPLES)
|
|
return;
|
|
double spreadAtr = spreadAtrSum / atrN;
|
|
//--- Published so CostAdjustedBreakEvenPct() can price the cost into every break-even the run quotes.
|
|
m_spreadAtr = spreadAtr;
|
|
Print(ID + StringFormat(": barrier expectancy scan - spread averages %.3f*ATR over %d bars. EV per "
|
|
"trade = edge x width, so the ratio is EV-neutral and WIDTH is what pays; "
|
|
"'spreads' is width/spread (cost efficiency), 'decided' is the share of bars "
|
|
"the long side resolved inside the %d-bar horizon. No row here demonstrates "
|
|
"an edge - it prices one.", spreadAtr, atrN, m_barrierHorizonBars));
|
|
double bestWidth = -1.0;
|
|
int bestT = -1, bestS = -1;
|
|
for(int tL = 0; tL < BARRIER_LADDER_COUNT; tL++)
|
|
{
|
|
//--- Every ladder pairing walks the whole labelled window again. Purely a pricing report - it
|
|
//--- adopts nothing - so leaving with the rows printed so far costs only information.
|
|
if(ShutdownRequested())
|
|
return;
|
|
for(int sL = 0; sL < BARRIER_LADDER_COUNT; sL++)
|
|
{
|
|
//--- Ladder levels are TRAVEL from the entry close; converting back to the SL/TP multiples that
|
|
//--- would actually be pinned puts the spread where the fill puts it - see BARRIER_LADDER.
|
|
double reward = BARRIER_LADDER[tL] - spreadAtr;
|
|
double risk = BARRIER_LADDER[sL] + spreadAtr;
|
|
if(reward <= 0.0 || risk <= 0.0)
|
|
continue; // target inside the spread - not a tradeable geometry at any hit rate
|
|
long nLong = 0, nShort = 0, nDecided = 0, nSeen = 0;
|
|
for(int i = from; i < bars; i++)
|
|
{
|
|
if(i >= ArraySize(m_labelCacheHasValue) || !m_labelCacheHasValue[i])
|
|
continue;
|
|
int b = i * BARRIER_LADDER_COUNT;
|
|
int tUpT = m_ladderUpAt[b + tL]; // long target / short stop reference
|
|
int tDnS = m_ladderDownAt[b + sL]; // long stop
|
|
int tDnT = m_ladderDownAt[b + tL]; // short target
|
|
int tUpS = m_ladderUpAt[b + sL]; // short stop
|
|
//--- 0 means "never touched inside the horizon". A smaller age is the EARLIER touch, and a
|
|
//--- tie goes to the stop - the same pessimistic convention the label walk uses, so these
|
|
//--- numbers describe the same game the training target does.
|
|
if(tUpT > 0 && (tDnS == 0 || tUpT < tDnS))
|
|
nLong++;
|
|
if(tDnT > 0 && (tUpS == 0 || tDnT < tUpS))
|
|
nShort++;
|
|
if(tUpT > 0 || tDnS > 0)
|
|
nDecided++;
|
|
nSeen++;
|
|
}
|
|
if(nSeen < BARRIER_DERIVE_MIN_SAMPLES)
|
|
continue;
|
|
double pL = 100.0 * nLong / nSeen;
|
|
double pS = 100.0 * nShort / nSeen;
|
|
double be = 100.0 * risk / (risk + reward);
|
|
double width = risk + reward;
|
|
double decided = 100.0 * nDecided / nSeen;
|
|
PrintFormat("%s: stop %.2f target %.2f | width %.2f*ATR = %.1f spreads | break-even %.1f%% |"
|
|
" base long %.1f%% short %.1f%% | decided %.1f%% | EV at a 1pp edge %.4f*ATR",
|
|
ID, risk, reward, width, (spreadAtr > 0.0 ? width / spreadAtr : 0.0), be,
|
|
pL, pS, decided, 0.01 * width);
|
|
//--- The recommendation is the WIDEST pair that still resolves most of its bars inside the
|
|
//--- horizon. Width is the whole of the EV multiplier; the decided-rate floor is what stops it
|
|
//--- running away to a barrier the horizon can never deliver, which is the failure the shipped
|
|
//--- 128-bar clamp already caused once.
|
|
if(decided >= 60.0 && width > bestWidth)
|
|
{
|
|
bestWidth = width;
|
|
bestT = tL;
|
|
bestS = sL;
|
|
}
|
|
}
|
|
}
|
|
if(bestT >= 0)
|
|
Print(ID + StringFormat(": barrier expectancy scan - on width alone the best resolvable pair is "
|
|
"stop %.2f*ATR target %.2f*ATR (width %.2f*ATR, %.1f spreads), against the "
|
|
"quantile rule's stop %.2f target %.2f (width %.2f*ATR, %.1f spreads) - a "
|
|
"%.2fx difference in EV per unit of edge. MEASUREMENT ONLY: the quantile "
|
|
"rule still chooses, because width buys nothing if the wider target is "
|
|
"less predictable, and this scan cannot see that.",
|
|
BARRIER_LADDER[bestS] + spreadAtr, BARRIER_LADDER[bestT] - spreadAtr,
|
|
bestWidth, (spreadAtr > 0.0 ? bestWidth / spreadAtr : 0.0),
|
|
m_derivedSlMult, m_derivedTpMult, m_derivedSlMult + m_derivedTpMult,
|
|
(spreadAtr > 0.0 ? (m_derivedSlMult + m_derivedTpMult) / spreadAtr : 0.0),
|
|
(m_derivedSlMult + m_derivedTpMult > 0.0
|
|
? bestWidth / (m_derivedSlMult + m_derivedTpMult) : 0.0)));
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//+------------------------------------------------------------------+
|
|
//| Share of the given bars that a LONG at (sl, tp) would have WON - |
|
|
//| target touched strictly before the stop - read off the |
|
|
//| first-passage ladder, which runs the FULL barrier horizon. |
|
|
//| |
|
|
//| THIS IS THE WINDOW THE TRADE ACTUALLY LIVES IN, and using the |
|
|
//| excursion cache instead was a real error (introduced bc57aca, |
|
|
//| found the same night). m_excUpCache accumulates only over |
|
|
//| excWindow = the SWING MEDIAN - deliberately, because sizing a |
|
|
//| barrier off travel measured over a horizon that itself scales |
|
|
//| with the barrier is circular and ran away to 14-31*ATR on |
|
|
//| EURUSD/USDCAD in 2026-08-07. That guard is correct and stays. |
|
|
//| But it makes the excursion cache the WRONG instrument for asking |
|
|
//| "would this target be reached", because the trade is held for |
|
|
//| m_barrierHorizonBars, not for the swing median. |
|
|
//| |
|
|
//| Measured on SP500 H4: swing median ~12 bars against a 64-bar |
|
|
//| horizon, so the excursion-based test understated reachability by |
|
|
//| ~2x (17.7% vs a true 35.9%) and rejected every wide rung of the |
|
|
//| scale ladder - which is exactly how the geometry came out at |
|
|
//| 1.61/3.21 when the data supported considerably wider. |
|
|
//| |
|
|
//| THE SNAP MUST PRESERVE THE RATIO, and the first version did not. |
|
|
//| Both legs used to snap independently to the smallest rung at or |
|
|
//| above the request, which silently re-rated each candidate: |
|
|
//| |
|
|
//| q90 4.86/9.71 -> 5.00/10.00 = 2.00 (asked 2.00) |
|
|
//| q85 4.07/8.14 -> 5.00/10.00 = 2.00 IDENTICAL to q90 |
|
|
//| q75 3.07/6.13 -> 4.00/ 6.50 = 1.63 a NEARER target |
|
|
//| |
|
|
//| So the scale ladder was comparing win shares measured at ratios |
|
|
//| from 1.63 to 2.17 and reading the differences as scale effects. |
|
|
//| It is why the reported reach column came out non-monotone in |
|
|
//| width (q75 48.5% ABOVE q90 42.9% on SP500 H4, 2026-08-17) - q75 |
|
|
//| was simply being tested against an easier target. |
|
|
//| |
|
|
//| Now the STOP snaps to its nearest rung and the target is taken |
|
|
//| relative to THAT, so the measured ratio is as close to the asked |
|
|
//| ratio as the grid can express. Rungs become comparable to each |
|
|
//| other, which is the only thing the ladder does with them. The |
|
|
//| pair actually measured is returned so the caller can print it: |
|
|
//| a collision (two quantiles landing on one grid pair) is a real |
|
|
//| limit of the instrument's resolution and must be visible, not |
|
|
//| hidden behind two identical-looking percentages. |
|
|
//| |
|
|
//| A target past the top rung returns 0 - unreachable as far as this |
|
|
//| instrument can measure, which is the honest answer now that the |
|
|
//| rung ceiling is 20*ATR. |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalAIBase::LadderWinShare(const int &idxList[], int n, double sl, double tp,
|
|
double &effSl, double &effTp)
|
|
{
|
|
effSl = 0.0;
|
|
effTp = 0.0;
|
|
//--- Cleared with the out-params and for the same reason: every early return below leaves this
|
|
//--- untouched otherwise, and the caller loops over rungs - so a rejected rung would report the
|
|
//--- PREVIOUS rung's lifespan as its own. Same class of bug as the excursion publication at the top
|
|
//--- of TripleBarrierLabel; 0 reads as "not measured".
|
|
m_lastRungLifespan = 0.0;
|
|
if(sl <= 0.0 || tp <= 0.0 || n <= 0)
|
|
return 0.0;
|
|
//--- n is the EXCURSION sample size, which is not always idxList's size: the conditional (fractal)
|
|
//--- geometry path fills up[]/dn[] from m_fracLegFav with n = m_fracLegCount while leaving idxList
|
|
//--- empty, because leg-scoped excursions carry no bar index to look a first-passage row up by. The
|
|
//--- loop below would then index a zero-length array. Answer "not measurable" rather than fault:
|
|
//--- reachability off the ladder is undefined without the bar indices, and the caller's floor treats
|
|
//--- 0 as "this rung does not qualify", which is the correct handling of an unmeasurable rung.
|
|
if(ArraySize(idxList) < n)
|
|
return 0.0;
|
|
//--- Stop leg: NEAREST rung, in log space because the ladder is roughly geometric and a linear
|
|
//--- "nearest" would bias every choice toward the coarse upper end.
|
|
int dnLvl = -1;
|
|
double bestErr = DBL_MAX;
|
|
for(int k = 0; k < BARRIER_LADDER_COUNT; k++)
|
|
{
|
|
double err = MathAbs(MathLog(BARRIER_LADDER[k] / sl));
|
|
if(err < bestErr)
|
|
{
|
|
bestErr = err;
|
|
dnLvl = k;
|
|
}
|
|
}
|
|
if(dnLvl < 0)
|
|
return 0.0;
|
|
//--- Target leg: nearest rung to the ratio applied to the SNAPPED stop, so the pair that gets measured
|
|
//--- is a 1:RR pair on the grid rather than the requested pair re-rated by two independent roundings.
|
|
double wantTp = (BARRIER_LADDER[dnLvl] / sl) * tp;
|
|
int upLvl = -1;
|
|
bestErr = DBL_MAX;
|
|
for(int k = 0; k < BARRIER_LADDER_COUNT; k++)
|
|
{
|
|
double err = MathAbs(MathLog(BARRIER_LADDER[k] / wantTp));
|
|
if(err < bestErr)
|
|
{
|
|
bestErr = err;
|
|
upLvl = k;
|
|
}
|
|
}
|
|
if(upLvl < 0)
|
|
return 0.0;
|
|
//--- The grid cannot express this target at all (it sits past the top rung and the nearest rung is a
|
|
//--- materially different trade). Refuse rather than silently measure something else - the caller's
|
|
//--- floor is supposed to reject exactly this case.
|
|
if(wantTp > BARRIER_LADDER[BARRIER_LADDER_COUNT - 1])
|
|
return 0.0;
|
|
effSl = BARRIER_LADDER[dnLvl];
|
|
effTp = BARRIER_LADDER[upLvl];
|
|
int won = 0, seen = 0;
|
|
//--- LIFESPAN AT THIS RUNG, harvested in the same pass. The first-passage cache already stores the
|
|
//--- touch AGE at every ladder level, so the time this candidate geometry would take to resolve is
|
|
//--- readable for a rung the run is not training on - which turns "narrow barriers give more
|
|
//--- independent samples" from an argument into a measurement, across the whole ladder, in one run.
|
|
//--- Unresolved bars contribute the horizon: that is when their label becomes knowable.
|
|
double lifeSum = 0.0;
|
|
int lifeN = 0;
|
|
int horizon = MathMax(m_barrierHorizonBars, 1);
|
|
for(int i = 0; i < n; i++)
|
|
{
|
|
int b = idxList[i] * BARRIER_LADDER_COUNT;
|
|
if(b + BARRIER_LADDER_COUNT > ArraySize(m_ladderUpAt) ||
|
|
b + BARRIER_LADDER_COUNT > ArraySize(m_ladderDownAt))
|
|
continue;
|
|
//--- 0 means "never touched inside the horizon"; a SMALLER age is the EARLIER touch. Tie goes to
|
|
//--- the stop, the same pessimistic convention the label walk and the expectancy scan both use.
|
|
int tUp = m_ladderUpAt[b + upLvl];
|
|
int tDn = m_ladderDownAt[b + dnLvl];
|
|
if(tUp > 0 && (tDn == 0 || tUp < tDn))
|
|
won++;
|
|
seen++;
|
|
//--- Resolution age = the FIRST of the two touches; neither touching means it ran to the horizon.
|
|
int age = 0;
|
|
if(tUp > 0 && tDn > 0)
|
|
age = (int)MathMin(tUp, tDn);
|
|
else
|
|
if(tUp > 0)
|
|
age = tUp;
|
|
else
|
|
if(tDn > 0)
|
|
age = tDn;
|
|
else
|
|
age = horizon;
|
|
lifeSum += (double)age;
|
|
lifeN++;
|
|
}
|
|
m_lastRungLifespan = (lifeN > 0) ? lifeSum / lifeN : 0.0;
|
|
return (seen > 0) ? 100.0 * won / seen : 0.0;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
bool CExpertSignalAIBase::DeriveBarrierGeometry(void)
|
|
{
|
|
int bars = m_labelCacheBars;
|
|
double up[], dn[];
|
|
ArrayResize(up, bars);
|
|
ArrayResize(dn, bars);
|
|
int n = 0;
|
|
//--- CONDITIONAL source for the fractal target: quantiles of the leg-scoped MFE/MAE recorded at
|
|
//--- Buy/Sell-LABELED bars (see m_fracLegFav) instead of the pooled every-bar excursions. The bars
|
|
//--- the model trades are the bars the label marks, and their excursion distribution is provably
|
|
//--- different from the pooled one whenever the label carries information - sizing the barriers on
|
|
//--- all bars mis-sizes them for the traded ones. No circularity: the fractal label does not
|
|
//--- depend on SL/TP. Falls through to the pooled source (with its own log line) when too few
|
|
//--- labeled legs exist, so a thin chart still gets a geometry.
|
|
//--- Parallel to up[]/dn[]: was THIS bar labelled Buy, and which bar was it? The label feeds the
|
|
//--- consistency report; the index feeds the first-passage reachability test in the scale ladder.
|
|
bool labUp[];
|
|
int idxList[];
|
|
ArrayResize(labUp, 0);
|
|
ArrayResize(idxList, 0);
|
|
bool conditional = false;
|
|
if(IsFractalTarget() && m_fracLegCount >= BARRIER_DERIVE_MIN_SAMPLES)
|
|
{
|
|
conditional = true;
|
|
n = m_fracLegCount;
|
|
ArrayResize(up, n);
|
|
ArrayResize(dn, n);
|
|
for(int i = 0; i < n; i++)
|
|
{
|
|
up[i] = m_fracLegFav[i];
|
|
dn[i] = m_fracLegAdv[i];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if(IsFractalTarget())
|
|
Print(ID + StringFormat(": conditional geometry NOT available - only %d labeled fractal legs "
|
|
"(need %d); deriving from the pooled every-bar excursions instead.",
|
|
m_fracLegCount, BARRIER_DERIVE_MIN_SAMPLES));
|
|
//--- IS region only, matching BuildMiSample: a geometry chosen with the holdout in view has used
|
|
//--- the holdout for selection, and it stops being a holdout.
|
|
int oosCutoff = (int)(MathMax(0, MathMin(100, m_oosSplitPct)) / 100.0
|
|
* MathMax(bars - MathMax(m_historyBars, 0), 0));
|
|
for(int i = MathMax(oosCutoff, 0); i < bars; i++)
|
|
{
|
|
if(i >= ArraySize(m_labelCacheHasValue) || !m_labelCacheHasValue[i])
|
|
continue;
|
|
if(i >= ArraySize(m_excUpCache))
|
|
continue;
|
|
double u = m_excUpCache[i], d = m_excDownCache[i];
|
|
if(!MathIsValidNumber(u) || !MathIsValidNumber(d) || (u <= 0.0 && d <= 0.0))
|
|
continue; // unresolvable bar - see the same guard in BuildMiSample
|
|
up[n] = u;
|
|
dn[n] = d;
|
|
//--- CONSISTENCY CHECK + first-passage reachability, harvested on the SAME bar in the SAME pass.
|
|
ArrayResize(labUp, n + 1);
|
|
labUp[n] = (i < ArraySize(m_labelCacheBuy) && m_labelCacheBuy[i]);
|
|
ArrayResize(idxList, n + 1);
|
|
idxList[n] = i;
|
|
n++;
|
|
}
|
|
}
|
|
if(n < BARRIER_DERIVE_MIN_SAMPLES)
|
|
{
|
|
Print(ID + StringFormat(": barrier geometry NOT derived - only %d usable excursion samples "
|
|
"(need %d). Falling back to the configured %d:%d.", n,
|
|
BARRIER_DERIVE_MIN_SAMPLES, m_sl_mode, m_tp_mode));
|
|
return false;
|
|
}
|
|
ArrayResize(up, n);
|
|
ArrayResize(dn, n);
|
|
//--- Unsorted copy: ArraySort below destroys the index correspondence with labUp[], and the
|
|
//--- consistency report needs to pair each bar's excursion with its own label.
|
|
double upUnsorted[];
|
|
ArrayResize(upUnsorted, n);
|
|
ArrayCopy(upUnsorted, up, 0, 0, n);
|
|
ArraySort(up);
|
|
ArraySort(dn);
|
|
//--- STOP from the ADVERSE distribution, TARGET from the FAVOURABLE one - each leg sized by the thing
|
|
//--- it actually has to survive or reach. The stop sits at a HIGH quantile of MAE so only the minority
|
|
//--- of bars whose adverse travel exceeds it ever reach it; the target at the MEDIAN of MFE so it is
|
|
//--- reached about half the time within the horizon. See BARRIER_SL_QUANTILE for why that quantile is
|
|
//--- 0.75 and not 0.25 - the first version had it backwards and the printed reachability caught it.
|
|
//--- SCALE FROM THE DATA, RATIO FROM POLICY - see BARRIER_TARGET_RR. Walk the quantile ladder widest
|
|
//--- first and take the first rung whose implied target is still reached often enough to be a
|
|
//--- trainable class. Width is what pays (EV = edge x width) so wider is strictly better on cost;
|
|
//--- reachability is the only thing that stops it running away, and it is measured here rather than
|
|
//--- assumed. Every rung is reported so the choice is auditable.
|
|
//--- THE HORIZON IS A HARD CONSTRAINT ON WIDTH, added 2026-08-17 after this ladder ran away.
|
|
//--- First-passage time for the band [-m, +k] grows like m*k, so widening the barrier lengthens the
|
|
//--- horizon QUADRATICALLY - and because chosenReach is measured over that horizon, a wider rung buys
|
|
//--- itself the very time that makes it look reachable. target -> horizon -> reach -> target is a loop,
|
|
//--- and it is the SAME loop the excursion window was deliberately kept short to avoid (see
|
|
//--- ComputeBarrierHorizonBars, and the 2026-08-07 EURUSD/USDCAD runaway to 14-31*ATR that "converged"
|
|
//--- only because the horizon ladder caps at 384). Measuring reachability over the full horizon - the
|
|
//--- correct fix for the excursion-window confusion - reopened it through the other door: on SP500 H4
|
|
//--- the geometry walked 128 -> 256 -> 384 bars over three derive passes and stopped at q90, the
|
|
//--- WIDEST rung there is, with every rung reading 39-48% against a 20% floor. A floor that nothing
|
|
//--- fails is not selecting anything; the ladder had degenerated to "take the widest".
|
|
//---
|
|
//--- What actually stops it is the constraint the loop cannot buy its way out of: a rung whose
|
|
//--- required horizon exceeds BARRIER_HORIZON_MAX gets a CLAMPED label - "target before stop" quietly
|
|
//--- becomes "...or 384 bars, whichever comes first" - while the deployed EA holds to SL or TP with no
|
|
//--- bar limit. That is a train/deploy mismatch in the target itself. ReportGeometryExpectancyScan has
|
|
//--- always disqualified those candidates ('!' in its output); the deriver did not, and on 2026-08-17
|
|
//--- shipped 4.86/9.71 - needing 613 bars - while the scan printed that same pair as CLAMPED two lines
|
|
//--- later. Two subsystems, one geometry, opposite verdicts. Now they apply the same rule.
|
|
double slRaw = 0.0, tpRaw = 0.0;
|
|
double chosenQ = 0.0, chosenReach = 0.0;
|
|
string rungRows = "";
|
|
for(int r = 0; r < BARRIER_SL_QUANTILE_COUNT; r++)
|
|
{
|
|
double q = BARRIER_SL_QUANTILE_LADDER[r];
|
|
double sl = dn[(int)MathMin(q * n, n - 1)];
|
|
if(sl < MIN_SL_ATR_MULTIPLIER)
|
|
sl = MIN_SL_ATR_MULTIPLIER;
|
|
double tp = BARRIER_TARGET_RR * sl;
|
|
double effSl = 0.0, effTp = 0.0;
|
|
double reach = LadderWinShare(idxList, n, sl, tp, effSl, effTp);
|
|
int needH = RequiredHorizonBars(sl, tp);
|
|
int gotH = GrantedHorizonBars(sl, tp);
|
|
//--- DETECTABILITY AT THIS RUNG - the quantity BARRIER_SCALE_OBJECTIVE trades against width.
|
|
//--- n_eff = sample / this rung's own measured lifespan; the smallest edge 2 sigma can separate
|
|
//--- from chance follows, and multiplying by the width gives the smallest EV per trade that could
|
|
//--- ever be PROVEN at this geometry. Chance is the 1:RR break-even rather than a measured drift
|
|
//--- rate - this is a property of the geometry, not of any model.
|
|
//--- THE DENOMINATOR IS THE IS SAMPLE (n), because that is all the deriver is allowed to see - a
|
|
//--- geometry chosen with the holdout in view has used the holdout. The DEPLOY gate measures on
|
|
//--- the OOS window, which is smaller (roughly n x m_oosSplitPct/100), so these absolute figures
|
|
//--- are OPTIMISTIC by sqrt(that ratio) - about 1.5x at a 30% split. The RANKING across rungs is
|
|
//--- unaffected, since every rung is divided by the same n, and ranking is all this loop uses it
|
|
//--- for. Read the era line's DEPLOY BAR for the number that actually gates a deploy.
|
|
double rungLife = (m_lastRungLifespan > 0.0) ? m_lastRungLifespan : 1.0;
|
|
double rungEffN = MathMax((double)n / rungLife, 2.0);
|
|
double beP = 1.0 / (1.0 + BARRIER_TARGET_RR);
|
|
double rungSE = 100.0 * MathSqrt(beP * (1.0 - beP) / rungEffN);
|
|
double minEdge = EDGE_MIN_SIGMAS * rungSE; // percentage points
|
|
double minEV = minEdge / 100.0 * (sl + tp); // in ATR per trade
|
|
//--- Round-trip spread as a share of the move. This is what stops MEASURE mode at the tight end.
|
|
//--- m_spreadAtr is measured by ReportGeometryExpectancyScan, which runs at the END of this
|
|
//--- function - so on the FIRST derive pass it is still 0 and this filter is deliberately inert
|
|
//--- (costOK true) rather than rejecting every rung on an unmeasured cost. The pass-2 re-derivation
|
|
//--- applies it for real; that is exactly what the fixed-point iteration is for.
|
|
double costPct = (sl + tp > 0.0 && m_spreadAtr > 0.0)
|
|
? 100.0 * 2.0 * m_spreadAtr / (sl + tp) : 0.0;
|
|
bool costOK = (m_spreadAtr <= 0.0 || costPct <= BARRIER_MAX_COST_FRACTION_PCT);
|
|
//--- REJECT ON THE CEILING ONLY, matching ReportGeometryExpectancyScan's '!' exactly - that is the
|
|
//--- whole point of applying one rule in two places. gotH < needH is the SEPARATE, milder
|
|
//--- truncation the ladder's snap-down always imposes (317 needed -> 256 granted); it is reported,
|
|
//--- not rejected, because rejecting on it would select rungs for landing just above a ladder point
|
|
//--- rather than for anything about the market. The timeout share is what says whether it bites.
|
|
bool fitsH = (needH <= BARRIER_HORIZON_MAX);
|
|
//--- The measured pair is printed beside the requested one whenever the grid could not express the
|
|
//--- request, so a collision between two quantiles is visible as a collision rather than as two
|
|
//--- rungs that happen to score identically.
|
|
string effNote = "";
|
|
if(effSl > 0.0 && (MathAbs(effSl - sl) > 0.005 || MathAbs(effTp - tp) > 0.005))
|
|
effNote = StringFormat(" @grid %.2f/%.2f", effSl, effTp);
|
|
rungRows += StringFormat("%sq%.0f(stop %.2f target %.2f width %.2f reach %.1f%%%s needs %d gets %d"
|
|
" | L %.0f -> n_eff %.0f, min provable edge %.1fpp = %.2f ATR/trade,"
|
|
" cost %.1f%%%s%s)",
|
|
(rungRows == "" ? "" : " "), 100.0 * q, sl, tp, sl + tp, reach, effNote,
|
|
needH, gotH, rungLife, rungEffN, minEdge, minEV, costPct,
|
|
(costOK ? "" : " COST-REJECTED"), (fitsH ? "" : " CLAMPED-REJECTED"));
|
|
//--- Both objectives share the reachability floor and the horizon ceiling; they differ only in
|
|
//--- which end of the surviving set they take. DEPLOY keeps the first (widest, ladder is ordered
|
|
//--- widest-first); MEASURE keeps overwriting, so it ends on the last - the narrowest that is
|
|
//--- still cost-efficient.
|
|
bool eligible = fitsH && costOK && reach >= BARRIER_MIN_TP_REACH_PCT;
|
|
bool takeIt = (BARRIER_SCALE_OBJECTIVE == BARRIER_SCALE_DEPLOY) ? (slRaw <= 0.0) : true;
|
|
if(eligible && takeIt)
|
|
{
|
|
slRaw = sl;
|
|
tpRaw = tp;
|
|
chosenQ = q;
|
|
chosenReach = reach;
|
|
}
|
|
}
|
|
if(slRaw <= 0.0)
|
|
{
|
|
//--- No rung clears the floor: the horizon cannot deliver a 1:RR target at ANY survivable stop on
|
|
//--- this instrument. Take the tightest rung (the most reachable one there is) and say so - the
|
|
//--- ratio is risk policy, and the honest response is to price it, not to silently abandon it.
|
|
double q = BARRIER_SL_QUANTILE_LADDER[BARRIER_SL_QUANTILE_COUNT - 1];
|
|
slRaw = dn[(int)MathMin(q * n, n - 1)];
|
|
if(slRaw < MIN_SL_ATR_MULTIPLIER)
|
|
slRaw = MIN_SL_ATR_MULTIPLIER;
|
|
tpRaw = BARRIER_TARGET_RR * slRaw;
|
|
chosenQ = q;
|
|
double fbSl = 0.0, fbTp = 0.0;
|
|
chosenReach = LadderWinShare(idxList, n, slRaw, tpRaw, fbSl, fbTp);
|
|
Print(ID + StringFormat(": WARNING - no stop quantile produced a %.1f:1 target reached on at least "
|
|
"%.0f%% of bars inside the %d-bar horizon AND resolvable within the %d-bar "
|
|
"ceiling. Taking the tightest rung (q%.0f) at %.1f%% reachability. The "
|
|
"positive class will be rare and training will be correspondingly hard; "
|
|
"raise BARRIER_HORIZON_MAX or lower BARRIER_TARGET_RR if that proves "
|
|
"untrainable.",
|
|
BARRIER_TARGET_RR, BARRIER_MIN_TP_REACH_PCT, m_barrierHorizonBars,
|
|
BARRIER_HORIZON_MAX, 100.0 * chosenQ, chosenReach));
|
|
}
|
|
Print(ID + StringFormat(": barrier SCALE ladder - objective %s (ratio fixed at 1:%.1f by policy). Every "
|
|
"rung must clear %.0f%% reachability, resolve inside the %d-bar horizon "
|
|
"ceiling, and keep the round-trip spread under %.1f%% of its width; of those, "
|
|
"%s. Width is EV per trade, narrowness is EV you can PROVE - min provable EV "
|
|
"grows as width SQUARED because the label's lifespan does, so the two ends of "
|
|
"this ladder are opposed and only one is right per phase. - %s | chose q%.0f, "
|
|
"target reached on %.1f%% of bars; needs %d bars, granted %d (ceiling %d, then "
|
|
"snapped DOWN - the shortfall shows up as the timeout share on the next "
|
|
"label-cache line)",
|
|
(BARRIER_SCALE_OBJECTIVE == BARRIER_SCALE_DEPLOY
|
|
? "DEPLOY (maximise EV per trade)"
|
|
: "MEASURE (maximise detectability - the edge is not proven yet)"),
|
|
BARRIER_TARGET_RR, BARRIER_MIN_TP_REACH_PCT, BARRIER_HORIZON_MAX,
|
|
BARRIER_MAX_COST_FRACTION_PCT,
|
|
(BARRIER_SCALE_OBJECTIVE == BARRIER_SCALE_DEPLOY
|
|
? "the WIDEST wins" : "the NARROWEST wins"),
|
|
rungRows, 100.0 * chosenQ, chosenReach, RequiredHorizonBars(slRaw, tpRaw),
|
|
GrantedHorizonBars(slRaw, tpRaw), BARRIER_HORIZON_MAX));
|
|
//--- Same floor a real order gets, so the stop used for labelling is the stop that can actually be
|
|
//--- placed. This is the ONLY adjustment either leg receives - both multiples are otherwise read
|
|
//--- straight off the measured distributions.
|
|
if(slRaw < MIN_SL_ATR_MULTIPLIER)
|
|
slRaw = MIN_SL_ATR_MULTIPLIER;
|
|
//--- The minimum-reward:risk raise that used to sit here is GONE (2026-08-09). It overrode a measured
|
|
//--- target with an arithmetic one - twice whatever the stop happened to be - and on SP500 H1 that
|
|
//--- pushed the target from the q50 of favourable travel out to 6.66*ATR, reachable on 3.3% of bars.
|
|
//--- The model was then trained to predict an outcome that essentially never happens. A measured
|
|
//--- target has to stay measured; see Variables\Inputs.mqh for why the ratio bought nothing in
|
|
//--- exchange (a reward:risk floor moves payoff and hit rate together at a fixed break-even, it does
|
|
//--- not create expectancy) and cost two separate outages.
|
|
//--- TRAVEL SHARES OVER THE EXCURSION WINDOW - what fraction of bars moved this far within the ~swing
|
|
//--- median. These describe the DISTRIBUTION THE MULTIPLES WERE READ OFF, which is the only thing they
|
|
//--- can honestly describe, and they are near-tautological by construction (a q50 stop is exceeded by
|
|
//--- ~50% of bars). They are NOT reachability over the horizon the trade is held for - that is
|
|
//--- chosenReach, measured on the first-passage ladder, and mixing the two is what produced the
|
|
//--- 17.7%-vs-35.9% confusion. Kept and RENAMED rather than deleted: they are the sanity check that
|
|
//--- the quantile read did what it claimed.
|
|
int travelTp = 0, travelSl = 0;
|
|
for(int i = 0; i < n; i++)
|
|
{
|
|
if(up[i] >= tpRaw)
|
|
travelTp++;
|
|
if(dn[i] >= slRaw)
|
|
travelSl++;
|
|
}
|
|
double tpTravel = 100.0 * travelTp / n;
|
|
double slTravel = 100.0 * travelSl / n;
|
|
double breakeven = 100.0 * slRaw / (slRaw + tpRaw);
|
|
m_derivedSlMult = slRaw;
|
|
m_derivedTpMult = tpRaw;
|
|
m_geometryDerived = true;
|
|
//--- Publish to the LIVE order path (ConfidenceBridge.mqh). Until 2026-08-09 the derived pair
|
|
//--- reached the labels only, so the gate certified trades at this geometry while OpenParams()
|
|
//--- placed them at the enum geometry - graded on one game, paid on another.
|
|
g_DerivedSlAtrMult = m_derivedSlMult;
|
|
g_DerivedTpAtrMult = m_derivedTpMult;
|
|
if(conditional)
|
|
Print(ID + StringFormat(": geometry source - CONDITIONAL on the fractal label: MFE/MAE measured "
|
|
"from each labeled bar's close over the leg to its NEXT fractal extreme "
|
|
"(%d IS legs, entry-anchored), not over every bar. The stop/target below "
|
|
"are sized for the bars the model actually trades.", n));
|
|
Print(ID + StringFormat(": live orders now use the MEASURED geometry - stop %.2f*ATR, target "
|
|
"%.2f*ATR - overriding the SL_Mode/TP_Mode enums (and the Intelligent "
|
|
"modes' confidence scaling), so the trade placed is the trade the deploy "
|
|
"gate certified.", m_derivedSlMult, m_derivedTpMult));
|
|
Print(ID + StringFormat(": barrier geometry DERIVED from %d measured excursions - stop %.2f*ATR "
|
|
"(q%.0f of adverse travel, chosen by the SCALE ladder above), target %.2f*ATR "
|
|
"(= %.1f x the stop, RATIO BY POLICY not by measurement) | width %.2f*ATR | "
|
|
"travelled within the %d-bar EXCURSION window: target on %.1f%% of bars, stop "
|
|
"on %.1f%% (near-tautological - that is where the quantiles were read) | "
|
|
"implied break-even %.1f%%. This does NOT create expectancy - chance precision "
|
|
"equals break-even at every RATIO - what the geometry buys is WIDTH, and width "
|
|
"is the EV multiplier because the spread is a fixed cost per trade.",
|
|
//--- ORDER MATTERS AND WAS WRONG ONCE: the multiples and the quantile labels
|
|
//--- were swapped, so the log read "stop 25.00*ATR (q3 ...)" - printing the
|
|
//--- quantile percentage as the multiple and the multiple as the quantile.
|
|
//--- 25*ATR is absurd on its face, which is the only reason it was caught.
|
|
n, m_derivedSlMult, 100.0 * chosenQ, m_derivedTpMult, BARRIER_TARGET_RR,
|
|
m_derivedSlMult + m_derivedTpMult, m_swingMedianBars,
|
|
tpTravel, slTravel, breakeven));
|
|
//--- THE THREE WINDOWS, printed together because two of them look like the same quantity and are not.
|
|
//--- This is the reconciliation for a 2026-08-17 discrepancy that cost an hour: the derivation
|
|
//--- reported "target reached on 17.7% of bars" while the label cache reported Buy on 35.9% - twice as
|
|
//--- many wins as there were bars that ever reached the target. Nothing was broken. They measure
|
|
//--- different windows:
|
|
//---
|
|
//--- excursion window ~12 bars (the SWING MEDIAN) - what m_excUpCache accumulates over, kept short
|
|
//--- ON PURPOSE so the barrier is not sized off travel measured over a horizon
|
|
//--- that scales with the barrier (the 2026-08-07 runaway to 14-31*ATR)
|
|
//--- barrier horizon 64 bars - what the LABEL walk and the first-passage ladder run over, and how
|
|
//--- long the EA actually holds the trade
|
|
//---
|
|
//--- So `up >= target` is a 12-bar question and `label == Buy` is a 64-bar one, and the second can
|
|
//--- freely exceed the first. The ladder share below is the 64-bar question asked the same way the
|
|
//--- label asks it, so THAT is the one that should match the Buy rate - and any gap between those two
|
|
//--- is real: it can only come from the ladder's snap-to-rung discretisation.
|
|
if(!conditional && ArraySize(labUp) >= n && n > 0)
|
|
{
|
|
int excReach = 0, buyCount = 0;
|
|
for(int i = 0; i < n; i++)
|
|
{
|
|
if(upUnsorted[i] >= tpRaw)
|
|
excReach++;
|
|
if(labUp[i])
|
|
buyCount++;
|
|
}
|
|
double recSl = 0.0, recTp = 0.0;
|
|
double ladderShare = LadderWinShare(idxList, n, slRaw, tpRaw, recSl, recTp);
|
|
double buyPct = 100.0 * buyCount / n;
|
|
double gap = MathAbs(ladderShare - buyPct);
|
|
//--- The gap tolerance has to scale with how badly the grid mis-states the pair, not sit at a flat
|
|
//--- 5pp: the ladder measures recSl/recTp, the labels measure slRaw/tpRaw, and when those differ the
|
|
//--- two are answering NEARLY the same question rather than exactly it. A fixed tolerance either
|
|
//--- cries wolf on a coarse rung or says nothing on a fine one.
|
|
double gridSkew = (slRaw > 0.0 && tpRaw > 0.0 && recSl > 0.0)
|
|
? MathAbs((recTp / recSl) - (tpRaw / slRaw)) / (tpRaw / slRaw) : 0.0;
|
|
double gapTol = 5.0 + 100.0 * gridSkew;
|
|
Print(ID + StringFormat(": window reconciliation at stop %.2f target %.2f - EXCURSION window "
|
|
"(%d bars, sizes the barrier): target travelled on %.1f%% of bars | "
|
|
"BARRIER horizon (%d bars, what the trade lives in): ladder says a long "
|
|
"wins %.1f%% (measured at the grid pair %.2f/%.2f, ratio %.2f vs the asked "
|
|
"%.2f), the label cache says Buy %.1f%% | ladder-vs-label gap %.1fpp vs a "
|
|
"%.1fpp tolerance %s. The first number is EXPECTED to be the smallest - it "
|
|
"asks a %d-bar question where the other two ask a %d-bar one.",
|
|
slRaw, tpRaw, m_swingMedianBars, 100.0 * excReach / n,
|
|
m_barrierHorizonBars, ladderShare, recSl, recTp,
|
|
(recSl > 0.0 ? recTp / recSl : 0.0), tpRaw / slRaw, buyPct, gap, gapTol,
|
|
(gap <= gapTol ? "(rung discretisation, expected)"
|
|
: "<-- TOO LARGE to be discretisation; the ladder and the label walk should"
|
|
" be answering the identical question, so one of them is wrong"),
|
|
m_swingMedianBars, m_barrierHorizonBars));
|
|
}
|
|
//--- Prices every alternative geometry against the one just chosen. Runs AFTER the pick so the report
|
|
//--- can compare the two, and changes nothing - see its definition for why width, not ratio, is the
|
|
//--- quantity that moves expectancy.
|
|
ReportGeometryExpectancyScan();
|
|
//--- The scale ladder already retreats until this floor is met, so reaching here means even its
|
|
//--- tightest rung could not - which is a HORIZON problem, not a ratio problem. Same failure the
|
|
//--- clamped-horizon incident produced, and the ladder report above shows every rung it tried.
|
|
if(chosenReach < BARRIER_MIN_TP_REACH_PCT)
|
|
Print(ID + StringFormat(": WARNING - the target of %.2f*ATR (%.1f x the %.2f stop) is reached on "
|
|
"only %.1f%% of bars inside the %d-bar horizon, and no rung of the scale "
|
|
"ladder did better. The positive class will be that rare, so expect the "
|
|
"recall floor to bite. Lengthen the horizon or lower BARRIER_TARGET_RR.",
|
|
m_derivedTpMult, BARRIER_TARGET_RR, m_derivedSlMult, chosenReach,
|
|
m_barrierHorizonBars));
|
|
return true;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Mean bars-to-resolution over the label cache. 1.0 until something |
|
|
//| has been measured, which makes EffectiveSampleSize() the identity |
|
|
//| - the pre-2026-08-17 behaviour. That default is deliberate: an |
|
|
//| UNMEASURED overlap must not silently shrink anyone's sample, so |
|
|
//| the correction switches itself on only once it has evidence. |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalAIBase::MeanLabelLifespan(void)
|
|
{
|
|
if(m_labelLifespanCount <= 0 || m_labelLifespanSum <= 0.0)
|
|
return 1.0;
|
|
double mean = m_labelLifespanSum / (double)m_labelLifespanCount;
|
|
//--- Cannot exceed the window it was measured in, and cannot be shorter than one bar.
|
|
double cap = (double)MathMax(m_barrierHorizonBars, 1);
|
|
return MathMax(1.0, MathMin(mean, cap));
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Independent observations behind `rawN` overlapping labels. |
|
|
//| |
|
|
//| Triple-barrier labels started one per bar with mean lifespan L |
|
|
//| have average concurrency ~L, hence average uniqueness ~1/L, hence |
|
|
//| n_eff = n/L (Lopez de Prado, AFML ch. 4 - sample uniqueness and |
|
|
//| the sequential bootstrap). Every sqrt(p(1-p)/n) in this codebase |
|
|
//| assumed L = 1, which on SP500 H4 at a 384-bar horizon understated |
|
|
//| every standard error by roughly sqrt(L). See m_lastLabelLifespan |
|
|
//| for the run that exposed it. |
|
|
//| |
|
|
//| ORDER OF THE CLAMPS MATTERS, and the first version had it wrong: |
|
|
//| MathMax(2, MathMin(eff, rawN)) returns 2 when rawN is 1, i.e. an |
|
|
//| effective sample LARGER than the raw one, which shrinks the SE in |
|
|
//| exactly the direction this function exists to prevent. The cap at |
|
|
//| rawN has to be applied LAST, so a floor can never manufacture |
|
|
//| observations that were not there. |
|
|
//| |
|
|
//| THIS IS CONSERVATIVE, and knowingly so. n/L is the sample-size |
|
|
//| treatment AFML prescribes, but it is an upper bound on the damage:|
|
|
//| two labels sharing 99% of their outcome window are highly |
|
|
//| correlated, not identical - they enter at different prices, so |
|
|
//| one can win where the other loses. The true effective sample sits |
|
|
//| somewhere between n/L and n, and nothing here measures where. |
|
|
//| Erring toward n/L means gates get HARDER to clear, never easier, |
|
|
//| which is the safe direction on a funded account and the opposite |
|
|
//| of the error this replaces. Expect the operating point to sit at |
|
|
//| its deterministic fallback far more often and the deploy gate to |
|
|
//| reject eras it used to pass; that is the correction working, not |
|
|
//| a regression. If it proves too strict, the honest refinement is |
|
|
//| to MEASURE average uniqueness per label (AFML 4.2) rather than to |
|
|
//| soften the divisor by taste. |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalAIBase::EffectiveSampleSize(double rawN)
|
|
{
|
|
if(rawN <= 0.0)
|
|
return 0.0;
|
|
double eff = rawN / MeanLabelLifespan();
|
|
//--- Floor, so a caller dividing by it cannot hit zero...
|
|
if(eff < 2.0)
|
|
eff = 2.0;
|
|
//--- ...then the cap, LAST, so the floor can never exceed the observations that actually exist.
|
|
return MathMin(eff, rawN);
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Bars this geometry needs before its label stops being truncated. |
|
|
//| IDENTICAL arithmetic to ComputeBarrierHorizonBars() - see the |
|
|
//| declaration for the 2026-08-17 divergence that made factoring it |
|
|
//| out necessary rather than tidy. |
|
|
//+------------------------------------------------------------------+
|
|
int CExpertSignalAIBase::RequiredHorizonBars(double slMult, double tpMult)
|
|
{
|
|
double swing = (double)MathMax(m_swingMedianBars, 1);
|
|
if(slMult <= 0.0 || tpMult <= 0.0)
|
|
return BARRIER_HORIZON_MIN;
|
|
return (int)MathRound(swing * slMult * tpMult);
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| THE horizon ladder, and the only copy of it. It used to be a |
|
|
//| local array inside ComputeBarrierHorizonBars(); the scale ladder |
|
|
//| needs the same snap to report what a rung would actually be |
|
|
//| granted, and a second copy is precisely the drift that let the |
|
|
//| deriver and the expectancy scan disagree about one geometry. |
|
|
//| |
|
|
//| Snaps DOWN, matching ComputeFirstLayerWidth()'s direction: a |
|
|
//| horizon shorter than measured makes the label stricter (more |
|
|
//| Neutral), never more permissive. |
|
|
//+------------------------------------------------------------------+
|
|
int CExpertSignalAIBase::SnapHorizonToLadder(int rawBars)
|
|
{
|
|
int ladder[BARRIER_HORIZON_LADDER_COUNT] = { 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384 };
|
|
int raw = rawBars;
|
|
if(raw < BARRIER_HORIZON_MIN)
|
|
raw = BARRIER_HORIZON_MIN;
|
|
if(raw > BARRIER_HORIZON_MAX)
|
|
raw = BARRIER_HORIZON_MAX;
|
|
int snapped = ladder[0];
|
|
for(int k = 0; k < BARRIER_HORIZON_LADDER_COUNT; k++)
|
|
if(ladder[k] <= raw)
|
|
snapped = ladder[k];
|
|
return snapped;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| What this pair would actually be labelled under - see the |
|
|
//| declaration for why this is NOT RequiredHorizonBars(). |
|
|
//+------------------------------------------------------------------+
|
|
int CExpertSignalAIBase::GrantedHorizonBars(double slMult, double tpMult)
|
|
{
|
|
return SnapHorizonToLadder(RequiredHorizonBars(slMult, tpMult));
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Break-even INCLUDING the spread - see the declaration comment. |
|
|
//+------------------------------------------------------------------+
|
|
//+------------------------------------------------------------------+
|
|
//| See the declaration. The trade the EA would ACTUALLY have taken |
|
|
//| from this bar, under the exit policy actually in force. |
|
|
//+------------------------------------------------------------------+
|
|
bool CExpertSignalAIBase::SimulateTradeOutcome(int entryIdx, bool isLong, double &rMultiple,
|
|
int &lifespanBars, bool &endedOnVote)
|
|
{
|
|
rMultiple = 0.0;
|
|
lifespanBars = 0;
|
|
endedOnVote = false;
|
|
double atr = m_ATR.Main(entryIdx);
|
|
if(!MathIsValidNumber(atr) || atr <= 0.0)
|
|
return false;
|
|
double entry = m_Close.GetData(entryIdx);
|
|
if(!MathIsValidNumber(entry) || entry <= 0.0)
|
|
return false;
|
|
double slMult, tpMult;
|
|
BarrierMultiples(slMult, tpMult);
|
|
double risk = slMult * atr;
|
|
double reward = tpMult * atr;
|
|
if(risk <= 0.0)
|
|
return false;
|
|
double spread = (double)m_symbol.Spread() * m_symbol.Point();
|
|
if(!MathIsValidNumber(spread) || spread < 0.0)
|
|
spread = 0.0;
|
|
//--- IDENTICAL fill/barrier convention to ComputeLabelForBar's walk, deliberately and by copy: if the
|
|
//--- two ever disagree about what a trade costs, the "simulated vs hold-to-barrier" comparison this
|
|
//--- function exists to produce measures the discrepancy between two pieces of our own arithmetic
|
|
//--- rather than the effect of the exit policy.
|
|
double fill = isLong ? (entry + spread) : (entry - spread);
|
|
double tpLevel = isLong ? (entry + spread + reward) : (entry - reward - spread);
|
|
double slLevel = isLong ? (entry + spread - risk) : (entry + risk - spread);
|
|
//--- Vote-reversal threshold, 0 when the policy has no vote-driven exit. Mirrors the LIVE rule in
|
|
//--- CExpertSignalCustom::CheckClosePosition: the route fires when the AI VOTE has reversed
|
|
//--- against the position and its magnitude reaches the threshold. Both sides are on the 0-100
|
|
//--- win-rate scale as of 2026-08-18 - m_oosDecisionSeries carries the vote, not the softmax
|
|
//--- confidence it used to, and m_exitVoteThreshold is Min_Vote_Close unscaled. A simulation
|
|
//--- that models a different exit rule than the one that runs is worse than no simulation.
|
|
bool voteExitsOn = (!m_exitHoldToBarrier && m_exitVoteThreshold > 0.0 && m_exitVoteThreshold <= 100.0
|
|
&& ArraySize(m_oosDecisionSeries) > 0);
|
|
int last = entryIdx - MathMax(m_barrierHorizonBars, 1);
|
|
if(last < 0)
|
|
last = 0;
|
|
for(int t = entryIdx - 1; t >= last; t--)
|
|
{
|
|
double hi = m_High.GetData(t);
|
|
double lo = m_Low.GetData(t);
|
|
double cl = m_Close.GetData(t);
|
|
if(!MathIsValidNumber(hi) || !MathIsValidNumber(lo) || hi == EMPTY_VALUE || lo == EMPTY_VALUE)
|
|
break;
|
|
lifespanBars = entryIdx - t;
|
|
//--- STOP FIRST on a bar that spans both, same pessimism as the label walk.
|
|
if(isLong ? (lo <= slLevel) : (hi >= slLevel))
|
|
{
|
|
rMultiple = -1.0;
|
|
return true;
|
|
}
|
|
if(isLong ? (hi >= tpLevel) : (lo <= tpLevel))
|
|
{
|
|
rMultiple = reward / risk;
|
|
return true;
|
|
}
|
|
//--- VOTE REVERSAL, checked AFTER the barriers on the same bar. Order matters and this is the
|
|
//--- honest one: intrabar we cannot know whether the barrier or the close came first, and the
|
|
//--- barrier is the outcome the broker would have executed automatically, without waiting for a
|
|
//--- bar close. Checking the vote first would credit the exit policy with escapes that a real
|
|
//--- stop would have taken out of its hands.
|
|
if(voteExitsOn && t < ArraySize(m_oosDecisionSeries))
|
|
{
|
|
double vote = m_oosDecisionSeries[t];
|
|
bool reversed = isLong ? (vote < 0.0) : (vote > 0.0);
|
|
if(reversed && MathAbs(vote) >= m_exitVoteThreshold && MathIsValidNumber(cl) && cl > 0.0)
|
|
{
|
|
//--- Closed at THIS bar's close, at whatever P&L that is - which is the whole point: a
|
|
//--- vote exit produces a CONTINUOUS payoff, not a win or a loss, and that is why an
|
|
//--- exit-aware gate cannot go on scoring win-rate against a fixed break-even.
|
|
rMultiple = isLong ? (cl - fill) / risk : (fill - cl) / risk;
|
|
endedOnVote = true;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
//--- Ran out of horizon (or history) with neither barrier touched: the trade is closed at the last
|
|
//--- bar the walk could see, exactly as the live horizon-timeout would.
|
|
int lastSeen = entryIdx - MathMax(lifespanBars, 1);
|
|
if(lastSeen < 0)
|
|
lastSeen = 0;
|
|
double closeOut = m_Close.GetData(lastSeen);
|
|
if(!MathIsValidNumber(closeOut) || closeOut <= 0.0)
|
|
return false;
|
|
rMultiple = isLong ? (closeOut - fill) / risk : (fill - closeOut) / risk;
|
|
return true;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| See the declaration. Replays this era's OOS calls under the exit |
|
|
//| policy actually in force, and says how far that lands from the |
|
|
//| hold-to-barrier outcome the gate certifies. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::SimulateExitPolicyOutcomes(void)
|
|
{
|
|
int n = ArraySize(m_oosDecisionSeries);
|
|
for(int r = 0; r < n; r++)
|
|
{
|
|
//--- One full forward price walk per directional call, run in a single unchunked pass at the end of
|
|
//--- an era - the longest thing between the last pass-3 yield and Train() returning. Zeroing the
|
|
//--- accumulators on the way out is deliberate: ReportExitPolicyDivergence skips on m_simTrades<=0,
|
|
//--- so a stop drops the line entirely instead of printing an expectancy over the arbitrary prefix
|
|
//--- of trades that happened to run - and it LATCHES m_exitReplayReported, so a partial number
|
|
//--- would be the only one this run ever prints.
|
|
if(ShutdownRequested())
|
|
{
|
|
m_simRSum = 0.0;
|
|
m_simRSumSq = 0.0;
|
|
m_simTrades = 0;
|
|
m_simVoteExits = 0;
|
|
m_simBarrierWins = 0;
|
|
return;
|
|
}
|
|
double vote = m_oosDecisionSeries[r];
|
|
if(vote == 0.0)
|
|
continue; // abstained - no trade to replay
|
|
bool isLong = (vote > 0.0);
|
|
double rMult = 0.0;
|
|
int life = 0;
|
|
bool onVote = false;
|
|
if(!SimulateTradeOutcome(r, isLong, rMult, life, onVote))
|
|
continue;
|
|
m_simRSum += rMult;
|
|
m_simRSumSq += rMult * rMult;
|
|
m_simTrades++;
|
|
if(onVote)
|
|
m_simVoteExits++;
|
|
//--- What the CERTIFICATE counts on this same call, so the two are compared on identical trades
|
|
//--- rather than on two different populations.
|
|
bool barrierWin = (isLong
|
|
? (r < ArraySize(m_winLongCache) && m_winLongCache[r])
|
|
: (r < ArraySize(m_winShortCache) && m_winShortCache[r]));
|
|
if(barrierWin)
|
|
m_simBarrierWins++;
|
|
}
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| See the declaration. The one line that says whether the number |
|
|
//| being certified is still the number that would be traded. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::ReportExitPolicyDivergence(void)
|
|
{
|
|
if(m_simTrades <= 0)
|
|
return;
|
|
bool policyIsBarrier0 = (m_exitHoldToBarrier || m_exitVoteThreshold <= 0.0);
|
|
//--- Every era while vote exits are ON, because then this is load-bearing and its drift is the thing
|
|
//--- to watch. ONCE per run while they are off, because then it is arithmetically guaranteed to agree
|
|
//--- with the certificate and a line per era would be pure noise in an already large journal.
|
|
if(policyIsBarrier0 && m_exitReplayReported)
|
|
return;
|
|
m_exitReplayReported = true;
|
|
double meanR = m_simRSum / m_simTrades;
|
|
//--- SE on the R DISTRIBUTION, not a binomial: once a vote exit can end a trade anywhere between the
|
|
//--- two barriers the payoff is continuous, so "win rate vs break-even" stops being the right
|
|
//--- statistic and expectancy-in-R vs 0 replaces it. Deflated by the label overlap on the same
|
|
//--- doctrine as every other SE here (see EffectiveSampleSize).
|
|
double varR = (m_simRSumSq / m_simTrades) - (meanR * meanR);
|
|
if(varR < 0.0)
|
|
varR = 0.0;
|
|
double effN = EffectiveSampleSize((double)m_simTrades);
|
|
double seR = (effN > 0.0) ? MathSqrt(varR / effN) : 0.0;
|
|
double barrierWinPct = 100.0 * (double)m_simBarrierWins / m_simTrades;
|
|
double votePct = 100.0 * (double)m_simVoteExits / m_simTrades;
|
|
bool policyIsBarrier = (m_exitHoldToBarrier || m_exitVoteThreshold <= 0.0);
|
|
PrintFormat("%s: EXIT-POLICY REPLAY of this era's %d OOS calls - policy in force: %s | simulated"
|
|
" expectancy %+.3f R (2 SE %.3f on %.0f independent trades) | %.0f%% closed by a VOTE"
|
|
" REVERSAL before either barrier | hold-to-barrier win rate on the SAME calls %.1f%%"
|
|
" (break-even %.1f%%). %s",
|
|
ID, m_simTrades,
|
|
policyIsBarrier ? "SL/TP only (no vote exit)" : "vote exit ENABLED",
|
|
meanR, 2.0 * seR, effN, votePct, barrierWinPct, CostAdjustedBreakEvenPct(),
|
|
policyIsBarrier
|
|
? "Vote exits are off, so every trade here resolved at a barrier and this replay is"
|
|
" arithmetically the same trade the deploy gate certifies - the two cannot drift."
|
|
: "VOTE EXITS ARE ON, so these are NOT the trades the deploy gate's win rate describes:"
|
|
" that number grades target-before-stop, and a vote flip inside the horizon is neither."
|
|
" Read the expectancy, not the win rate - a win rate over trades with continuous"
|
|
" payoffs has no fixed break-even to be measured against.");
|
|
}
|
|
double CExpertSignalAIBase::CostAdjustedBreakEvenPct(void)
|
|
{
|
|
double slMult = 0.0, tpMult = 0.0;
|
|
BarrierMultiples(slMult, tpMult);
|
|
double frictionless = (slMult + tpMult > 0.0) ? 100.0 * slMult / (slMult + tpMult) : 50.0;
|
|
if(!MathIsValidNumber(m_spreadAtr) || m_spreadAtr <= 0.0)
|
|
return frictionless;
|
|
//--- A long fills at close+spread, so its target needs (TP - spread) of net travel to pay and its stop
|
|
//--- costs (SL + spread) when it trips. Same convention ReportGeometryExpectancyScan prices its ladder
|
|
//--- with, so the two reports cannot disagree about what a trade costs.
|
|
double reward = tpMult - m_spreadAtr;
|
|
double risk = slMult + m_spreadAtr;
|
|
if(reward <= 0.0 || risk <= 0.0)
|
|
return frictionless; // target inside the spread - not tradeable at any hit rate
|
|
return 100.0 * risk / (risk + reward);
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::BarrierMultiples(double &slMult, double &tpMult)
|
|
{
|
|
//--- Scan override (ReportBarrierGeometryScan). Both must be positive or neither applies, so a half-set
|
|
//--- pair can never silently relabel a live run. Restored to 0 by the scan before it returns; nothing
|
|
//--- else writes these, and no persisted state is keyed on them.
|
|
if(m_barrierScanSlMult > 0.0 && m_barrierScanTpMult > 0.0)
|
|
{
|
|
slMult = m_barrierScanSlMult;
|
|
tpMult = m_barrierScanTpMult;
|
|
return;
|
|
}
|
|
//--- DERIVED geometry wins over the mode constants. Set once from the measured excursion distribution
|
|
//--- (DeriveBarrierGeometry) and then pinned in the .cfg, so a trained model keeps the barriers it
|
|
//--- learned. Below the scan override deliberately: the scan is exploring hypothetical geometries and
|
|
//--- must still be able to impose one.
|
|
if(m_geometryDerived && m_derivedSlMult > 0.0 && m_derivedTpMult > 0.0)
|
|
{
|
|
slMult = m_derivedSlMult;
|
|
tpMult = m_derivedTpMult;
|
|
return;
|
|
}
|
|
slMult = (m_sl_mode == SL_INTELLIGENT_MODE) ? SL_INTELLIGENT_BASE_MULT : (double)m_sl_mode;
|
|
//--- Same floor OpenLongParams/OpenShortParams apply before sizing anything off the stop, reproduced
|
|
//--- here so the label's risk leg cannot be tighter than the one a real order would receive.
|
|
if(slMult < MIN_SL_ATR_MULTIPLIER)
|
|
slMult = MIN_SL_ATR_MULTIPLIER;
|
|
tpMult = (m_tp_mode == TP_INTELLIGENT_MODE) ? (TP_INTELLIGENT_BASE_RR * slMult) : (double)m_tp_mode;
|
|
if(tpMult <= 0.0)
|
|
{
|
|
//--- UNREACHABLE via the Inputs tab: ValidateBarrierInputs() (Warrior_EA.mq5) refuses to start on
|
|
//--- any value that is not an enum member. It is kept, and made LOUD, because the silent version of
|
|
//--- this line is what let a stale TP_PREV_SWING (-101) train four topologies for ~250 eras on a
|
|
//--- 1:1 barrier while the log cheerfully reported "target 1.00*ATR" as if that were configured.
|
|
//--- A fallback that cannot announce itself is indistinguishable from correct behaviour.
|
|
if(!m_barrierFallbackWarned)
|
|
{
|
|
m_barrierFallbackWarned = true;
|
|
Print(ID + ": ERROR - take-profit mode " + IntegerToString(m_tp_mode) + " is not a valid ATR "
|
|
"multiple; the barrier label is falling back to " + DoubleToString(slMult, 2) + "*ATR (1:1). "
|
|
"This should have been caught at init - the model being trained does NOT match the "
|
|
"configured strategy.");
|
|
}
|
|
tpMult = slMult;
|
|
}
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| TRIPLE-BARRIER LABEL for one bar (Lopez de Prado ch. 3). See the |
|
|
//| BARRIER_TIE_GOES_TO_STOP block in Expert\ExpertSignalAIBase.mqh |
|
|
//| for why this replaced the exact-pivot ZigZag target. |
|
|
//| |
|
|
//| Hypothetical entry at bar `idx`'s CLOSE - the same instant the |
|
|
//| feature window ends, so the label answers exactly the question |
|
|
//| the deployed model is asked live: "from what I can see right now, |
|
|
//| does a trade placed here reach its target before its stop?" |
|
|
//| |
|
|
//| Costs are charged. MT5 bar series are BID, so a long fills at ask |
|
|
//| (close + spread) and exits at bid, while a short fills at bid and |
|
|
//| buys back at ask - both legs shifted so the returned outcome is a |
|
|
//| NET result. Spread is taken as the symbol's current value, held |
|
|
//| constant across history: MT5's standard timeseries carries no |
|
|
//| per-bar spread, and a label that ignored the cost entirely would |
|
|
//| report a win rate the account cannot reproduce. |
|
|
//| |
|
|
//| Walks forward in time (toward index 0) for m_barrierHorizonBars. |
|
|
//| Ties inside one bar resolve to the STOP - OHLC cannot order two |
|
|
//| touches within a bar, and the optimistic reading is how a |
|
|
//| backtested edge becomes a live loss. |
|
|
//+------------------------------------------------------------------+
|
|
ENUM_SIGNAL CExpertSignalAIBase::TripleBarrierLabel(int idx)
|
|
{
|
|
//--- CLEARED FIRST, ahead of every early return below. These are published to the caller the way
|
|
//--- m_lastBarrierTimedOut is, and an unresolvable bar that returned before touching them would leave
|
|
//--- the PREVIOUS bar's excursions in place for AdvanceBarrierLabelState to cache against this index -
|
|
//--- one bar's outcome filed under another's, which is exactly the kind of silent contamination the
|
|
//--- excursion measurement is being built to avoid.
|
|
m_lastExcUp = 0.0;
|
|
m_lastExcDown = 0.0;
|
|
//--- MOVED UP from the bottom of the walk (2026-08-09) for exactly the reason written above about the
|
|
//--- excursions: the two early returns below this line return WITHOUT reaching the assignment that
|
|
//--- used to be the only one, so an unresolvable bar published the PREVIOUS bar's timeout verdict. The
|
|
//--- both-won flags are new and are cleared here from the start rather than inheriting that bug.
|
|
m_lastBarrierTimedOut = false;
|
|
m_lastBarrierBothWon = false;
|
|
m_lastBarrierBothWonTied = false;
|
|
m_lastWinLong = false;
|
|
m_lastWinShort = false;
|
|
//--- Cleared with the rest, and for the same reason: an early return must not leave the PREVIOUS bar's
|
|
//--- lifespan for the prebuild to accumulate. 0 = not measured, which the accumulator skips.
|
|
m_lastLabelLifespan = 0;
|
|
double atr = m_ATR.Main(idx);
|
|
if(!MathIsValidNumber(atr) || atr <= 0.0)
|
|
return Neutral; // no volatility scale yet - unresolvable, same practical answer as "no setup"
|
|
double entry = m_Close.GetData(idx);
|
|
if(!MathIsValidNumber(entry) || entry <= 0.0)
|
|
return Neutral;
|
|
double slMult, tpMult;
|
|
BarrierMultiples(slMult, tpMult);
|
|
double risk = slMult * atr;
|
|
double reward = tpMult * atr;
|
|
//--- Round-trip cost, in price. Both sides pay it once.
|
|
double spread = (double)m_symbol.Spread() * m_symbol.Point();
|
|
if(!MathIsValidNumber(spread) || spread < 0.0)
|
|
spread = 0.0;
|
|
//--- Barrier levels expressed in BID terms, which is what m_High/m_Low carry.
|
|
//--- Long fills at close+spread: target needs bid >= fill+reward, stop trips at bid <= fill-risk.
|
|
//--- Short fills at close: target needs bid <= close-reward-spread (it buys back at ask),
|
|
//--- stop trips at bid >= close+risk-spread.
|
|
double longTp = entry + spread + reward;
|
|
double longSl = entry + spread - risk;
|
|
double shortTp = entry - reward - spread;
|
|
double shortSl = entry + risk - spread;
|
|
bool longWon = false, longLost = false, shortWon = false, shortLost = false;
|
|
//--- Bar index at which each target was FIRST reached, for the both-won resolution below. The walk
|
|
//--- runs t = idx-1 downward, i.e. forward in time, so the LARGER t is the earlier touch.
|
|
int longWonAt = -1, shortWonAt = -1;
|
|
//--- AGE (idx - t, so bars AFTER entry) at which each side first resolved either way. The won-at
|
|
//--- indices above cannot serve: they are bar indices rather than ages, and they say nothing about the
|
|
//--- losing side, which is what fixes a Neutral label's lifespan. See m_lastLabelLifespan.
|
|
int longEndAge = 0, shortEndAge = 0;
|
|
//--- Excursion accumulators. Deliberately NOT stopped when a barrier trips: they describe how far
|
|
//--- price travelled over the whole horizon, which is the question a predicted SL/TP needs answered,
|
|
//--- whereas the barriers describe what a trade with THIS geometry would have collected. Truncating
|
|
//--- them at the first touch would bake the current SL/TP back into the measurement of whether a
|
|
//--- different SL/TP is learnable - the circularity the whole exercise is trying to escape.
|
|
double maxHigh = -DBL_MAX, minLow = DBL_MAX; // published values already cleared at the top
|
|
//--- First-passage ladder for THIS bar (see BARRIER_LADDER). Cursors, not a full rescan: the ladder is
|
|
//--- ascending and travel is monotone in the running extreme, so once a level is passed no lower level
|
|
//--- can be reached later - each level is tested until it trips exactly once, which keeps this O(1)
|
|
//--- amortised per walked bar instead of 2 x BARRIER_LADDER_COUNT comparisons on every one.
|
|
ArrayInitialize(m_lastLadderUpAt, 0);
|
|
ArrayInitialize(m_lastLadderDownAt, 0);
|
|
int upCursor = 0, dnCursor = 0;
|
|
//--- Age of the LAST bar the loop actually visited. Not simply the horizon: the walk breaks early when
|
|
//--- it runs off loaded history, and a timeout lifespan of "the full horizon" would then be longer than
|
|
//--- the window that was examined.
|
|
int walkedAge = 0;
|
|
//--- Never longer than the horizon actually walked, so the window cannot claim bars the loop below
|
|
//--- does not visit; falls back to the horizon before the swing median has been measured.
|
|
int excWindow = (m_swingMedianBars > 0)
|
|
? (int)MathMin(m_swingMedianBars, MathMax(m_barrierHorizonBars, 1))
|
|
: MathMax(m_barrierHorizonBars, 1);
|
|
int last = idx - MathMax(m_barrierHorizonBars, 1);
|
|
if(last < 0)
|
|
last = 0;
|
|
for(int t = idx - 1; t >= last; t--)
|
|
{
|
|
double hi = m_High.GetData(t);
|
|
double lo = m_Low.GetData(t);
|
|
if(!MathIsValidNumber(hi) || !MathIsValidNumber(lo) || hi == EMPTY_VALUE || lo == EMPTY_VALUE)
|
|
break; // ran off loaded history - whatever resolved so far stands, the rest times out
|
|
//--- Excursions accumulate only over the REFERENCE WINDOW, not the whole barrier horizon - see
|
|
//--- m_swingMedianBars. The barrier walk below still runs the full horizon, because that is how
|
|
//--- long the trade is actually held; only the MEASUREMENT used to size the barrier is confined to
|
|
//--- a window that does not depend on the barrier.
|
|
if(idx - t <= excWindow)
|
|
{
|
|
if(hi > maxHigh)
|
|
maxHigh = hi;
|
|
if(lo < minLow)
|
|
minLow = lo;
|
|
}
|
|
//--- First-passage ladder. Runs over the WHOLE horizon, not excWindow: this measures how a trade
|
|
//--- held to its barriers would have resolved, so it must see every bar the trade would have been
|
|
//--- open for. Travel is measured from `entry` (the close) with no spread applied - see
|
|
//--- BARRIER_LADDER for why, and for how a level converts back into an SL/TP multiple.
|
|
int age = idx - t;
|
|
walkedAge = age;
|
|
while(upCursor < BARRIER_LADDER_COUNT && hi >= entry + BARRIER_LADDER[upCursor] * atr)
|
|
{
|
|
m_lastLadderUpAt[upCursor] = age;
|
|
upCursor++;
|
|
}
|
|
while(dnCursor < BARRIER_LADDER_COUNT && lo <= entry - BARRIER_LADDER[dnCursor] * atr)
|
|
{
|
|
m_lastLadderDownAt[dnCursor] = age;
|
|
dnCursor++;
|
|
}
|
|
//--- Stop tested FIRST on each side, so a bar that spans both barriers is scored as the loss.
|
|
if(!longWon && !longLost)
|
|
{
|
|
if(lo <= longSl)
|
|
{
|
|
longLost = true;
|
|
longEndAge = age;
|
|
}
|
|
else
|
|
if(hi >= longTp)
|
|
{
|
|
longWon = true;
|
|
longWonAt = t;
|
|
longEndAge = age;
|
|
}
|
|
}
|
|
if(!shortWon && !shortLost)
|
|
{
|
|
if(hi >= shortSl)
|
|
{
|
|
shortLost = true;
|
|
shortEndAge = age;
|
|
}
|
|
else
|
|
if(lo <= shortTp)
|
|
{
|
|
shortWon = true;
|
|
shortWonAt = t;
|
|
shortEndAge = age;
|
|
}
|
|
}
|
|
//--- The early-out that used to sit here (both sides resolved -> break) is GONE, because the
|
|
//--- excursion accumulators above must see the whole horizon and it would have truncated them at
|
|
//--- whichever bar happened to trip the last barrier - making the measured excursion a function of
|
|
//--- the current SL/TP, which is exactly the circularity being escaped. The loop was already
|
|
//--- bounded by m_barrierHorizonBars, so the worst case is unchanged and only the average moves.
|
|
}
|
|
if(maxHigh > -DBL_MAX && minLow < DBL_MAX)
|
|
{
|
|
//--- Same spread convention as the barriers: a long fills at close+spread, so its favourable
|
|
//--- excursion is measured from that fill and its adverse excursion likewise. Clamped at zero -
|
|
//--- a horizon whose every high sits below the fill has no favourable excursion, not a negative one.
|
|
m_lastExcUp = MathMax((maxHigh - (entry + spread)) / atr, 0.0);
|
|
m_lastExcDown = MathMax(((entry + spread) - minLow) / atr, 0.0);
|
|
}
|
|
//--- WHEN THIS LABEL BECAME KNOWABLE, which is what the overlap correction needs - see
|
|
//--- m_lastLabelLifespan. The label is fixed by the FIRST target touched, because the both-won branch
|
|
//--- below resolves by first touch: once one side wins, no later touch on the other side can change the
|
|
//--- answer, and an earlier one would already have been recorded. So a bar with a winner is determined at
|
|
//--- that win, however long the other side takes.
|
|
//--- With no winner the answer is Neutral, but it is not KNOWN to be Neutral until every side that could
|
|
//--- still win has stopped out - or, failing that, until the horizon expires and the trade times out.
|
|
if(longWon || shortWon)
|
|
{
|
|
if(longWon && shortWon)
|
|
m_lastLabelLifespan = (int)MathMin(longEndAge, shortEndAge);
|
|
else
|
|
m_lastLabelLifespan = (longWon ? longEndAge : shortEndAge);
|
|
}
|
|
else
|
|
if(longLost && shortLost)
|
|
m_lastLabelLifespan = (int)MathMax(longEndAge, shortEndAge);
|
|
else
|
|
m_lastLabelLifespan = walkedAge; // a live side ran out of horizon: the timeout IS the decision
|
|
//--- Published BEFORE the collapse to a single label, because the collapse cannot be undone afterwards
|
|
//--- and these are what profitability is actually a function of. Every return below this point carries
|
|
//--- them; the early returns above leave them false, which is correct - an unresolvable bar has no
|
|
//--- winning direction.
|
|
m_lastWinLong = longWon;
|
|
m_lastWinShort = shortWon;
|
|
if(longWon && !shortWon)
|
|
return Buy;
|
|
if(shortWon && !longWon)
|
|
return Sell;
|
|
//--- BOTH TARGETS REACHED. This comment used to say the branch was "unreachable" for every shipped SL/TP
|
|
//--- pairing, and while reward >= risk that was true - reaching one side's stop necessarily crossed the
|
|
//--- other side's nearer target first, so the two outcomes were complementary. Removing the
|
|
//--- minimum-reward:risk raise (2026-08-09) ended that: the MEASURED geometry puts the target at the q50
|
|
//--- of favourable travel and the stop at the q75 of adverse, i.e. target CLOSER than stop, and price
|
|
//--- that swings +target then -target inside one horizon wins in BOTH directions.
|
|
//---
|
|
//--- Falling through to Neutral here was actively harmful, and not marginally: on SP500 H1 it labelled
|
|
//--- ~27% of all bars "do not trade" when a trade in EITHER direction would have collected its target.
|
|
//--- Those are the cleanest positives in the sample, and they were being handed to the model as the
|
|
//--- abstain class - while the confidence threshold downstream was being asked to find selectivity in
|
|
//--- what was left.
|
|
//---
|
|
//--- Resolved by FIRST TOUCH: the target reached earlier is the trade that would have closed first, and
|
|
//--- it is the direction the bar actually moved in before it reversed. Deterministic, and no more
|
|
//--- lookahead than any other part of this walk - it reads the same forward window.
|
|
if(longWon && shortWon)
|
|
{
|
|
m_lastBarrierBothWon = true;
|
|
if(longWonAt > shortWonAt) // larger t = earlier bar, see the declaration
|
|
return Buy;
|
|
if(shortWonAt > longWonAt)
|
|
return Sell;
|
|
//--- Same bar. OHLC carries no intrabar ordering, and the whole file's convention is to refuse the
|
|
//--- ordering it cannot see rather than guess it (BARRIER_TIE_GOES_TO_STOP). Unlike the stop tie
|
|
//--- there is no pessimistic side to fall to - both directions won - so the bar stays Neutral and
|
|
//--- is counted, because a guess here would inject a coin-flip direction into the training target.
|
|
//--- Requires a single bar spanning both targets, ~2x the measured target in range, so it should be
|
|
//--- rare; m_labelPrebuildBothWonTieCount is what confirms that rather than assuming it.
|
|
m_lastBarrierBothWonTied = true;
|
|
return Neutral;
|
|
}
|
|
//--- Neither side resolved AT ALL = the vertical barrier is what ended it. Recorded separately from a
|
|
//--- stop-out because only this outcome says the horizon is too short - see m_lastBarrierTimedOut.
|
|
m_lastBarrierTimedOut = (!longWon && !longLost && !shortWon && !shortLost);
|
|
return Neutral; // timed out, stopped out, or an unorderable both-won tie - nothing tradeable here
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Median distance in bars between consecutive confirmed ZigZag |
|
|
//| pivots - this symbol/timeframe's own swing horizon, and what the |
|
|
//| vertical barrier is set to. Snapped to a coarse ladder so the |
|
|
//| estimate has to move ~30% to change the answer; see the |
|
|
//| BARRIER_HORIZON_* constants for why the quantization matters more |
|
|
//| than the precision (an unquantized horizon that drifted as |
|
|
//| history downloaded would relabel a partly-trained model's |
|
|
//| targets mid-run). |
|
|
//| |
|
|
//| Reads only pivots old enough to be non-repainting, for the same |
|
|
//| reason every other ZigZag read in this class does. |
|
|
//+------------------------------------------------------------------+
|
|
int CExpertSignalAIBase::ComputeBarrierHorizonBars(int bars)
|
|
{
|
|
//--- The ladder itself now lives in SnapHorizonToLadder(), which this function ends by calling.
|
|
int gaps[];
|
|
ArrayResize(gaps, 0);
|
|
int prevPivot = -1;
|
|
int scanned = 0;
|
|
//--- Oldest-to-newest is irrelevant here (a median has no order dependence), so scan newest-first from
|
|
//--- the first non-repainting bar and stop at the history edge.
|
|
for(int p = MathMax(m_swingConfirmationBars, 1); p < bars && scanned < SWING_SCAN_CAP_BARS * 4; p++, scanned++)
|
|
{
|
|
if(m_Open.GetData(p) == EMPTY_VALUE)
|
|
break;
|
|
if(m_ADZigZag.GetData(0, p) == 0.0)
|
|
continue;
|
|
if(prevPivot >= 0)
|
|
{
|
|
int gap = p - prevPivot;
|
|
if(gap > 0)
|
|
{
|
|
int n = ArraySize(gaps);
|
|
ArrayResize(gaps, n + 1);
|
|
gaps[n] = gap;
|
|
}
|
|
}
|
|
prevPivot = p;
|
|
}
|
|
int count = ArraySize(gaps);
|
|
double swingMedian = BARRIER_HORIZON_FALLBACK;
|
|
//--- Published so EnsureBarrierHorizon can refuse to LATCH a fallback: right after a terminal
|
|
//--- restart the ZigZag handle has calculated nothing yet, and a horizon computed from 0 legs is
|
|
//--- the indicator's warm-up state, not a property of the instrument.
|
|
m_barrierHorizonLegStarved = (count < BARRIER_HORIZON_MIN_SAMPLES);
|
|
if(!m_barrierHorizonLegStarved)
|
|
{
|
|
ArraySort(gaps);
|
|
swingMedian = gaps[count / 2];
|
|
}
|
|
else
|
|
if(!m_horizonStarvedWarned)
|
|
{
|
|
m_horizonStarvedWarned = true;
|
|
Print(ID + ": barrier horizon - only " + IntegerToString(count) + " confirmed ZigZag legs available (need " +
|
|
IntegerToString(BARRIER_HORIZON_MIN_SAMPLES) + "), falling back to " +
|
|
IntegerToString(BARRIER_HORIZON_FALLBACK) + " bars PROVISIONALLY - re-resolved on the "
|
|
"next label-cache rebuild, once the indicator has caught up");
|
|
}
|
|
//--- SCALE BY THE BARRIER GEOMETRY. The swing median alone measures how long a ~1 ATR move takes on
|
|
//--- this instrument; it says nothing about how long the CONFIGURED barrier takes to resolve, and the
|
|
//--- first version of this function ignored that entirely.
|
|
//--- For a driftless random walk leaving the band [-m*ATR, +k*ATR], the expected first-passage time is
|
|
//--- proportional to m*k. So a 1:3 barrier takes ~3x as long to resolve as a 1:1 one, and a horizon
|
|
//--- tuned for 1:1 applied to 1:3 would time out most trades - pushing Neutral straight back up and
|
|
//--- re-creating the imbalance the relabel exists to remove.
|
|
//--- Calibrated against a real measurement rather than assumed: the 2026-08-01 run resolved at m=k=1
|
|
//--- with a 12-bar horizon and only 16.7% timeouts, so the swing median IS the right scale at m*k=1.
|
|
//--- Multiplying by m*k carries that calibration to every other barrier (1:3 -> 36, snapping to 32).
|
|
double slMult, tpMult;
|
|
BarrierMultiples(slMult, tpMult);
|
|
//--- THE EXCURSION REFERENCE WINDOW, published UNSCALED. This is a property of the instrument (how
|
|
//--- long its typical swing leg lasts) and owes nothing to the barrier, which is exactly what makes it
|
|
//--- usable for sizing the barrier. Sizing a stop off travel measured over the SCALED horizon below
|
|
//--- is circular: horizon grows with the target, excursions grow with the horizon, the target is a
|
|
//--- quantile of the excursions - so target -> horizon -> excursions -> target diverges. Measured
|
|
//--- 2026-08-07 on EURUSD/USDCAD: it ran away to a 14-15*ATR stop and a 29-31*ATR target that only
|
|
//--- 5.7-7.2% of bars ever reached, and "converged" solely because the ladder caps at 384 bars. A
|
|
//--- saturated runaway, not a fixed point - which is why the iteration guard, watching for
|
|
//--- oscillation, did not catch it.
|
|
m_swingMedianBars = (int)MathMax(MathRound(swingMedian), 1);
|
|
int raw = (int)MathRound(swingMedian * slMult * tpMult);
|
|
//--- CLAMPED means the barrier this geometry describes needs MORE time than the ceiling allows, so the
|
|
//--- label stops being "does the target come before the stop" and quietly becomes "does the target come
|
|
//--- within BARRIER_HORIZON_MAX bars". The deployed EA has no such bar limit - it holds until SL or TP -
|
|
//--- so a clamped label trains the model on a question the strategy never asks, and the unresolved
|
|
//--- remainder all lands in Neutral. Recorded rather than merely clamped because the geometry scan must
|
|
//--- be able to disqualify these: they LOOK informative precisely because a Neutral-dominated label has
|
|
//--- little entropy left to explain.
|
|
m_barrierHorizonClamped = (raw > BARRIER_HORIZON_MAX);
|
|
//--- Clamp + snap live in SnapHorizonToLadder() so the scale ladder can ask the same question about a
|
|
//--- candidate rung without a second copy of the ladder - see its header.
|
|
return SnapHorizonToLadder(raw);
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Resolves m_barrierHorizonBars once per process and logs the whole |
|
|
//| label definition. Called from BOTH the training prebuild and the |
|
|
//| deployed inference path - see the declaration for why a deployed |
|
|
//| model that skipped this would silently learn online from bars |
|
|
//| whose barriers had not resolved. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::EnsureBarrierHorizon(int bars)
|
|
{
|
|
if(m_barrierHorizonResolved)
|
|
return;
|
|
int prevHorizon = m_barrierHorizonBars;
|
|
m_barrierHorizonBars = ComputeBarrierHorizonBars(bars);
|
|
//--- A leg-starved computation is the fallback, not a measurement - keep it PROVISIONAL so the next
|
|
//--- full rebuild recomputes it, instead of latching an indicator warm-up artifact for the process
|
|
//--- lifetime (see m_barrierHorizonLegStarved).
|
|
m_barrierHorizonResolved = !m_barrierHorizonLegStarved;
|
|
//--- If a re-resolution actually MOVED the horizon, any label cached under the old one answers a
|
|
//--- different question - wipe, and let the prebuild refill under one rule. The wipe is cheap
|
|
//--- relative to training on labels from two different horizons, which is the exact failure the
|
|
//--- once-per-process latch exists to prevent.
|
|
if(m_barrierHorizonBars != prevHorizon && ArraySize(m_labelCacheHasValue) > 0)
|
|
{
|
|
ArrayInitialize(m_labelCacheHasValue, false);
|
|
m_labelCachePrebuilt = false;
|
|
}
|
|
double slMultLog, tpMultLog;
|
|
BarrierMultiples(slMultLog, tpMultLog);
|
|
//--- PROVISIONAL vs FINAL. The geometry can only be derived from measured excursions, and excursions only
|
|
//--- exist once bars have been labelled, so the first pass necessarily labels with the enum fallback and
|
|
//--- prints it here. It is then discarded: DeriveBarrierGeometry() runs, the horizon re-resolves, the
|
|
//--- cache is wiped and this line prints again with the measured pair. Reading the log without knowing
|
|
//--- that, the first line looks exactly like a config change that failed to take effect - which is how it
|
|
//--- was in fact read. Say which one this is.
|
|
string stage = m_geometryDerived
|
|
? " | MEASURED geometry, this is what trains"
|
|
: " | PROVISIONAL - enum fallback for the measurement pass only, superseded by the "
|
|
"DERIVED pair logged next";
|
|
if(m_barrierHorizonLegStarved)
|
|
stage += " | horizon PROVISIONAL (ZigZag still warming up, re-resolved on the next rebuild)";
|
|
Print(ID + ": triple-barrier labels - stop " + DoubleToString(slMultLog, 2) + "*ATR, target " +
|
|
DoubleToString(tpMultLog, 2) + "*ATR, horizon " + IntegerToString(m_barrierHorizonBars) +
|
|
" bars (median confirmed ZigZag leg, snapped) | spread charged " +
|
|
IntegerToString(m_symbol.Spread()) + " points | intrabar ties score as the STOP" + stage);
|
|
if(IsFractalTarget())
|
|
Print(ID + ": FRACTAL TARGET active - the training label is the direction to the next confirmed"
|
|
" 5-bar fractal extreme (min move max(2 spreads, 0.10 ATR), outside bars Neutral), NOT the"
|
|
" barrier verdict. The barrier geometry above still sizes the live orders and the win-rate"
|
|
" gate: deploy is decided on what a trade at that SL/TP actually collected.");
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Resolves the triple-barrier label for whichever candidate bar is |
|
|
//| exactly m_barrierHorizonBars behind the one being visited - i.e. |
|
|
//| the newest bar whose outcome is now fully knowable. Mirrors the |
|
|
//| shape of the ZigZag-confirmation scan this replaced, with the |
|
|
//| lookahead depth changed from "how long until a pivot stops |
|
|
//| repainting" to "how long until the trade resolves". |
|
|
//| |
|
|
//| Unlike the ZigZag version, a bar's verdict here is FINAL the |
|
|
//| moment it is computed: the barrier outcome depends only on price |
|
|
//| within a fixed forward window, so nothing later can revise it. |
|
|
//| That is what lets the widening/re-spreading pass this file used |
|
|
//| to need disappear entirely. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::AdvanceBarrierLabelState(int i, int bars)
|
|
{
|
|
int idx = i + MathMax(m_barrierHorizonBars, 1);
|
|
if(idx >= bars || m_labelCacheHasValue[idx])
|
|
return;
|
|
ENUM_SIGNAL verdict = TripleBarrierLabel(idx);
|
|
//--- FRACTAL TARGET: the barrier walk above still runs in full - it fills the excursion/ladder/win
|
|
//--- caches that the measured geometry, the expectancy scan and the era gate's realized-win scoring
|
|
//--- all read - but the TRAINING label it returned is replaced by the fractal-direction verdict.
|
|
//--- The model learns "which way is the next confirmed fractal extreme"; the deploy gate keeps
|
|
//--- scoring what a trade at the EA's own SL/TP actually collected from these bars.
|
|
if(IsFractalTarget())
|
|
verdict = FractalDirectionLabel(idx);
|
|
//--- IS-ONLY, matching the final tally pass exactly. These counters are reported as percentages OF the
|
|
//--- Buy/Sell/Neutral tallies, and those are IS-only - the timeout count was previously incremented over
|
|
//--- the whole scan and then divided by an in-sample denominator, so its "% of Neutral" could read high
|
|
//--- for no reason but the split. A diagnostic that mixes two populations is worse than no diagnostic:
|
|
//--- it is the horizon check, and it has to be trustworthy to do its job.
|
|
bool countable = (idx >= MathMax(2, m_labelPrebuildOosCutoff)
|
|
&& idx <= bars - MathMax(m_historyBars, 0) - 1);
|
|
if(countable)
|
|
{
|
|
//--- MEAN LABEL LIFESPAN, accumulated on the IS population for the same reason the timeout share is:
|
|
//--- it deflates standard errors computed on that population. 0 = the walk could not resolve this
|
|
//--- bar at all, which is not a lifespan of zero - it is no measurement, so it is skipped.
|
|
//--- Deliberately taken from the BARRIER walk even under the fractal target: the overlap being
|
|
//--- corrected for is the barrier outcome window, which every deploy-gate win rate is scored on.
|
|
if(m_lastLabelLifespan > 0)
|
|
{
|
|
m_labelLifespanSum += (double)m_lastLabelLifespan;
|
|
m_labelLifespanCount++;
|
|
}
|
|
if(verdict == Neutral && m_lastBarrierTimedOut)
|
|
m_labelPrebuildTimeoutCount++;
|
|
//--- Counted for EVERY verdict, not just Neutral: after first-touch resolution most both-won bars
|
|
//--- now carry a direction, and the interesting number is how much of the label set this class is -
|
|
//--- not how much of it stayed unresolved.
|
|
if(m_lastBarrierBothWon)
|
|
{
|
|
m_labelPrebuildBothWonCount++;
|
|
if(m_lastBarrierBothWonTied)
|
|
m_labelPrebuildBothWonTieCount++;
|
|
}
|
|
}
|
|
m_labelCacheBuy[idx] = (verdict == Buy);
|
|
m_labelCacheSell[idx] = (verdict == Sell);
|
|
//--- Stored under the SAME validity flag as the label, set last so no reader can see one without the
|
|
//--- other. TripleBarrierLabel() publishes these for the bar it just walked.
|
|
if(idx < ArraySize(m_excUpCache))
|
|
{
|
|
m_excUpCache[idx] = m_lastExcUp;
|
|
m_excDownCache[idx] = m_lastExcDown;
|
|
}
|
|
//--- Published under the SAME validity flag as the label and the excursions, for the same reason:
|
|
//--- a reader must never see one without the others (see BARRIER_LADDER).
|
|
int ladderBase = idx * BARRIER_LADDER_COUNT;
|
|
if(ladderBase + BARRIER_LADDER_COUNT <= ArraySize(m_ladderUpAt))
|
|
for(int L = 0; L < BARRIER_LADDER_COUNT; L++)
|
|
{
|
|
m_ladderUpAt[ladderBase + L] = m_lastLadderUpAt[L];
|
|
m_ladderDownAt[ladderBase + L] = m_lastLadderDownAt[L];
|
|
}
|
|
if(idx < ArraySize(m_winLongCache))
|
|
{
|
|
m_winLongCache[idx] = m_lastWinLong;
|
|
m_winShortCache[idx] = m_lastWinShort;
|
|
}
|
|
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;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| FRACTAL-DIRECTION LABEL for one bar (TrainingTarget=TARGET_ |
|
|
//| FRACTAL). Direction of price from bar idx's close to the NEXT |
|
|
//| confirmed strict 5-bar fractal extreme - the reference library's |
|
|
//| per-bar extremum-direction target, ~balanced by construction. |
|
|
//| |
|
|
//| A bar j is an up-fractal when its high strictly exceeds the highs |
|
|
//| of the two bars on each side (down-fractal mirrored on lows), and |
|
|
//| it is CONFIRMED two bars later - deterministic, no repainting, so |
|
|
//| unlike the ZigZag there is no embargo to wait out and labels |
|
|
//| resolve to within ~2 bars of the present. |
|
|
//| |
|
|
//| The walk runs newer (decreasing index) from idx-1: the first bar |
|
|
//| that proves out as a fractal extreme is the next swing marker in |
|
|
//| time. Costs use the same bid-series convention as the barrier |
|
|
//| label: a long fills at close+spread and is marked against the |
|
|
//| extreme's bid price, mirrored for shorts. A move that cannot |
|
|
//| clear PIVOT_MIN_MOVE (noise floor: whichever is larger of |
|
|
//| 2 spreads or 0.10 ATR) labels Neutral - "the next turn is too |
|
|
//| close to pay for reaching it". An outside bar that is both an up- |
|
|
//| and a down-fractal is directionally unorderable within OHLC and |
|
|
//| labels Neutral for the same reason barrier ties score as the |
|
|
//| stop: the optimistic reading is how a backtest lies. |
|
|
//+------------------------------------------------------------------+
|
|
ENUM_SIGNAL CExpertSignalAIBase::FractalDirectionLabel(int idx)
|
|
{
|
|
double entry = m_Close.GetData(idx);
|
|
double atr = m_ATR.Main(idx);
|
|
if(!MathIsValidNumber(entry) || entry <= 0.0 || !MathIsValidNumber(atr) || atr <= 0.0)
|
|
return Neutral;
|
|
double spread = (double)m_symbol.Spread() * m_symbol.Point();
|
|
if(!MathIsValidNumber(spread) || spread < 0.0)
|
|
spread = 0.0;
|
|
double minMove = MathMax(2.0 * spread, 0.10 * atr);
|
|
//--- p walks FORWARD IN TIME (indices shrink toward now). A fractal at p needs the two newer
|
|
//--- neighbours p-1/p-2 to exist, so the scan stops at p == 2; a bar closer to now than that has an
|
|
//--- unconfirmable label and stays Neutral - same convention as the barrier's unresolved horizon.
|
|
int deepest = idx - 1;
|
|
int shallowest = MathMax(idx - SWING_SCAN_CAP_BARS, 2);
|
|
//--- Leg extremes over every bar visited (the extreme bar included): the conditional MFE/MAE the
|
|
//--- geometry derivation feeds on - travel measured over exactly the leg the label points at.
|
|
double legHi = -DBL_MAX, legLo = DBL_MAX;
|
|
for(int p = deepest; p >= shallowest; p--)
|
|
{
|
|
double h0 = m_High.GetData(p);
|
|
double l0 = m_Low.GetData(p);
|
|
if(h0 == EMPTY_VALUE || l0 == EMPTY_VALUE || !MathIsValidNumber(h0) || !MathIsValidNumber(l0))
|
|
return Neutral; // ran off loaded history before a marker confirmed
|
|
if(h0 > legHi)
|
|
legHi = h0;
|
|
if(l0 < legLo)
|
|
legLo = l0;
|
|
bool up = h0 > m_High.GetData(p + 1) && h0 > m_High.GetData(p + 2)
|
|
&& h0 > m_High.GetData(p - 1) && h0 > m_High.GetData(p - 2);
|
|
bool dn = l0 < m_Low.GetData(p + 1) && l0 < m_Low.GetData(p + 2)
|
|
&& l0 < m_Low.GetData(p - 1) && l0 < m_Low.GetData(p - 2);
|
|
if(!up && !dn)
|
|
continue;
|
|
if(up && dn)
|
|
return Neutral; // outside bar: both extremes, unorderable within OHLC
|
|
ENUM_SIGNAL verdict;
|
|
if(up)
|
|
verdict = (h0 - (entry + spread) >= minMove) ? Buy : Neutral;
|
|
else
|
|
verdict = ((entry - spread) - l0 >= minMove) ? Sell : Neutral;
|
|
//--- Record the labeled leg's conditional excursions - IS region, prebuild passes only, and only
|
|
//--- until the geometry is derived and pinned (see m_fracLegFav's declaration comment). Both
|
|
//--- prebuild passes would otherwise record each bar twice: pass 1 (provisional geometry) fills
|
|
//--- these, derivation runs off them, and pass 2's relabel walk finds m_geometryDerived set.
|
|
if(verdict != Neutral && !m_geometryDerived && m_labelPrebuildActive
|
|
&& idx >= MathMax(2, m_labelPrebuildOosCutoff))
|
|
{
|
|
if(verdict == Buy)
|
|
RecordFractalLegExcursion((legHi - entry) / atr, (entry - legLo) / atr);
|
|
else
|
|
RecordFractalLegExcursion((entry - legLo) / atr, (legHi - entry) / atr);
|
|
}
|
|
return verdict;
|
|
}
|
|
return Neutral; // no fractal inside the scan cap - dead-quiet stretch, nothing to aim at
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| 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)
|
|
{
|
|
//--- Not armed until the history is synced - the caller retries on its next scheduled call. Without
|
|
//--- this, a terminal restart ran the resumed-model pre-scan in the same second as OnInit, against
|
|
//--- whatever the terminal had loaded so far.
|
|
if(!SeriesInfoInteger(m_symbol.Name(), PERIOD_CURRENT, SERIES_SYNCHRONIZED))
|
|
return;
|
|
//--- THE GAP IN THE TEARDOWN GUARDS (ad80e0b), found by the 2026-08-17 21:58 shutdown. Everything below
|
|
//--- is ResizeBuffers + RefreshData over the FULL study window - 33,984 bars on XAUUSD - and it is
|
|
//--- unchunked, so OnDeinit cannot begin until it returns. Normally that is a once-per-run cost and it
|
|
//--- does not matter. It mattered that night because SP500 and XAUUSD LSTM were wedged in the
|
|
//--- "cache invalidated at era start" loop, which calls this on EVERY Train() call: two members
|
|
//--- re-preparing tens of thousands of bars, forever. The terminal closed into that, all three charts hit
|
|
//--- "Abnormal termination" ~5.3 s later with no cleanup-timings line, and 993/1373/1557 chart objects
|
|
//--- were stranded. The stall is a separate bug (see the ANCHOR MOVED / SIZE CHANGED diagnostic); this is
|
|
//--- the reason it took the CHARTS down with it, and it is worth closing on its own.
|
|
if(ShutdownRequested())
|
|
return;
|
|
//--- A model that is still TRAINING sizes its window by the training rule, not by the saved study
|
|
//--- watermark. The watermark of a caught-up model sits at its last studied bar, so on resume
|
|
//--- Bars(dtStudied, now) is ~0 and the whole pipeline downstream ran on an empty window: a zero-bar
|
|
//--- "complete" cache ("Buy: 0 | Sell: 0 | Neutral: 0"), a horizon from 0 ZigZag legs, no geometry.
|
|
//--- Train()'s own era start applies this exact reset (TrainWindowStart) - this makes the pre-scan
|
|
//--- and the era loop agree. Deployed (complete) models keep their watermark: for them dtStudied
|
|
//--- gates INFERENCE recency, and this scan must not touch it.
|
|
if(!m_trainingComplete)
|
|
dtStudied = TrainWindowStart(m_tuneStartTrainBar);
|
|
int barsNow = (int)MathMin(Bars(m_symbol.Name(), PERIOD_CURRENT, dtStudied, TimeCurrent()) + m_historyBars, Bars(m_symbol.Name(), PERIOD_CURRENT));
|
|
//--- Clamped for TWO reasons, only one of which is about labels (see ServableBars()). The labels
|
|
//--- themselves come from price and ADZigZag and would survive a capped MA - but ResizeBuffers()
|
|
//--- sizes EVERY buffer, including m_MA, and a RefreshData() whose CopyBuffer fails leaves that
|
|
//--- buffer EMPTY for whoever reads it next. So an unclamped prebuild here would re-break the very
|
|
//--- feature block Train()'s clamp just repaired, from a path that looks unrelated to it.
|
|
//--- The second reason is grid agreement: a label cache deeper than the sweep that consumes it is
|
|
//--- history training can never reach anyway, and it made labelCacheBars (50,179 in the 2026-08-17
|
|
//--- stall reports) disagree with the depth actually studied.
|
|
//--- Prime first (this call IS the request that makes the terminal calculate that deep), then settle.
|
|
if(!ResizeBuffers(barsNow) || !RefreshData())
|
|
{
|
|
//--- NEVER SILENT AGAIN. This return used to be bare, and on 2026-08-17 it swallowed a hard
|
|
//--- failure for as long as the chart was open: a buffer was being sized one bar past Bars(),
|
|
//--- CheckLoadHistory refused, ResizeBuffers returned false, and the ONLY visible symptom was
|
|
//--- Train() reporting "arming the first label-cache prebuild" forever with labelCacheBars=0.
|
|
//--- MQL5's own "failed to get N bars" line was in the log the whole time and belonged to a
|
|
//--- stack frame nothing connected to the prebuild. Say which depth, and say it is fatal here.
|
|
if(!m_prebuildBlockWarned)
|
|
{
|
|
m_prebuildBlockWarned = true;
|
|
PrintFormat("%s: label prebuild BLOCKED - buffers would not prepare for %d bars. If MQL5"
|
|
" printed 'failed to get %d bars' just above, a buffer is being sized beyond the"
|
|
" %d bars this symbol actually has, and no era can start until that is fixed.",
|
|
ID, barsNow, barsNow, Bars(m_symbol.Name(), PERIOD_CURRENT));
|
|
}
|
|
return; // m_labelCachePrebuilt stays false, retried next call
|
|
}
|
|
int settled = SettledBars(barsNow, "label prebuild");
|
|
if(settled <= 0)
|
|
return; // depth still moving - retried next call, same contract as the line above
|
|
if(settled < barsNow)
|
|
{
|
|
barsNow = settled;
|
|
if(!ResizeBuffers(barsNow) || !RefreshData())
|
|
return;
|
|
}
|
|
EnsureBarCachesCapacity(barsNow);
|
|
//--- Settle the vertical barrier BEFORE the first label is computed. Derived once per process and then
|
|
//--- held: AdvanceBarrierLabelState() indexes off it, so a value that moved mid-scan would leave the
|
|
//--- cache holding labels from two different rules.
|
|
EnsureBarrierHorizon(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_labelPrebuildTimeoutCount = 0;
|
|
m_labelPrebuildBothWonCount = 0;
|
|
m_labelPrebuildBothWonTieCount = 0;
|
|
//--- Reset WITH the cache, not once per process: a rebuild follows a geometry or horizon change, and
|
|
//--- lifespans measured under the old barrier answer a different question. Carrying them forward would
|
|
//--- deflate the new geometry's standard errors by the old geometry's overlap.
|
|
m_labelLifespanSum = 0.0;
|
|
m_labelLifespanCount = 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's class priors are measured, not empty. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::AdvanceLabelCachePrebuild(void)
|
|
{
|
|
const uint PREBUILD_TIME_BUDGET_MS = 80;
|
|
uint chunkStartTick = GetTickCount();
|
|
int i;
|
|
for(i = m_labelPrebuildIndex; i >= 2; i--)
|
|
{
|
|
//--- Already chunked at 80 ms, so this costs at most one chunk - but the tally pass below is NOT
|
|
//--- chunked, and on a stop there is no reason to walk the rest of the window to reach it.
|
|
//--- Resumable by construction: m_labelPrebuildIndex is written before returning either way.
|
|
if(ShutdownRequested())
|
|
{
|
|
m_labelPrebuildIndex = i;
|
|
return;
|
|
}
|
|
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;
|
|
// A barrier label needs m_barrierHorizonBars of FUTURE (lower-index) bars to resolve, so visiting
|
|
// bar i settles the label for bar i+horizon - see AdvanceBarrierLabelState(). Unlike the ZigZag
|
|
// scan this replaced, each verdict is final when written: the outcome depends only on price inside
|
|
// a fixed forward window, so no later iteration can revise it and there is no widening/re-spread
|
|
// pass to run afterwards. The tally still happens in one pass at the end, purely because the loop
|
|
// above is chunked across Train() calls and may resume mid-scan.
|
|
if(!m_labelCacheHasValue[i])
|
|
AdvanceBarrierLabelState(i, m_labelPrebuildBars);
|
|
}
|
|
//--- Final tally pass (IS-only, matches isOOS = (i < oosCutoff) used by the era loop).
|
|
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 class base rates from the real upfront tally instead of leaving
|
|
//--- UpdateClassPriors() nothing to measure (see m_prevEraTrueBuyCount's declaration comment).
|
|
//--- Consumed (and cleared) by Train()'s era-start block on era 0 specifically - 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. This line used to also report "reps up to Nx (M% parity)" and
|
|
//--- "(seeding era 0's class-balance oversampling)" - describing an oversampling pass that the
|
|
//--- logit-adjusted loss had already disabled, and which no longer exists at all since 2026-07-31.
|
|
//--- It was pure fiction in every shipped run, and convincing enough to send a diagnosis down the
|
|
//--- wrong path. A log line must describe what the code DID, not what some earlier version would
|
|
//--- have done: report the measured distribution, which is real and useful, and nothing else.
|
|
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"
|
|
: " | measured imbalance n/a (a directional class has no labeled bars in this window)";
|
|
//--- These three counts are now WIN / LOSS-or-timeout counts under the EA's real stop and target, not
|
|
//--- pivot-spotting counts, so the Buy+Sell share here IS the fraction of bars offering a tradeable
|
|
//--- setup - and the era line's dir-precision against it is a win rate. This is the number that
|
|
//--- decides whether LogitAdjustTau still has a job: at a near-balanced split the log-prior spread
|
|
//--- collapses and the correction (plus its range cap, and the SoftMax port behind it) is redundant.
|
|
int prebuildTotal = m_labelPrebuildBuyCount + m_labelPrebuildSellCount + m_labelPrebuildNeutralCount;
|
|
//--- BOTH-WON composition. Reported unconditionally rather than only when non-zero, because zero is
|
|
//--- itself the answer to "is the target closer than the stop" and a line that vanishes cannot say so.
|
|
//--- The tie share is the one to watch: it is the only part of this class still landing in Neutral, and
|
|
//--- if it is not small then first-touch resolution is not actually recovering these bars.
|
|
string prebuildBothWon = (prebuildTotal > 0)
|
|
? " | both targets reached (target nearer than stop) " + IntegerToString(m_labelPrebuildBothWonCount) +
|
|
" = " + DoubleToString(100.0 * m_labelPrebuildBothWonCount / prebuildTotal, 1) +
|
|
"% of bars, resolved by first touch; " + IntegerToString(m_labelPrebuildBothWonTieCount) +
|
|
" same-bar tie" + (m_labelPrebuildBothWonTieCount == 1 ? "" : "s") + " left Neutral"
|
|
: "";
|
|
string prebuildShare = (prebuildTotal > 0)
|
|
? " | share Buy " + DoubleToString(100.0 * m_labelPrebuildBuyCount / prebuildTotal, 1) +
|
|
"% Sell " + DoubleToString(100.0 * m_labelPrebuildSellCount / prebuildTotal, 1) +
|
|
"% Neutral " + DoubleToString(100.0 * m_labelPrebuildNeutralCount / prebuildTotal, 1) + "%"
|
|
: "";
|
|
Print(ID + ": label cache pre-built - IS true-label distribution -> Buy: " + IntegerToString(m_labelPrebuildBuyCount) +
|
|
" | Sell: " + IntegerToString(m_labelPrebuildSellCount) + " | Neutral: " + IntegerToString(m_labelPrebuildNeutralCount) +
|
|
prebuildRatioInfo + prebuildShare + prebuildBothWon +
|
|
" | of which timed out (horizon too short?) " + IntegerToString(m_labelPrebuildTimeoutCount) +
|
|
(m_labelPrebuildNeutralCount > 0
|
|
? " = " + DoubleToString(100.0 * m_labelPrebuildTimeoutCount / m_labelPrebuildNeutralCount, 1) + "% of Neutral"
|
|
: "") +
|
|
//--- LABEL OVERLAP, printed with the distribution because it is a property of the same
|
|
//--- measurement and because every standard error downstream is divided by it. A mean lifespan
|
|
//--- of L bars means consecutive labels share L-1 bars of outcome window, so these N labels are
|
|
//--- worth about N/L independent observations - see EffectiveSampleSize(). Read it beside the
|
|
//--- horizon: a lifespan approaching the horizon means most labels are running to the vertical
|
|
//--- barrier, which is the timeout share saying the same thing a different way.
|
|
(m_labelLifespanCount > 0
|
|
? StringFormat(" | mean label lifespan %.1f bars of a %d-bar horizon -> %d overlapping labels "
|
|
"are worth ~%d independent ones (every SE below is sized on that)",
|
|
MeanLabelLifespan(), m_barrierHorizonBars, (int)m_labelLifespanCount,
|
|
(int)EffectiveSampleSize((double)m_labelLifespanCount))
|
|
: "") +
|
|
(m_eraCount == 0 ? " (seeding era 0 - triple-barrier targets, so Buy/Sell mean 'target hit before stop')"
|
|
: " (mid-run rebuild after new-bar cache invalidation - era " + IntegerToString(m_eraCount) + " resumes on the relabeled window)"));
|
|
//--- DERIVE THE GEOMETRY FROM WHAT WAS JUST MEASURED, then relabel under it. At era 0, OR whenever
|
|
//--- no pair is pinned yet (m_geometryDerived false): the "only at era 0" form of this gate meant a
|
|
//--- RESUMED model whose .cfg carried no derived pair could never derive - it fell back to the enum
|
|
//--- barriers permanently, relabelling weights that had been trained on the measured pair. The
|
|
//--- mid-run stability the era gate was protecting is carried by m_geometryDerived itself: once a
|
|
//--- pair is derived or adopted it is never re-derived, so a mid-run rebuild still cannot move the
|
|
//--- target under a fitted model.
|
|
//--- NOT while the horizon is leg-starved: the excursion window would be the indicator's warm-up
|
|
//--- fallback, and a pair derived from it gets PINNED (in the .cfg, below) - pinning an artifact.
|
|
//--- Iterated because the horizon scales with the target and the excursions are measured over the
|
|
//--- horizon (see BARRIER_DERIVE_MAX_PASSES) - one pass would size the target from travel measured
|
|
//--- under the previous horizon.
|
|
//--- !m_geometryAdopted: once ReportBarrierGeometryScan has crowned a pairing that cleared its
|
|
//--- family-wise null, that pair is the geometry - re-measuring the scale here would overwrite a
|
|
//--- significance-tested choice with an untested quantile read, on the very pass the adoption itself
|
|
//--- triggers. See m_geometryAdopted for why the scan outranks this function rather than the reverse.
|
|
if((m_eraCount == 0 || !m_geometryDerived) && !m_geometryAdopted && !m_barrierHorizonLegStarved &&
|
|
m_geometryDerivePasses < BARRIER_DERIVE_MAX_PASSES)
|
|
{
|
|
double prevSl = m_derivedSlMult, prevTp = m_derivedTpMult;
|
|
m_geometryDerivePasses++;
|
|
if(DeriveBarrierGeometry())
|
|
{
|
|
bool settled = (prevSl > 0.0 && prevTp > 0.0
|
|
&& MathAbs(m_derivedSlMult - prevSl) <= BARRIER_DERIVE_TOLERANCE * prevSl
|
|
&& MathAbs(m_derivedTpMult - prevTp) <= BARRIER_DERIVE_TOLERANCE * prevTp);
|
|
if(!settled)
|
|
{
|
|
if(m_geometryDerivePasses >= BARRIER_DERIVE_MAX_PASSES)
|
|
Print(ID + StringFormat(": barrier geometry did NOT settle within %d passes (last move "
|
|
"%.2f->%.2f stop, %.2f->%.2f target). Using the latest pair; the "
|
|
"reachability figures above are the ones to check.",
|
|
BARRIER_DERIVE_MAX_PASSES, prevSl, m_derivedSlMult, prevTp,
|
|
m_derivedTpMult));
|
|
else
|
|
{
|
|
//--- Re-derive the horizon for the NEW target and relabel the whole window under it.
|
|
//--- Train()'s !m_labelCachePrebuilt gate restarts the scan on the next call.
|
|
m_barrierHorizonResolved = false;
|
|
m_labelCachePrebuilt = false;
|
|
ArrayInitialize(m_labelCacheHasValue, false);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
//--- PIN THE SETTLED PAIR TO DISK. The .cfg was only ever written at model creation and at
|
|
//--- weights-reset - both BEFORE era 0's derivation - so the derived pair lived exclusively in
|
|
//--- memory: every restart read back zeros, adopted nothing, fell back to the enum barriers, and
|
|
//--- (with the era gate above) could never re-derive. A full day of training on the measured
|
|
//--- 3.33/1.62 pair resumed as 2:6 the moment the terminal restarted. One-shot per process; the
|
|
//--- adoption path sets the flag too, since there the pair is already on disk.
|
|
if(m_geometryDerived && !m_geometryCfgSaved)
|
|
{
|
|
m_geometryCfgSaved = true;
|
|
if(SaveTopologyConfiguration(m_activeFileName, m_initialNeuronsCount, m_hiddenLayersCount,
|
|
m_neuronsReduction, m_minNeuronsCount, m_optimizationAlgo,
|
|
m_historyBars, m_outputNeuronsCount, m_neuronsCount,
|
|
LEGACY_STUDY_PERIOD_SLOT, m_minTrainYear, m_isInitialized,
|
|
LEGACY_CONVERGE_WR_SLOT, m_fractalPeriods, m_convFilterCount,
|
|
m_lstmHiddenSize, m_activeFileCommon))
|
|
Print(ID + StringFormat(": derived geometry PINNED to the .cfg - stop %.2f*ATR, target "
|
|
"%.2f*ATR. A restart now adopts this pair instead of falling back "
|
|
"to the enum barriers.", m_derivedSlMult, m_derivedTpMult));
|
|
else
|
|
Print(ID + ": WARNING - failed to pin the derived geometry to the .cfg; a restart will "
|
|
"re-derive it from the same data instead of adopting it.");
|
|
}
|
|
//--- 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;
|
|
//--- Trigger raised 0.40 -> COLD_START_SEED_MIN_DOMINANCE with the triple-barrier relabel. This seed
|
|
//--- is an antidote to an EXTREME prior: under the old exact-pivot target Neutral held ~94% of bars
|
|
//--- and a uniform-ish random argmax over-called wildly for the first few thousand steps. Barrier
|
|
//--- labels land near 25/25/50, where sigmoid(+-3) ~ 0.95/0.05 is no longer a correction but a
|
|
//--- distortion - it would start the net further from the truth than random init does. Keeping the
|
|
//--- mechanism behind a genuinely-dominant threshold means it stays available for a skewed symbol
|
|
//--- (or a tight-target configuration that pushes Neutral back up) and self-disables otherwise.
|
|
if(totalLabeled > 0 && (double)dominantCount / totalLabeled > COLD_START_SEED_MIN_DOMINANCE)
|
|
{
|
|
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)");
|
|
}
|
|
}
|
|
}
|
|
//--- ConfirmedZigZagLabel() REMOVED 2026-08-01. It was the online-learning path's copy of the exact-pivot
|
|
//--- target; that target is gone, and its one caller now asks TripleBarrierLabel() the same question
|
|
//--- training asks. Keeping a second label rule alive is how the live and trained tasks drift apart.
|
|
#endif // WARRIOR_AIBASE_LABELS_MQH
|