//+------------------------------------------------------------------+ //| 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); 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; 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++) 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))); } //+------------------------------------------------------------------+ bool CExpertSignalAIBase::DeriveBarrierGeometry(void) { int bars = m_labelCacheBars; double up[], dn[]; ArrayResize(up, bars); ArrayResize(dn, bars); int n = 0; //--- 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; 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); 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. double slRaw = dn[(int)MathMin(BARRIER_SL_QUANTILE * n, n - 1)]; double tpRaw = up[(int)MathMin(BARRIER_TP_QUANTILE * n, n - 1)]; //--- 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. //--- REACHABILITY, measured not assumed: what share of bars actually saw an excursion this big. This //--- is the number that catches a target the horizon cannot deliver - the failure that shipped once //--- already, where a clamped horizon quietly made every label "target within 128 bars". int reachTp = 0, reachSl = 0; for(int i = 0; i < n; i++) { if(up[i] >= tpRaw) reachTp++; if(dn[i] >= slRaw) reachSl++; } double tpReach = 100.0 * reachTp / n; double slReach = 100.0 * reachSl / 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; 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), target %.2f*ATR (q%.0f of favourable) | reached " "within the horizon: target on %.1f%% of bars, stop on %.1f%% | implied " "break-even %.1f%%. Replaces the enum multiples; the grid those came from was " "a set of guesses. This does NOT create expectancy - chance precision equals " "break-even at every geometry - it makes the target reachable and the stop " "survivable, both read off the data.", //--- 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 * BARRIER_SL_QUANTILE, m_derivedTpMult, 100.0 * BARRIER_TP_QUANTILE, tpReach, slReach, breakeven)); //--- 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(); //--- Reachability is still worth warning about, but the diagnosis has changed. Nothing inflates the //--- target any more, so a target the market rarely reaches can only mean the HORIZON is too short //--- for the favourable travel this instrument actually produces - the q50 of MFE should by //--- construction be reached about half the time, and a reading far under that says the excursion //--- window is cutting the measurement off. That is a horizon problem, not a ratio problem, and it //--- is the same failure the clamped-horizon incident produced. if(tpReach < BARRIER_MIN_TP_REACH_PCT) Print(ID + StringFormat(": WARNING - the MEASURED target of %.2f*ATR is reached on only %.1f%% of " "bars inside the %d-bar horizon. It was taken from the q%.0f of favourable " "travel, so it should be reached on roughly %.0f%% - a reading this far " "below means the horizon is truncating the excursions the geometry is " "derived from, and the labels inherit that truncation.", m_derivedTpMult, tpReach, m_barrierHorizonBars, 100.0 * BARRIER_TP_QUANTILE, 100.0 * (1.0 - BARRIER_TP_QUANTILE))); return true; } //+------------------------------------------------------------------+ 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; 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; //--- 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; //--- 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; 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; else if(hi >= longTp) { longWon = true; longWonAt = t; } } if(!shortWon && !shortLost) { if(hi >= shortSl) shortLost = true; else if(lo <= shortTp) { shortWon = true; shortWonAt = t; } } //--- 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); } //--- 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) { int ladder[BARRIER_HORIZON_LADDER_COUNT] = { 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384 }; 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); if(raw < BARRIER_HORIZON_MIN) raw = BARRIER_HORIZON_MIN; if(raw > BARRIER_HORIZON_MAX) raw = BARRIER_HORIZON_MAX; //--- Snap DOWN to the ladder, matching ComputeFirstLayerWidth()'s direction: a horizon shorter than //--- measured makes the label stricter (more Neutral), never more permissive. int snapped = ladder[0]; for(int k = 0; k < BARRIER_HORIZON_LADDER_COUNT; k++) if(ladder[k] <= raw) snapped = ladder[k]; return snapped; } //+------------------------------------------------------------------+ //| 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); } //+------------------------------------------------------------------+ //| 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); //--- 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) { 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; } //+------------------------------------------------------------------+ //| 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; //--- 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)); if(!ResizeBuffers(barsNow) || !RefreshData()) return; // couldn't prep buffers yet - m_labelCachePrebuilt stays false, retried next call 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; 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--) { 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" : "") + (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. if((m_eraCount == 0 || !m_geometryDerived) && !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