forked from animatedread/Warrior_EA
The diagnostic shipped in de382bb came back off both live charts and
confirmed the arithmetic exactly:
CLOSE-ALL BUDGET - flattens every position every 29 bars ... an entry
landing anywhere in the cycle gets 15 bars on average. The horizon
ladder just granted 128.
So the ceiling the ladder was rejecting rungs against - BARRIER_HORIZON_MAX,
384 - never bound anything, while the one that does bind was invisible to
it. SnapHorizonToLadder and the scale ladder's fitsH test now both read
EffectiveHorizonMax(), which is the measured close-all cycle. One
function, so the ceiling cannot be lowered in the snap and left high in
the rejection test.
The CYCLE, not the 15-bar mean: a Monday entry really does get the whole
cycle, and rejecting on the mean would invent a second criterion where
the design deliberately has one ceiling and reports the milder snap-down
truncation instead of rejecting on it.
Expect the ladder to pick a NARROWER pair, which is what the MEASURE
objective already asks for - min provable EV grows as width squared, and
USDJPY's 6.00*ATR target was being asked of a trade that lives ~11 bars.
"Schedule off" is cached; "not enough bars loaded yet" is not. Caching
the latter would restore the 384-bar ceiling for the whole process
because one early call landed before history arrived.
RE-KEYS EVERY FINGERPRINT - the horizon is a label parameter, so this is
a full retrain on both charts. Done now because both are at era 0 after
a fresh deploy, which is the cheapest this change will ever be.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1946 lines
104 KiB
MQL5
1946 lines
104 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| Warrior_EA |
|
|
//| AnimateDread |
|
|
//| |
|
|
//| Triple-barrier labelling and the async label-cache prebuild. |
|
|
//+------------------------------------------------------------------+
|
|
#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);
|
|
ArrayResize(m_termTravelCache, 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. |
|
|
//+------------------------------------------------------------------+
|
|
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). |
|
|
//+------------------------------------------------------------------+
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
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.
|
|
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.
|
|
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. 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.
|
|
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);
|
|
//--- THE STOP LADDER, read in one call. MathQuantile sorts its own copy, so up[]/dn[] keep the bar
|
|
//--- order their labels are paired by - which is what the consistency report below needs, and what
|
|
//--- an explicit unsorted copy used to be kept for. up[] was being sorted for no consumer at all.
|
|
double slLadder[];
|
|
if(!MathQuantile(dn, BARRIER_SL_QUANTILE_LADDER, slLadder))
|
|
{
|
|
Print(ID + StringFormat(": barrier geometry NOT derived - the stop ladder could not be read off"
|
|
" %d adverse-excursion samples. Falling back to the configured %d:%d.",
|
|
n, m_sl_mode, m_tp_mode));
|
|
return false;
|
|
}
|
|
//--- STOP from the ADVERSE distribution, TARGET from the FAVOURABLE one - each leg sized by the
|
|
//--- thing it actually has to survive or reach. Every rung is reported so the choice is
|
|
//--- auditable.
|
|
double slRaw = 0.0, tpRaw = 0.0;
|
|
double chosenQ = 0.0, chosenReach = 0.0, chosenRr = BARRIER_TARGET_RR_MIN;
|
|
string rungRows = "";
|
|
for(int r = 0; r < BARRIER_SL_QUANTILE_COUNT; r++)
|
|
{
|
|
double q = BARRIER_SL_QUANTILE_LADDER[r];
|
|
double sl = slLadder[r];
|
|
if(sl < MIN_SL_ATR_MULTIPLIER)
|
|
sl = MIN_SL_ATR_MULTIPLIER;
|
|
//--- RATIO = the policy FLOOR, raised toward what the swings actually offer - and the raise
|
|
//--- has to be EARNED against measured reachability, one rung at a time.
|
|
double rrWant = (m_swingMedianLegAtr > 0.0)
|
|
? BarrierSnapRr(m_swingMedianLegAtr / sl) : BARRIER_TARGET_RR_MIN;
|
|
double rr = rrWant;
|
|
double effSl = 0.0, effTp = 0.0;
|
|
double reach = 0.0;
|
|
for(;;)
|
|
{
|
|
reach = LadderWinShare(idxList, n, sl, rr * sl, effSl, effTp);
|
|
//--- Floor reached, or this ratio is reachable: either way the walk is over. BarrierStepDownRr
|
|
//--- is strictly decreasing and bottoms at BARRIER_TARGET_RR_MIN, so this cannot spin.
|
|
if(reach >= BarrierMinReachPct(rr) || rr <= BARRIER_TARGET_RR_MIN + 0.01)
|
|
break;
|
|
rr = BarrierStepDownRr(rr);
|
|
}
|
|
double tp = rr * sl;
|
|
//--- Printed so a raise that was proposed and then walked back is visible as exactly that, not
|
|
//--- as a rung that never wanted more.
|
|
string rrNote = (rrWant > rr + 0.01)
|
|
? StringFormat(" [legs proposed 1:%.1f, unreached]", rrWant) : "";
|
|
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.
|
|
double rungLife = (m_lastRungLifespan > 0.0) ? m_lastRungLifespan : 1.0;
|
|
double rungEffN = MathMax((double)n / rungLife, 2.0);
|
|
double beP = 1.0 / (1.0 + rr); // THIS rung's break-even, not the floor's
|
|
double rungSE = BinomialSEPct(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. 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 <= EffectiveHorizonMax());
|
|
//--- 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=1:%.1f%s 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, rr, rrNote, 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.
|
|
bool eligible = fitsH && costOK && reach >= BarrierMinReachPct(rr);
|
|
bool takeIt = (BARRIER_SCALE_OBJECTIVE == BARRIER_SCALE_DEPLOY) ? (slRaw <= 0.0) : true;
|
|
if(eligible && takeIt)
|
|
{
|
|
slRaw = sl;
|
|
tpRaw = tp;
|
|
chosenQ = q;
|
|
chosenReach = reach;
|
|
chosenRr = rr;
|
|
}
|
|
}
|
|
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 = slLadder[BARRIER_SL_QUANTILE_COUNT - 1];
|
|
if(slRaw < MIN_SL_ATR_MULTIPLIER)
|
|
slRaw = MIN_SL_ATR_MULTIPLIER;
|
|
//--- SAME STEP-DOWN AS THE LOOP, and leaving it out is what actually shipped the 20.7:1
|
|
//--- labels: this branch runs precisely when no rung was reachable, so re-proposing the raw
|
|
//--- leg-implied ratio here re-applies the ratio that had just been rejected everywhere.
|
|
double fbSl = 0.0, fbTp = 0.0;
|
|
double rrWantFb = (m_swingMedianLegAtr > 0.0)
|
|
? BarrierSnapRr(m_swingMedianLegAtr / slRaw) : BARRIER_TARGET_RR_MIN;
|
|
chosenRr = rrWantFb;
|
|
for(;;)
|
|
{
|
|
chosenReach = LadderWinShare(idxList, n, slRaw, chosenRr * slRaw, fbSl, fbTp);
|
|
if(chosenReach >= BarrierMinReachPct(chosenRr) || chosenRr <= BARRIER_TARGET_RR_MIN + 0.01)
|
|
break;
|
|
chosenRr = BarrierStepDownRr(chosenRr);
|
|
}
|
|
tpRaw = chosenRr * slRaw;
|
|
chosenQ = q;
|
|
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_MIN if that proves "
|
|
"untrainable.",
|
|
chosenRr, BarrierMinReachPct(chosenRr), m_barrierHorizonBars,
|
|
EffectiveHorizonMax(), 100.0 * chosenQ, chosenReach));
|
|
}
|
|
int meanBudget = 0;
|
|
int cycleBars = MeasureCloseAllBudget(meanBudget);
|
|
if(cycleBars > 0)
|
|
Print(ID + StringFormat(": CLOSE-ALL BUDGET - the scheduled close-all flattens every position"
|
|
" every %d bars, so no trade from this chart can live longer than that"
|
|
" and an entry landing anywhere in the cycle gets %d bars on average."
|
|
" The horizon ladder just granted %d. Measured 2026-08-22: EVERY label"
|
|
" timeout on this chart was the close-all and NONE was the horizon, so"
|
|
" the horizon is not the binding barrier - the close-all is, and the"
|
|
" horizon ceiling is now this cycle rather than the %d-bar"
|
|
" BARRIER_HORIZON_MAX, which never bound anything. A target needing"
|
|
" more than %d bars is unreachable however reachable the excursion scan"
|
|
" says it is, so the ladder is expected to pick a NARROWER pair - which"
|
|
" is what the MEASURE objective wants anyway, since min provable EV"
|
|
" grows as width squared.",
|
|
cycleBars, meanBudget, m_barrierHorizonBars, BARRIER_HORIZON_MAX,
|
|
meanBudget));
|
|
Print(ID + StringFormat(": barrier SCALE ladder - objective %s (ratio: policy MINIMUM 1:%.1f, raised per rung toward the median swing leg of %.2f*ATR where the stop leaves room). Every "
|
|
"rung must clear 60%% of its OWN break-even in reachability (%.0f%% at the "
|
|
"minimum ratio, looser above it), 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_MIN, m_swingMedianLegAtr,
|
|
BarrierMinReachPct(BARRIER_TARGET_RR_MIN), EffectiveHorizonMax(),
|
|
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), EffectiveHorizonMax()));
|
|
//--- 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). The model was
|
|
//--- then trained to predict an outcome that essentially never happens.
|
|
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%s) | 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, chosenRr,
|
|
(chosenRr > BARRIER_TARGET_RR_MIN + 0.01
|
|
? ", RAISED above the 1:2 policy floor by the median swing leg"
|
|
: ", the policy MINIMUM ratio - the swings did not offer more"),
|
|
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. Nothing was broken. They measure different windows:
|
|
if(!conditional && ArraySize(labUp) >= n && n > 0)
|
|
{
|
|
int excReach = 0, buyCount = 0;
|
|
for(int i = 0; i < n; i++)
|
|
{
|
|
if(up[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.
|
|
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 < BarrierMinReachPct(chosenRr))
|
|
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_MIN.",
|
|
m_derivedTpMult, chosenRr, 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) const
|
|
{
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalAIBase::EffectiveSampleSize(double rawN) const
|
|
{
|
|
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);
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Bars between scheduled close-alls, and what an entry really gets. |
|
|
//| |
|
|
//| Returns the CYCLE length (the most any trade can live) and sets |
|
|
//| meanBudgetBars to the mean over entries spread through the cycle, |
|
|
//| which is what an average bar is labelled under. Measured off the |
|
|
//| real bar series, so it is session- and DST-correct rather than |
|
|
//| arithmetic on a nominal week. |
|
|
//| |
|
|
//| WHY (2026-08-22): every single label timeout on both live charts |
|
|
//| was the close-all, none was the horizon - 14417 of 14417 on |
|
|
//| USDJPY, 2434 of 2434 on SP500. The horizon ladder had granted 96 |
|
|
//| bars to a trade that is flattened every Friday. |
|
|
//+------------------------------------------------------------------+
|
|
int CExpertSignalAIBase::MeasureCloseAllBudget(int &meanBudgetBars)
|
|
{
|
|
meanBudgetBars = 0;
|
|
int cutSec = PeriodSeconds(m_period);
|
|
if(cutSec <= 0)
|
|
return 0;
|
|
int bars = (int)MathMin(Bars(m_symbol.Name(), m_period), 4000);
|
|
if(bars < 8)
|
|
return 0;
|
|
//--- Walk oldest -> newest, counting bars between the cut points the label walk itself would hit.
|
|
int spans = 0, spanSum = 0, run = 0;
|
|
datetime cut = 0;
|
|
for(int i = bars - 1; i >= 0; i--)
|
|
{
|
|
datetime t = m_Time.GetData(i);
|
|
if(t <= 0)
|
|
continue;
|
|
if(cut <= 0)
|
|
{
|
|
cut = NextScheduledCloseAll((datetime)(t + cutSec));
|
|
if(cut <= 0)
|
|
return 0; // schedule off - the horizon really is the only barrier
|
|
continue;
|
|
}
|
|
run++;
|
|
if((datetime)(t + cutSec) > cut)
|
|
{
|
|
spans++;
|
|
spanSum += run;
|
|
run = 0;
|
|
cut = NextScheduledCloseAll((datetime)(t + cutSec));
|
|
if(cut <= 0)
|
|
break;
|
|
}
|
|
}
|
|
if(spans <= 0)
|
|
return 0;
|
|
int cycle = (int)MathRound((double)spanSum / spans);
|
|
//--- An entry lands uniformly inside the cycle, so it gets half of one on average.
|
|
meanBudgetBars = (int)MathMax(1, MathRound(cycle / 2.0));
|
|
return cycle;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| The horizon ceiling, lowered to what the close-all really grants. |
|
|
//| |
|
|
//| BARRIER_HORIZON_MAX is 384 bars. The scheduled close-all flattens |
|
|
//| every position on a ~29-bar cycle (H4, CLOSE_FRIDAY), so a label |
|
|
//| granted more than that is describing a trade that cannot exist - |
|
|
//| and the ladder was granting 128. Measured 2026-08-22: EVERY |
|
|
//| timeout on both charts was the close-all, NONE was the horizon. |
|
|
//| |
|
|
//| The CYCLE, not the ~15-bar mean an average entry gets: a Monday |
|
|
//| entry really does get the whole cycle, and rejecting on the mean |
|
|
//| would invent a second rule where the design has one ceiling. |
|
|
//| Measured once and cached - the scale ladder asks per rung. |
|
|
//+------------------------------------------------------------------+
|
|
int CExpertSignalAIBase::EffectiveHorizonMax(void)
|
|
{
|
|
if(m_closeAllCycleBars == 0)
|
|
{
|
|
//--- "Schedule off" is a PERMANENT answer and is cached. "Not enough bars loaded yet" is NOT -
|
|
//--- caching that would silently restore the 384-bar ceiling for the whole process because one
|
|
//--- early call happened before history arrived.
|
|
if((int)targetDayOfWeek == -1 || (int)targetHour == -1 || (int)targetMinutes == -1)
|
|
m_closeAllCycleBars = -1;
|
|
else
|
|
{
|
|
int mean = 0;
|
|
int cycle = MeasureCloseAllBudget(mean);
|
|
if(cycle <= 0)
|
|
return BARRIER_HORIZON_MAX; // not measurable yet - retry next call, cache nothing
|
|
m_closeAllCycleBars = cycle;
|
|
m_closeAllMeanBudget = mean;
|
|
}
|
|
}
|
|
if(m_closeAllCycleBars <= 0)
|
|
return BARRIER_HORIZON_MAX; // schedule off - the horizon really is the only barrier
|
|
return (int)MathMax(BARRIER_HORIZON_MIN, MathMin(BARRIER_HORIZON_MAX, m_closeAllCycleBars));
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| THE horizon ladder, and the only copy of it. |
|
|
//+------------------------------------------------------------------+
|
|
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;
|
|
int ceiling = EffectiveHorizonMax();
|
|
if(raw < BARRIER_HORIZON_MIN)
|
|
raw = BARRIER_HORIZON_MIN;
|
|
if(raw > ceiling)
|
|
raw = ceiling;
|
|
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,
|
|
bool &endedOnTimeout)
|
|
{
|
|
rMultiple = 0.0;
|
|
endedOnTimeout = false;
|
|
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;
|
|
//--- Same broker-minimum widening as TripleBarrierLabel (see the note there): this simulates
|
|
//--- the live trade, so it wears the live constraints.
|
|
double simMinStop = TCMinStopDistance(m_symbol.Name());
|
|
if(simMinStop > 0.0)
|
|
{
|
|
if(risk < simMinStop)
|
|
risk = simMinStop;
|
|
if(reward < simMinStop)
|
|
reward = simMinStop;
|
|
}
|
|
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 TripleBarrierLabel'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. 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;
|
|
//--- THE SCHEDULED CLOSE-ALL IS THIS WALK'S SECOND VERTICAL BARRIER TOO (2026-08-21).
|
|
int cutBarSec = PeriodSeconds(m_period);
|
|
datetime simCut = NextScheduledCloseAll((datetime)(m_Time.GetData(entryIdx) + cutBarSec));
|
|
for(int t = entryIdx - 1; t >= last; t--)
|
|
{
|
|
//--- Ahead of the price reads, so lifespanBars keeps the last bar actually HELD and the fall-
|
|
//--- through below closes there. Identical placement to the label's cut, deliberately.
|
|
if(simCut > 0 && (datetime)(m_Time.GetData(t) + cutBarSec) > simCut)
|
|
break;
|
|
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. 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;
|
|
}
|
|
}
|
|
}
|
|
//--- Neither barrier touched: the trade is closed at the last bar the walk could see. Three ways to
|
|
//--- get here and they are one economic event - ran out of horizon, ran off loaded history, or was
|
|
//--- flattened by the scheduled close-all - so all three are counted as timeouts and paid at
|
|
//--- whatever the close was, exactly as the live EA would have.
|
|
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;
|
|
endedOnTimeout = true;
|
|
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.
|
|
if(ShutdownRequested())
|
|
{
|
|
m_simRSum = 0.0;
|
|
m_simRSumSq = 0.0;
|
|
m_simTrades = 0;
|
|
m_simVoteExits = 0;
|
|
m_simBarrierWins = 0;
|
|
m_simTpHits = 0;
|
|
m_geoTrades = 0;
|
|
m_simTimeouts = 0;
|
|
m_simTimeoutRSum = 0.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, onTimeout = false;
|
|
if(!SimulateTradeOutcome(r, isLong, rMult, life, onVote, onTimeout))
|
|
continue;
|
|
m_simRSum += rMult;
|
|
m_simRSumSq += rMult * rMult;
|
|
m_simTrades++;
|
|
//--- Same call, scored under the incumbent pair AND the pair this bar's excursion head would
|
|
//--- have chosen. Rides this walk rather than opening its own so the two populations cannot
|
|
//--- differ - see ReportCandidateGeometry.
|
|
ScoreCandidateGeometry(r, isLong);
|
|
if(onVote)
|
|
m_simVoteExits++;
|
|
//--- The population CostAdjustedBreakEvenPct assumes away - see EmpiricalBreakEvenPct.
|
|
if(onTimeout)
|
|
{
|
|
m_simTimeouts++;
|
|
m_simTimeoutRSum += rMult;
|
|
}
|
|
//--- 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++;
|
|
//--- Target before stop in the SIMULATION. A non-vote non-timeout outcome is exactly -1.0 or
|
|
//--- +reward/risk, so the sign identifies it without a fourth out-param.
|
|
if(!onVote && !onTimeout && rMult > 0.0)
|
|
m_simTpHits++;
|
|
}
|
|
//--- Latch this completed era's measurement for the next one to read - see m_lastTimeoutShare.
|
|
if(m_simTrades > 0)
|
|
{
|
|
m_lastTimeoutShare = (double)m_simTimeouts / m_simTrades;
|
|
m_lastTimeoutMeanR = (m_simTimeouts > 0) ? m_simTimeoutRSum / m_simTimeouts : 0.0;
|
|
}
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| 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. Every quantity on this line is MEASURED and moves with geometry and
|
|
//--- volatility, so it needs a cadence, not one print.
|
|
if(policyIsBarrier0 && m_exitReplayReported && !TrainLogDue())
|
|
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.
|
|
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;
|
|
//--- The SAME question answered by the other walk. Equal is the contract; the difference is the
|
|
//--- only thing that can tell the operator the two have drifted apart again.
|
|
double simWinPct = 100.0 * (double)m_simTpHits / m_simTrades;
|
|
double votePct = 100.0 * (double)m_simVoteExits / m_simTrades;
|
|
bool policyIsBarrier = (m_exitHoldToBarrier || m_exitVoteThreshold <= 0.0);
|
|
//--- THE BAR THIS EXPECTANCY ACTUALLY CROSSES. Measured 2026-08-21 on SP500 H4: the replay
|
|
//--- crossed zero at an implied 33.3% against a frictionless 33.24%.
|
|
double slMultBe = 0.0, tpMultBe = 0.0;
|
|
BarrierMultiples(slMultBe, tpMultBe);
|
|
double frictionlessBePct = (slMultBe + tpMultBe > 0.0)
|
|
? 100.0 * slMultBe / (slMultBe + tpMultBe) : 50.0;
|
|
//--- THE BREAK-EVENS, side by side, because they disagree and only one of them is measured.
|
|
//--- t and m are the numbers CostAdjustedBreakEvenPct cannot see - see EmpiricalBreakEvenPct.
|
|
double timeoutPct = 100.0 * (double)m_simTimeouts / m_simTrades;
|
|
double timeoutMeanR = (m_simTimeouts > 0) ? m_simTimeoutRSum / m_simTimeouts : 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 | target-before-stop %.1f%% in THIS walk vs %.1f%%"
|
|
" in the LABEL on the SAME calls (%+.1fpp; two walks, and anything but ~0 means they"
|
|
" have drifted apart again - the expectancy above is this walk's)"
|
|
" | BREAK-EVEN frictionless %.1f%% vs cost-adjusted %.1f%% vs horizon-aware %.1f%%"
|
|
" (%.1f%% of trades reached NEITHER barrier and paid %+.3f R each). The R here is"
|
|
" P&L/risk with the barriers placed off the FILL, so a win pays exactly TP/SL and a"
|
|
" loss exactly -1: the frictionless figure is the one this expectancy crosses zero at."
|
|
" %s",
|
|
ID, m_simTrades,
|
|
policyIsBarrier ? "SL/TP only (no vote exit)" : "vote exit ENABLED",
|
|
meanR, 2.0 * seR, effN, votePct, simWinPct, barrierWinPct, simWinPct - barrierWinPct,
|
|
frictionlessBePct, CostAdjustedBreakEvenPct(), EmpiricalBreakEvenPct(),
|
|
timeoutPct, timeoutMeanR,
|
|
policyIsBarrier
|
|
? "Vote exits are off, so every trade here resolved at a barrier or at a vertical one,"
|
|
" and the two walks should now agree call for call."
|
|
: "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.
|
|
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. 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;
|
|
}
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| INTELLIGENT trade direction: measure the drift, pick the |
|
|
//| side(s). The label cache's Buy/Sell shares ARE the win rates of |
|
|
//| taking every bar long/short at the REAL geometry with costs |
|
|
//| charged - their gap is the drift at this exact geometry. |
|
|
//+------------------------------------------------------------------+
|
|
void CExpertSignalAIBase::RefreshDriftVerdict(void)
|
|
{
|
|
//--- THE WIN CACHES, not the collapsed labels. This line reports always-long vs always-short win
|
|
//--- rates, and that is what m_winLongCache/m_winShortCache hold - each side scored on its own
|
|
//--- barriers, published before the collapse.
|
|
int buys = 0, sells = 0, totalLbl = 0;
|
|
int scanBars = MathMin(m_labelCacheBars, ArraySize(m_labelCacheHasValue));
|
|
scanBars = MathMin(scanBars, MathMin(ArraySize(m_winLongCache), ArraySize(m_winShortCache)));
|
|
for(int i = 0; i < scanBars; i++)
|
|
{
|
|
if(!m_labelCacheHasValue[i])
|
|
continue;
|
|
totalLbl++;
|
|
if(m_winLongCache[i])
|
|
buys++;
|
|
if(m_winShortCache[i])
|
|
sells++;
|
|
}
|
|
if(totalLbl <= 0)
|
|
return;
|
|
double effN = EffectiveSampleSize((double)totalLbl);
|
|
if(effN < 30.0)
|
|
return; // too little independent evidence to call a drift - stay/remain fail-open
|
|
double pL = (double)buys / totalLbl;
|
|
double pS = (double)sells / totalLbl;
|
|
double seL = 100.0 * MathSqrt(MathMax(pL * (1.0 - pL), 0.0) / effN);
|
|
double seS = 100.0 * MathSqrt(MathMax(pS * (1.0 - pS), 0.0) / effN);
|
|
//--- Independence assumed, which is CONSERVATIVE here: a bar where both sides reached their target
|
|
//--- counts on both, so the rates are positively correlated and the true SE of their gap is smaller.
|
|
double seGap = MathSqrt(seL * seL + seS * seS);
|
|
double gapPp = 100.0 * (pL - pS);
|
|
double breakEven = CostAdjustedBreakEvenPct();
|
|
TRADING_DIRECTION driftVerdict = BOTH;
|
|
if(MathAbs(gapPp) >= 2.0 * seGap)
|
|
{
|
|
if(gapPp > 0.0 && 100.0 * pS < breakEven)
|
|
driftVerdict = LONG_ONLY;
|
|
else
|
|
if(gapPp < 0.0 && 100.0 * pL < breakEven)
|
|
driftVerdict = SHORT_ONLY;
|
|
}
|
|
if(driftVerdict != g_warriorDriftVerdict || !g_warriorDriftMeasured)
|
|
{
|
|
g_warriorDriftMeasured = true;
|
|
g_warriorDriftVerdict = driftVerdict;
|
|
PrintFormat("%s: INTELLIGENT direction verdict - always-long %.1f%% vs always-short %.1f%%"
|
|
" at this geometry (gap %+.1fpp, 2SE band %.1fpp on %.0f effective samples,"
|
|
" break-even %.1f%%) -> %s.%s",
|
|
ID, 100.0 * pL, 100.0 * pS, gapPp, 2.0 * seGap, effN, breakEven,
|
|
driftVerdict == LONG_ONLY ? "LONG only"
|
|
: (driftVerdict == SHORT_ONLY ? "SHORT only" : "both sides"),
|
|
(tradingdirection == DIRECTION_INTELLIGENT)
|
|
? ""
|
|
: " (informational: the Trade direction input is not Intelligent, so this gates nothing)");
|
|
}
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| First scheduled close-all strictly AFTER `after` (server time), |
|
|
//| or 0 when the schedule is disabled. Mirrors the live check in |
|
|
//| CExpertCustom::OnTick exactly: same three inputs, same -1 |
|
|
//| disabled sentinels, same CLOSE_EVERYDAY semantics, same server |
|
|
//| clock (bar times ARE server time). |
|
|
//+------------------------------------------------------------------+
|
|
datetime CExpertSignalAIBase::NextScheduledCloseAll(const datetime after)
|
|
{
|
|
if((int)targetDayOfWeek == -1 || (int)targetHour == -1 || (int)targetMinutes == -1)
|
|
return 0; // schedule off = no cutoff, exactly like live
|
|
datetime dayStart = after - (after % 86400);
|
|
for(int d = 0; d <= 7; d++)
|
|
{
|
|
datetime candDay = dayStart + d * 86400;
|
|
MqlDateTime cdt;
|
|
TimeToStruct(candDay, cdt);
|
|
if(targetDayOfWeek != CLOSE_EVERYDAY && cdt.day_of_week != (int)targetDayOfWeek)
|
|
continue;
|
|
datetime cand = 0;
|
|
if(targetHour == CH_MARKET_CLOSE)
|
|
{
|
|
//--- The symbol's CURRENT session table stands in for every historical day - MT5 keeps no
|
|
//--- session history. ANALYSIS (2026-08-19): the bars themselves bound the error. Never
|
|
//--- optimistic, so it cannot manufacture edge.
|
|
int mktClose = WarriorMarketCloseSeconds(m_symbol.Name(), cdt.day_of_week);
|
|
if(mktClose <= 0)
|
|
continue;
|
|
int cutSec = mktClose - (int)targetMinutes * 60;
|
|
if(cutSec < 0)
|
|
cutSec = 0;
|
|
cand = candDay + cutSec;
|
|
}
|
|
else
|
|
cand = candDay + (int)targetHour * 3600 + (int)targetMinutes * 60;
|
|
if(cand > after)
|
|
return cand;
|
|
}
|
|
return 0;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| 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. |
|
|
//+------------------------------------------------------------------+
|
|
ENUM_SIGNAL CExpertSignalAIBase::TripleBarrierLabel(int idx)
|
|
{
|
|
//--- CLEARED FIRST, ahead of every early return below.
|
|
m_lastExcUp = 0.0;
|
|
m_lastExcDown = 0.0;
|
|
m_lastTermTravel = 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_lastLabelWeekendCut = 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;
|
|
//--- THE BROKER'S MINIMUM STOP DISTANCE APPLIES TO THE LABEL (2026-08-19, user directive:
|
|
//--- training goes through the same checks as trading). Irrelevant on H4 (0.5*ATR dwarfs any
|
|
//--- stops level), real on M5/tight-ATR symbols.
|
|
double minStopDist = TCMinStopDistance(m_symbol.Name());
|
|
if(minStopDist > 0.0)
|
|
{
|
|
if(risk < minStopDist)
|
|
risk = minStopDist;
|
|
if(reward < minStopDist)
|
|
reward = minStopDist;
|
|
}
|
|
//--- 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.
|
|
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. 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).
|
|
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;
|
|
//--- THE SCHEDULED CLOSE-ALL IS A SECOND VERTICAL BARRIER (2026-08-19). At the measured ~18-bar
|
|
//--- mean lifespan on H4 (~3 days) a large share of labels straddled it. Schedule disabled = no
|
|
//--- cutoff.
|
|
int cutBarSec = PeriodSeconds(m_period);
|
|
datetime weekendCut = NextScheduledCloseAll((datetime)(m_Time.GetData(idx) + cutBarSec));
|
|
for(int t = idx - 1; t >= last; t--)
|
|
{
|
|
if(weekendCut > 0 && (datetime)(m_Time.GetData(t) + cutBarSec) > weekendCut)
|
|
{
|
|
m_lastLabelWeekendCut = true;
|
|
break; // the close-all flattens the book here - later bars do not exist for this trade
|
|
}
|
|
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
|
|
//--- Overwritten every visited bar, so it ends up holding the LAST one - including when the
|
|
//--- close-all break above fires, which is the mark that matters most.
|
|
double cl = m_Close.GetData(t);
|
|
if(MathIsValidNumber(cl) && cl != EMPTY_VALUE)
|
|
m_lastTermTravel = (cl - entry) / atr;
|
|
//--- Excursions accumulate only over the REFERENCE WINDOW, not the whole barrier horizon -
|
|
//--- see m_swingMedianBars.
|
|
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.
|
|
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.
|
|
}
|
|
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. So a bar with a winner is determined at that win, however long the other
|
|
//--- side takes.
|
|
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.
|
|
m_lastWinLong = longWon;
|
|
m_lastWinShort = shortWon;
|
|
if(longWon && !shortWon)
|
|
return Buy;
|
|
if(shortWon && !longWon)
|
|
return Sell;
|
|
//--- BOTH TARGETS REACHED.
|
|
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).
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
int CExpertSignalAIBase::ComputeBarrierHorizonBars(int bars)
|
|
{
|
|
//--- The ladder itself now lives in SnapHorizonToLadder(), which this function ends by calling.
|
|
//--- double rather than int so MathMedian can read it; the values are bar counts either way.
|
|
double gaps[];
|
|
ArrayResize(gaps, 0);
|
|
//--- LEG RANGE, harvested in the SAME pivot scan as the leg duration (2026-08-19). Two properties
|
|
//--- of one object: how long a swing lasts and how far it travels. Measuring them together is what
|
|
//--- keeps the horizon and the target describing the same legs instead of two different windows.
|
|
double legs[];
|
|
ArrayResize(legs, 0);
|
|
double prevPivotPrice = 0.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;
|
|
double pivotPrice = m_ADZigZag.GetData(0, p);
|
|
if(pivotPrice == 0.0)
|
|
continue;
|
|
if(prevPivot >= 0)
|
|
{
|
|
int gap = p - prevPivot;
|
|
if(gap > 0)
|
|
{
|
|
int n = ArraySize(gaps);
|
|
ArrayResize(gaps, n + 1);
|
|
gaps[n] = gap;
|
|
//--- ATR-NORMALISED so the median is a multiple comparable with the barrier multiples,
|
|
//--- and read at the leg's own bar so an instrument whose volatility regime changed over
|
|
//--- the sample contributes each leg on its own scale rather than on today's.
|
|
double legAtr = m_ATR.Main(p);
|
|
if(prevPivotPrice > 0.0 && MathIsValidNumber(legAtr) && legAtr > 0.0)
|
|
{
|
|
double range = MathAbs(prevPivotPrice - pivotPrice) / legAtr;
|
|
if(range > 0.0 && MathIsValidNumber(range))
|
|
{
|
|
int m = ArraySize(legs);
|
|
ArrayResize(legs, m + 1);
|
|
legs[m] = range;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
prevPivot = p;
|
|
prevPivotPrice = pivotPrice;
|
|
}
|
|
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)
|
|
swingMedian = MathMedian(gaps);
|
|
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. For a driftless random walk leaving the band [-m*ATR,
|
|
//--- +k*ATR], the expected first-passage time is proportional to m*k.
|
|
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.
|
|
int legCount = ArraySize(legs);
|
|
if(legCount >= BARRIER_HORIZON_MIN_SAMPLES)
|
|
m_swingMedianLegAtr = MathMedian(legs);
|
|
else
|
|
m_swingMedianLegAtr = 0.0;
|
|
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".
|
|
m_barrierHorizonClamped = (raw > EffectiveHorizonMax());
|
|
//--- 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.
|
|
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.
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
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.
|
|
if(IsFractalTarget())
|
|
verdict = FractalDirectionLabel(idx);
|
|
//--- IS-ONLY, matching the final tally pass exactly. 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.
|
|
if(m_lastLabelLifespan > 0)
|
|
{
|
|
m_labelLifespanSum += (double)m_lastLabelLifespan;
|
|
m_labelLifespanCount++;
|
|
}
|
|
if(verdict == Neutral && m_lastBarrierTimedOut)
|
|
{
|
|
m_labelPrebuildTimeoutCount++;
|
|
if(m_lastLabelWeekendCut)
|
|
m_labelPrebuildWeekendCutCount++;
|
|
}
|
|
//--- 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;
|
|
m_termTravelCache[idx] = m_lastTermTravel;
|
|
}
|
|
//--- 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). |
|
|
//+------------------------------------------------------------------+
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
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).
|
|
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. Normally
|
|
//--- that is a once-per-run cost and it does not matter.
|
|
if(ShutdownRequested())
|
|
return;
|
|
//--- A model that is still TRAINING sizes its window by the training rule, not by the saved study
|
|
//--- watermark. 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()). 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.
|
|
if(!ResizeBuffers(barsNow) || !RefreshData())
|
|
{
|
|
//--- NEVER SILENT AGAIN. 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_labelPrebuildWeekendCutCount = 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().
|
|
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. 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.
|
|
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.
|
|
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_labelPrebuildWeekendCutCount > 0
|
|
? " (of which " + IntegerToString(m_labelPrebuildWeekendCutCount) +
|
|
" ended by the scheduled close-all, not the horizon)"
|
|
: "") +
|
|
//--- 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.
|
|
(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)"));
|
|
//--- INTELLIGENT TRADE DIRECTION (2026-08-19, user request: "add an Intelligent option that lets
|
|
//--- the geometry adjust for the drift" - the SQX EdgeFinder precedent).
|
|
RefreshDriftVerdict();
|
|
//--- DERIVE THE GEOMETRY FROM WHAT WAS JUST MEASURED, then relabel under it. 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. A full day of training on the measured 3.33/1.62 pair resumed
|
|
//--- as 2:6 the moment the terminal restarted.
|
|
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.
|
|
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.
|
|
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
|