Warrior_EA/Expert/AIBase/Excursion.mqh
AnimateDread b5e22a1e34 fix(geometry): a free zero made "never resolve" the winning geometry
The CANDIDATE GEOMETRY line shipped in 05f1a53 said per-candidate
geometry beats the global pair on every SP500 member at 2-3 sigma. It
does not. It said so because a bar that reached neither barrier scored
0 R, and the incumbent's mean is NEGATIVE (-0.07 to -0.21 R). Against a
losing baseline a free zero is a win, so the widest candidate always
came out ahead - and the reported gain ordered itself by timeout share,
not by skill:

  PAI  95.1% timed out -> +0.189 R   (head measured -2.42 sigma, HARMFUL)
  HYB  73.8%           -> +0.182 R   (head at chance, +0.68 sigma)
  CONV 61.8%           -> +0.163 R   (head measured -2.47 sigma, HARMFUL)
  LSTM 27.1%           -> +0.158 R   (head +1.67 sigma)

Monotone in the timeout share and inverted against the sigma gate. The
acceptance test written when this was built - "the sigma gate predicts
LSTM helps and CONV hurts; if the R difference does not reproduce that
ordering, something is wrong" - is what caught it.

A trade that reaches neither barrier is not worth zero. It is closed at
the horizon, which is what the scheduled close-all does live and what
SimulateTradeOutcome's timeout path already charges. So mark it there:
TripleBarrierLabel now publishes the signed close-to-close travel at the
last bar it actually visited (m_termTravelCache, same validity flag as
the excursion and ladder caches), and LadderOutcomeR prices a timeout
off it instead of returning false. A bar that cannot be evaluated under
BOTH pairs is now dropped whole - scoring one leg and defaulting the
other is the same bug in a smaller costume.

Second defect, same function: CandidateGeometryFor applied neither of
the floors the global derivation applies, so on USDJPY it chose stop
2.00 / target 1.00 - a 67% break-even, forbidden by the 1:2 policy
floor. c3daded in miniature: a selector optimising its own criterion
with no reference to the decision criterion. Both floors now apply, and
the ratio is re-checked AFTER the per-leg rung snap, which can lose it.

Also: the module weight was an unshrunk pooled win rate. USDJPY ConvLSTM
fired 19 times (2.0 effective), won 36.8%, and took module weight 0.37 -
41% of the ensemble's capable weight and the loudest voice on the chart,
off two effective observations. It also lifted the computed vote ceiling
to 26.3 against a 25 threshold, which is why THRESHOLD UNREACHABLE never
printed on a chart whose peak vote is 14 and whose practical ceiling
without that member is 18.8. The pooled rate is now shrunk toward the
coin-flip rate on the era's own OOS bars over 30 prior-equivalent calls,
and the tiers shrink toward the shrunk value rather than the raw one. A
member with ~300 effective calls moves by ~0.4pp; the 19-fire member
goes 0.37 -> ~0.15.

MEASUREMENT ONLY still - no order reads any of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 11:30:54 -04:00

826 lines
41 KiB
MQL5

//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| Excursion.mqh |
//| AnimateDread |
//| https://www.mql5.com |
//| EXCURSION-SIZE HEAD - a SECOND, small network that predicts HOW |
//| FAR price travels, never WHICH WAY. |
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| Build the head's topology: input window -> one hidden dense -> 2 |
//| x ladder sigmoid outputs. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::ExcursionBuildTopology(CArrayObj &topology)
{
CLayerDescription *desc = new CLayerDescription();
if(CheckPointer(desc) == POINTER_INVALID)
return false;
desc.count = (int)m_historyBars * m_neuronsCount;
desc.type = defNeuron;
desc.activation = NONE;
desc.optimization = (ENUM_OPTIMIZATION)m_optimizationAlgo;
if(!topology.Add(desc))
{
delete desc;
return false;
}
desc = new CLayerDescription();
if(CheckPointer(desc) == POINTER_INVALID)
return false;
desc.count = EXCURSION_HIDDEN_UNITS;
desc.type = defNeuron;
desc.activation = HiddenLayerActivation();
desc.optimization = (ENUM_OPTIMIZATION)m_optimizationAlgo;
if(!topology.Add(desc))
{
delete desc;
return false;
}
desc = new CLayerDescription();
if(CheckPointer(desc) == POINTER_INVALID)
return false;
//--- SIGMOID, and the count must stay != 3: backProp switches to the joint softmax+CCE gradient
//--- at exactly 3 outputs, which is right for one mutually-exclusive class decision and wrong
//--- here.
desc.count = 2 * BARRIER_LADDER_COUNT;
desc.type = defNeuron;
desc.activation = SIGMOID;
desc.optimization = (ENUM_OPTIMIZATION)m_optimizationAlgo;
if(!topology.Add(desc))
{
delete desc;
return false;
}
return true;
}
//+------------------------------------------------------------------+
//| Create the head once per run. Returns false (quietly, once) when |
//| the head cannot be built - the classifier must keep training |
//| regardless, since this is an instrument bolted onto its run and |
//| not a dependency of it. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::ExcursionEnsureHead(void)
{
if(!UseExcursionHead)
return false;
if(CheckPointer(m_excNet) != POINTER_INVALID)
return true;
if(m_excHeadFailed)
return false;
if(m_historyBars <= 0 || m_neuronsCount <= 0)
return false;
CArrayObj *topology = new CArrayObj();
if(CheckPointer(topology) == POINTER_INVALID)
{
m_excHeadFailed = true;
return false;
}
if(!ExcursionBuildTopology(topology))
{
delete topology;
m_excHeadFailed = true;
Print(ID + ": excursion head - could not build topology; the size predictor is disabled for this"
" run. The classifier is unaffected.");
return false;
}
m_excNet = new CNet(topology);
delete topology;
if(CheckPointer(m_excNet) == POINTER_INVALID)
{
m_excHeadFailed = true;
return false;
}
//--- Per-sample updates. The classifier's mini-batch accumulation is scoped to its own pass 2 and
//--- would silently apply here otherwise; this net is small enough that batching buys nothing.
m_excNet.SetBatchSize(1);
//--- Scratch buffers allocated ONCE. getResults takes CArrayDouble*& and news one when handed NULL,
//--- so a local would allocate and leak (or need a delete) on every one of ~32k bars per era.
if(CheckPointer(m_excTgt) == POINTER_INVALID)
m_excTgt = new CArrayDouble();
if(CheckPointer(m_excOut) == POINTER_INVALID)
m_excOut = new CArrayDouble();
if(CheckPointer(m_excTgt) == POINTER_INVALID || CheckPointer(m_excOut) == POINTER_INVALID)
{
m_excHeadFailed = true;
return false;
}
ArrayInitialize(m_excBaseHits, 0);
ArrayInitialize(m_excBrierHead, 0.0);
ArrayInitialize(m_excBrierBase, 0.0);
ArrayInitialize(m_excBrierHeadT, 0.0);
ArrayInitialize(m_excOosHits, 0);
//--- Trailing ring: horizon of hold-back plus the rolling window itself.
ArrayResize(m_excTrailRing, (int)MathMax(m_barrierHorizonBars, 1) + EXCURSION_TRAIL_WINDOW);
ArrayInitialize(m_excTrailRing, 0);
ArrayInitialize(m_excTrailHits, 0);
ArrayInitialize(m_excBrierTrail, 0.0);
m_excTrailHead = 0;
m_excTrailCount = 0;
m_excTrailN = 0;
m_excTrailScored = 0;
m_excBaseTotal = 0;
m_excScored = 0;
m_excScoredD = 0;
m_excDiffSum = 0.0;
m_excDiffSumSq = 0.0;
m_excTrailDiffSum = 0.0;
m_excTrailDiffSumSq = 0.0;
m_excMonoViol = 0;
Print(ID + StringFormat(": excursion head created - %d inputs -> %d hidden -> %d outputs "
"(P(reach rung) for %d up + %d down rungs). MEASUREMENT ONLY this build: it "
"predicts how FAR price travels, never which way, and reports a skill score "
"against the constant base rate that a fixed ATR multiple already assumes.",
(int)m_historyBars * m_neuronsCount, EXCURSION_HIDDEN_UNITS,
2 * BARRIER_LADDER_COUNT, BARRIER_LADDER_COUNT, BARRIER_LADDER_COUNT));
return true;
}
//+------------------------------------------------------------------+
//| This bar's 16 binary targets, straight off the first-passage |
//| ladder. Returns false when the bar has no measured ladder, which |
//| must skip the sample rather than train it as all-zero - an |
//| unmeasured bar and a bar price never moved on are the same array |
//| contents and opposite facts. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::ExcursionTargets(int idx)
{
if(CheckPointer(m_excTgt) == POINTER_INVALID)
return false;
int base = idx * BARRIER_LADDER_COUNT;
if(idx < 0 || base + BARRIER_LADDER_COUNT > ArraySize(m_ladderUpAt) ||
base + BARRIER_LADDER_COUNT > ArraySize(m_ladderDownAt))
return false;
if(idx >= ArraySize(m_labelCacheHasValue) || !m_labelCacheHasValue[idx])
return false;
//--- Same "not measured" marker the MI sample uses: TripleBarrierLabel's early returns leave the
//--- excursions cleared to zero, and price cannot genuinely travel zero in BOTH directions over a
//--- whole horizon. Training on those rows would teach the head that a fifth of bars never move.
if(idx < ArraySize(m_excUpCache) && idx < ArraySize(m_excDownCache) &&
m_excUpCache[idx] <= 0.0 && m_excDownCache[idx] <= 0.0)
return false;
//--- HARD 1/0, NOT the classifier's LABEL_SMOOTH_HIGH/LOW (0.9/0.05). Against a base rate of
//--- 0.99 the arithmetic is forced before the net learns anything at all:
m_excTgt.Clear();
for(int k = 0; k < BARRIER_LADDER_COUNT; k++)
m_excTgt.Add(m_ladderUpAt[base + k] > 0 ? 1.0 : 0.0);
for(int k = 0; k < BARRIER_LADDER_COUNT; k++)
m_excTgt.Add(m_ladderDownAt[base + k] > 0 ? 1.0 : 0.0);
return true;
}
//+------------------------------------------------------------------+
//| IS: one training step. Call while TempData still holds the |
//| FEATURE window - i.e. after the classifier's feedForward and |
//| BEFORE its getResults(), which overwrites TempData in place with |
//| the output activations. That ordering constraint is the only |
//| coupling between the two nets and it is why this takes no index |
//| for the forward pass. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ExcursionTrainStep(int idx)
{
if(!ExcursionEnsureHead())
return;
//--- STRIDE. One bar in EXCURSION_TRAIN_STRIDE keeps thousands of samples an era and cuts the
//--- head's training dispatches by the same factor.
m_excTrainTick++;
if((m_excTrainTick % EXCURSION_TRAIN_STRIDE) != 0)
return;
if(!ExcursionTargets(idx))
return;
ulong excT0 = GetMicrosecondCount();
if(!m_excNet.feedForward(TempData))
return;
//--- Base rates accumulated from the SAME rows the head trains on - IS only.
for(int k = 0; k < 2 * BARRIER_LADDER_COUNT; k++)
if(m_excTgt.At(k) > 0.5)
m_excBaseHits[k]++;
m_excBaseTotal++;
m_excNet.backProp(m_excTgt, 1.0);
//--- Charged to its OWN accumulator. Until now the head's passes landed in the era line's "other"
//--- bucket, which is how a 3.6x era-time regression read as an unexplained jump in a column nobody
//--- attributes. A cost that cannot be seen in the timing line cannot be traded off against anything.
m_excUs += GetMicrosecondCount() - excT0;
}
//+------------------------------------------------------------------+
//| OOS: score one bar. Brier score (mean squared error on a |
//| probability) for the head and for the constant base rate, summed |
//| per rung so the report can show WHERE any skill lives - a head |
//| that only predicts the near rungs is still useful for a stop and |
//| useless for a target. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ExcursionScoreStep(int idx)
{
if(CheckPointer(m_excNet) == POINTER_INVALID || m_excBaseTotal <= 0)
return;
if(!ExcursionTargets(idx))
return;
//--- DISJOINT WINDOWS ONLY - both the honest statistic AND the whole scoring cost.
int hz = (int)MathMax(m_barrierHorizonBars, 1);
bool disjoint = ((m_excScored % hz) == 0);
if(!disjoint)
{
ExcursionTrailPush();
m_excScored++;
return;
}
ulong excS0 = GetMicrosecondCount();
bool fwdOk = m_excNet.feedForward(TempData);
if(fwdOk)
m_excNet.getResults(m_excOut);
m_excUs += GetMicrosecondCount() - excS0;
if(!fwdOk || CheckPointer(m_excOut) == POINTER_INVALID ||
m_excOut.Total() < 2 * BARRIER_LADDER_COUNT)
return;
//--- MONOTONICITY. Reaching 3 ATR implies reaching 0.5 ATR, so P(reach k) must be non-increasing
//--- in k. Counted, not corrected: the rate is the diagnostic that says whether the survival
//--- parameterisation is holding together at all.
for(int side = 0; side < 2; side++)
for(int k = 1; k < BARRIER_LADDER_COUNT; k++)
if(m_excOut.At(side * BARRIER_LADDER_COUNT + k) >
m_excOut.At(side * BARRIER_LADDER_COUNT + k - 1) + 1e-9)
{
m_excMonoViol++;
side = 2; // one violation per bar is enough to characterise it
break;
}
//--- THIS WINDOW's paired Brier differences over the decision rungs, accumulated below and banked
//--- once after the loop. One value per disjoint window is what turns the two skill scores into
//--- estimates with a standard error - see m_excDiffSum.
bool decMask[];
DecisionRungMask(decMask);
double barDiff = 0.0, barTrailDiff = 0.0;
for(int k = 0; k < 2 * BARRIER_LADDER_COUNT; k++)
{
double y = (m_excTgt.At(k) > 0.5) ? 1.0 : 0.0;
double p = m_excOut.At(k);
double b = (double)m_excBaseHits[k] / m_excBaseTotal;
//--- k runs side-major over the ladder, so the rung is k modulo the ladder length.
bool isDec = decMask[k % BARRIER_LADDER_COUNT];
//--- ORACLE CONTROL. This is the control that separates "the head predicts per bar" from "the
//--- head learned a LEVEL nearer the OOS rate than the frozen IS constant". It peeks at the
//--- test block by construction, so it is a control and never a headline.
if(y > 0.5)
m_excOosHits[k]++;
double brHead = (p - y) * (p - y);
double brBase = (b - y) * (b - y);
m_excBrierHead[k] += brHead;
m_excBrierBase[k] += brBase;
if(isDec)
barDiff += brBase - brHead;
//--- Trailing climatology, scored on the SAME bars. Only once the window holds a usable sample -
//--- before that it would be a handful of bars pretending to be a rate.
if(m_excTrailN >= EXCURSION_TRAIL_MIN_N)
{
double tr = (double)m_excTrailHits[k] / m_excTrailN;
double brTrail = (tr - y) * (tr - y);
m_excBrierTrail[k] += brTrail;
//--- and the HEAD's Brier on this same bar, so the incumbent race compares the two
//--- predictors on an identical bar set - see m_excBrierHeadT's declaration comment.
m_excBrierHeadT[k] += brHead;
if(isDec)
barTrailDiff += brTrail - brHead;
}
}
//--- Banked per WINDOW, not per rung: the rungs of one bar are the same forecast read at different
//--- distances, so treating them as separate observations would inflate the count by eight.
m_excDiffSum += barDiff;
m_excDiffSumSq += barDiff * barDiff;
if(m_excTrailN >= EXCURSION_TRAIL_MIN_N)
{
m_excTrailScored++;
m_excTrailDiffSum += barTrailDiff;
m_excTrailDiffSumSq += barTrailDiff * barTrailDiff;
}
ExcursionTrailPush();
m_excScoredD++; // every bar reaching here IS a disjoint one now
m_excScored++;
}
//+------------------------------------------------------------------+
//| Advance the trailing-climatology ring by one bar. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ExcursionTrailPush(void)
{
int ringSize = ArraySize(m_excTrailRing);
if(ringSize <= 0 || CheckPointer(m_excTgt) == POINTER_INVALID)
return;
int hz = (int)MathMax(m_barrierHorizonBars, 1);
//--- Pack this bar's 32 outcomes into one mask.
ulong mask = 0;
for(int k = 0; k < 2 * BARRIER_LADDER_COUNT; k++)
if(m_excTgt.At(k) > 0.5)
mask |= ((ulong)1 << k);
//--- The entry that just crossed from unresolved into the window, and the one falling out the far
//--- end, are both at fixed offsets behind the write head - so each push is O(rungs), not O(window).
if(m_excTrailCount >= hz)
{
int justResolved = ((m_excTrailHead - hz) % ringSize + ringSize) % ringSize;
ulong rm = m_excTrailRing[justResolved];
for(int k = 0; k < 2 * BARRIER_LADDER_COUNT; k++)
if((rm & ((ulong)1 << k)) != 0)
m_excTrailHits[k]++;
m_excTrailN++;
}
if(m_excTrailCount >= ringSize)
{
ulong om = m_excTrailRing[m_excTrailHead]; // about to be overwritten: it leaves the window
for(int k = 0; k < 2 * BARRIER_LADDER_COUNT; k++)
if((om & ((ulong)1 << k)) != 0)
m_excTrailHits[k]--;
m_excTrailN--;
}
m_excTrailRing[m_excTrailHead] = mask;
m_excTrailHead = (m_excTrailHead + 1) % ringSize;
if(m_excTrailCount < ringSize)
m_excTrailCount++;
}
//+------------------------------------------------------------------+
//| Rungs whose Brier the decision actually depends on: the ones |
//| bracketing the live stop and target, because ExcursionQuantile |
//| interpolates between exactly those. Skill at 5 ATR is skill |
//| about a distance no order is placed at, and quoting the best |
//| rung of eight is a best-of-N over a grid. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::DecisionRungMask(bool &mask[])
{
ArrayResize(mask, BARRIER_LADDER_COUNT);
double slMult, tpMult;
BarrierMultiples(slMult, tpMult);
for(int k = 0; k < BARRIER_LADDER_COUNT; k++)
{
bool bracketsTp = (k + 1 < BARRIER_LADDER_COUNT && BARRIER_LADDER[k] <= tpMult && BARRIER_LADDER[k + 1] >= tpMult) ||
(k > 0 && BARRIER_LADDER[k - 1] <= tpMult && BARRIER_LADDER[k] >= tpMult);
bool bracketsSl = (k + 1 < BARRIER_LADDER_COUNT && BARRIER_LADDER[k] <= slMult && BARRIER_LADDER[k + 1] >= slMult) ||
(k > 0 && BARRIER_LADDER[k - 1] <= slMult && BARRIER_LADDER[k] >= slMult);
mask[k] = (bracketsTp || bracketsSl);
}
}
//+------------------------------------------------------------------+
//| Reset the per-era scoring accumulators. Base rates are NOT reset |
//| here - they are a property of the data, they only get more |
//| precise with more eras, and re-estimating them from scratch every |
//| era would make the baseline noisier than the thing it is meant to |
//| be a floor for. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ExcursionResetEraScores(void)
{
for(int k = 0; k < 2 * BARRIER_LADDER_COUNT; k++)
{
m_excBrierHead[k] = 0.0;
m_excBrierBase[k] = 0.0;
m_excBrierHeadT[k] = 0.0;
m_excOosHits[k] = 0;
m_excBrierTrail[k] = 0.0;
m_excTrailHits[k] = 0;
}
m_excScored = 0;
m_excScoredD = 0;
m_excDiffSum = 0.0;
m_excDiffSumSq = 0.0;
m_excTrailDiffSum = 0.0;
m_excTrailDiffSumSq = 0.0;
m_excMonoViol = 0;
m_excUs = 0;
//--- The trailing RING IS cleared here (2026-08-11; it deliberately was not, as "a rolling
//--- estimate of the market, not of the era").
if(ArraySize(m_excTrailRing) > 0)
ArrayInitialize(m_excTrailRing, 0);
m_excTrailHead = 0;
m_excTrailCount = 0;
m_excTrailN = 0;
m_excTrailScored = 0;
}
//+------------------------------------------------------------------+
//| Nearest ladder rung to a travel distance, in LOG space. |
//| |
//| The ladder is roughly geometric, so a linear "nearest" biases |
//| every choice toward its coarse upper end. Same rule LadderWinShare|
//| snaps with, so a rung chosen here and a rung chosen there are the |
//| same rung. |
//+------------------------------------------------------------------+
int CExpertSignalAIBase::LadderRungFor(const double travelAtr)
{
if(!MathIsValidNumber(travelAtr) || travelAtr <= 0.0)
return -1;
int best = -1;
double bestErr = DBL_MAX;
for(int k = 0; k < BARRIER_LADDER_COUNT; k++)
{
double err = MathAbs(MathLog(BARRIER_LADDER[k] / travelAtr));
if(err < bestErr)
{
bestErr = err;
best = k;
}
}
return best;
}
//+------------------------------------------------------------------+
//| Exact outcome of one bar at one (stop, target) rung pair, in R. |
//| |
//| Four array reads against the first-passage ages - no re-walk, and |
//| exact even on the bars where BOTH barriers were touched, which a |
//| maximum-travel cache cannot decide. Ladder levels are TRAVEL from |
//| the entry close, so the spread converts the way the fill puts it: |
//| a long needs (reward + spread) of travel to pay and its stop |
//| trips at (risk - spread). That is the SAME convention |
//| SimulateTradeOutcome and TripleBarrierLabel walk, so an R from |
//| here is comparable with an R from there. |
//| |
//| A bar that reaches NEITHER barrier is marked at the horizon close |
//| rather than scored zero. A free zero is what broke this |
//| measurement's first version (2026-08-22): against a losing |
//| incumbent it makes NOT RESOLVING the winning move, so the widest |
//| candidate always won and the reported gain ordered itself by |
//| timeout share instead of by skill. A real trade IS closed at the |
//| horizon - the convention SimulateTradeOutcome already charges. |
//| |
//| Returns false only when the bar cannot be evaluated at all. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::LadderOutcomeR(const int barIdx, const bool isLong, const int slRung,
const int tpRung, double &rMultiple, bool &timedOut)
{
rMultiple = 0.0;
timedOut = false;
if(slRung < 0 || tpRung < 0 || barIdx < 0)
return false;
int b = barIdx * BARRIER_LADDER_COUNT;
if(b + BARRIER_LADDER_COUNT > ArraySize(m_ladderUpAt) ||
b + BARRIER_LADDER_COUNT > ArraySize(m_ladderDownAt))
return false;
double reward = BARRIER_LADDER[tpRung] - m_spreadAtr;
double risk = BARRIER_LADDER[slRung] + m_spreadAtr;
if(reward <= 0.0 || risk <= 0.0)
return false; // target inside the spread - not tradeable at any hit rate
//--- Age 0 means "never touched inside the horizon"; a SMALLER age is the earlier touch, and a tie
//--- goes to the stop - the same pessimism the label walk uses.
int tTarget = isLong ? m_ladderUpAt[b + tpRung] : m_ladderDownAt[b + tpRung];
int tStop = isLong ? m_ladderDownAt[b + slRung] : m_ladderUpAt[b + slRung];
if(tTarget > 0 && (tStop == 0 || tTarget < tStop))
{
rMultiple = reward / risk;
return true;
}
if(tStop > 0)
{
rMultiple = -1.0;
return true;
}
//--- TIMEOUT, marked to the last close the label walk actually visited (the horizon, or the
//--- scheduled close-all where that came first). m_termTravelCache is signed and entry-relative;
//--- the spread is charged once at the exit on either side, as the barrier levels carry it.
if(barIdx >= ArraySize(m_termTravelCache))
return false;
double travel = m_termTravelCache[barIdx];
if(!MathIsValidNumber(travel))
return false;
timedOut = true;
rMultiple = ((isLong ? travel : -travel) - m_spreadAtr) / risk;
return true;
}
//+------------------------------------------------------------------+
//| The (stop, target) pair this bar's excursion head would choose. |
//| |
//| Same rule the GLOBAL derivation uses, applied per bar instead of |
//| once per era: stop at a high quantile of ADVERSE travel so only a |
//| minority of bars reach it, target at the median of FAVOURABLE |
//| travel so it is reached about half the time. Which side is which |
//| depends on the direction being taken. |
//| |
//| Neither creates expectancy - chance precision equals break-even |
//| at every geometry. What varies per candidate is the BREAK-EVEN, |
//| which is why the caller scores R and never a win rate. |
//+------------------------------------------------------------------+
bool CExpertSignalAIBase::CandidateGeometryFor(const int barIdx, const bool isLong,
int &slRung, int &tpRung)
{
slRung = -1;
tpRung = -1;
if(CheckPointer(m_excNet) == POINTER_INVALID || m_excBaseTotal <= 0)
return false;
if(!ExcursionTargets(barIdx))
return false;
if(!m_excNet.feedForward(TempData))
return false;
//--- Favourable travel is UP for a long and DOWN for a short; the stop reads the other side.
double favour = ExcursionQuantile(isLong, BARRIER_TP_QUANTILE);
double adverse = ExcursionQuantile(!isLong, BARRIER_SL_QUANTILE);
if(favour <= 0.0 || adverse <= 0.0)
return false;
//--- THE SAME TWO FLOORS THE GLOBAL DERIVATION APPLIES, for the same reasons: a stop tighter
//--- than the broker minimum cannot be placed, and a ratio under the policy minimum buys a high
//--- win rate at a break-even nothing downstream was set against. Without these the head chose
//--- 2.00/1.00 on USDJPY - break-even 67% - which is c3daded in miniature: a selector optimising
//--- its own criterion, unconstrained by the decision criterion.
if(adverse < MIN_SL_ATR_MULTIPLIER)
adverse = MIN_SL_ATR_MULTIPLIER;
if(favour < adverse * BARRIER_TARGET_RR_MIN)
favour = adverse * BARRIER_TARGET_RR_MIN;
slRung = LadderRungFor(adverse);
tpRung = LadderRungFor(favour);
//--- Snapping is per leg, so the ratio can survive the quantiles and still be lost to the rungs.
if(slRung >= 0 && tpRung >= 0
&& BARRIER_LADDER[tpRung] < BARRIER_LADDER[slRung] * BARRIER_TARGET_RR_MIN)
for(int k = tpRung + 1; k < BARRIER_LADDER_COUNT; k++)
if(BARRIER_LADDER[k] >= BARRIER_LADDER[slRung] * BARRIER_TARGET_RR_MIN)
{
tpRung = k;
break;
}
return (slRung >= 0 && tpRung >= 0);
}
//+------------------------------------------------------------------+
//| One OOS call, scored under both geometries on the SAME bar. |
//| |
//| Paired, and both legs resolved from the SAME ladder. Mixing the |
//| price walk with the ladder here would measure the discrepancy |
//| between two of our own evaluators rather than the effect of the |
//| geometry - which is exactly what f8ac10c had to unpick one layer |
//| over, where a label win rate sat beside a simulated expectancy. |
//| |
//| A bar unresolved under either pair contributes 0 R for that pair |
//| and is COUNTED, because a candidate that resolves more often is |
//| an advantage the mean would otherwise hide. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ScoreCandidateGeometry(const int barIdx, const bool isLong)
{
//--- See m_geoStartTick. The clock starts on the first ATTEMPT, not the first success - a bar the
//--- head cannot answer for still costs a forward pass. After the budget this simply stops
//--- contributing, leaving the exit replay it rides on untouched.
if(m_geoStartTick == 0)
m_geoStartTick = GetTickCount();
else
if(GetTickCount() - m_geoStartTick >= GEOMETRY_BUDGET_MS)
return;
//--- INCUMBENT PAIR, converted into ladder TRAVEL. The scan's mapping is risk = ladder + spread and
//--- reward = ladder - spread, so the two legs convert with OPPOSITE signs: a stop trips after
//--- (risk - spread) of travel, a target pays after (reward + spread). The candidate legs need no
//--- conversion - ExcursionQuantile already reads the curve in ladder units.
double slMult, tpMult;
BarrierMultiples(slMult, tpMult);
int incSl = LadderRungFor(slMult - m_spreadAtr);
int incTp = LadderRungFor(tpMult + m_spreadAtr);
if(incSl < 0 || incTp < 0)
return; // no incumbent to compare against - scoring one leg alone would be a false baseline
int candSl, candTp;
if(!CandidateGeometryFor(barIdx, isLong, candSl, candTp))
return;
double rInc = 0.0, rCand = 0.0;
bool incTo = false, candTo = false;
//--- BOTH must be evaluable or the bar is dropped whole: scoring one leg and defaulting the
//--- other is the free-zero bug in a smaller costume.
if(!LadderOutcomeR(barIdx, isLong, incSl, incTp, rInc, incTo))
return;
if(!LadderOutcomeR(barIdx, isLong, candSl, candTp, rCand, candTo))
return;
if(incTo)
m_geoIncOpen++;
if(candTo)
m_geoCandOpen++;
double d = rCand - rInc;
m_geoDiffSum += d;
m_geoDiffSumSq += d * d;
m_geoIncSum += rInc;
m_geoCandSum += rCand;
m_geoCandSl += BARRIER_LADDER[candSl];
m_geoCandTp += BARRIER_LADDER[candTp];
m_geoTrades++;
}
//+------------------------------------------------------------------+
//| Does per-candidate geometry beat the one global pair? |
//| |
//| MEASUREMENT ONLY - nothing here changes an order. Reported in R |
//| and never as a win rate, because the whole point is that the |
//| break-even moves per candidate, so no fixed bar exists to score a |
//| win rate against. |
//| |
//| The SE is deflated by the label overlap on the same doctrine as |
//| every other SE here: these calls are consecutive bars, not |
//| independent trades. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ReportCandidateGeometry(void)
{
if(m_geoTrades < 2 || !TrainLogDue())
return;
double mean = m_geoDiffSum / m_geoTrades;
double var = (m_geoDiffSumSq / m_geoTrades) - (mean * mean);
if(var < 0.0)
var = 0.0;
double effN = EffectiveSampleSize((double)m_geoTrades);
double se = (effN > 0.0) ? MathSqrt(var / effN) : 0.0;
double t = (se > 0.0) ? mean / se : 0.0;
double slMult, tpMult;
BarrierMultiples(slMult, tpMult);
PrintFormat("%s: CANDIDATE GEOMETRY - %d OOS calls scored under BOTH pairs on the same bars, both"
" resolved from the first-passage ladder | incumbent stop %.2f target %.2f -> %+.3f R"
" | per-candidate mean stop %.2f target %.2f -> %+.3f R | difference %+.3f R at %.2f"
" sigma on %.0f independent calls | timed out and MARKED AT THE HORIZON CLOSE:"
" incumbent %.1f%%, candidate %.1f%% (marked, NOT scored 0 - a free zero would let the"
" widest candidate win by never resolving, which is what this line first measured) | covered %d of this era's %d replayed calls%s. MEASUREMENT ONLY - no"
" order uses this. Below 2 sigma the one global pair is doing as well, and it costs no"
" forward pass.",
ID, m_geoTrades, slMult, tpMult, m_geoIncSum / m_geoTrades,
m_geoCandSl / m_geoTrades, m_geoCandTp / m_geoTrades, m_geoCandSum / m_geoTrades,
mean, t, effN,
100.0 * m_geoIncOpen / m_geoTrades, 100.0 * m_geoCandOpen / m_geoTrades,
m_geoTrades, m_simTrades,
(m_geoTrades < m_simTrades
? StringFormat(" (stopped at the %.0f s budget)", GEOMETRY_BUDGET_MS / 1000.0) : ""));
}
//+------------------------------------------------------------------+
//| Per-bar quantile in ATR multiples, read off the predicted |
//| survival curve: the largest rung whose reach-probability is |
//| still >= (1 - tau), linearly interpolated between rungs. |
//+------------------------------------------------------------------+
double CExpertSignalAIBase::ExcursionQuantile(bool upward, double tau)
{
if(CheckPointer(m_excNet) == POINTER_INVALID)
return -1.0;
m_excNet.getResults(m_excOut);
if(CheckPointer(m_excOut) == POINTER_INVALID || m_excOut.Total() < 2 * BARRIER_LADDER_COUNT)
return -1.0;
int off = upward ? 0 : BARRIER_LADDER_COUNT;
double want = 1.0 - tau; // P(reach) at the quantile we are asking for
double prev = BARRIER_LADDER[0], prevP = 1.0;
for(int k = 0; k < BARRIER_LADDER_COUNT; k++)
{
double p = m_excOut.At(off + k);
if(p <= want)
{
//--- Crossed between rung k-1 and k. Interpolate in the probability, not the multiple: the
//--- ladder is geometric, so a linear read in p is the less distorted of the two.
double span = prevP - p;
double frac = (span > 1e-9) ? (prevP - want) / span : 0.0;
return prev + frac * (BARRIER_LADDER[k] - prev);
}
prev = BARRIER_LADDER[k];
prevP = p;
}
//--- Never crossed: the horizon reaches past the top rung more often than tau allows, so the honest
//--- answer is the top rung rather than an extrapolation off the end of the measured ladder.
return BARRIER_LADDER[BARRIER_LADDER_COUNT - 1];
}
//+------------------------------------------------------------------+
//| The verdict line. Skill = 1 - Brier(head)/Brier(base), the |
//| standard Brier skill score: > 0 means the head beats the |
//| constant base rate, 0 means it has learned exactly the base |
//| rate, < 0 means it is worse than assuming nothing. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ExcursionReport(void)
{
if(CheckPointer(m_excNet) == POINTER_INVALID || m_excScored < EXCURSION_MIN_SCORED)
return;
//--- DECISION RUNGS, pre-registered as "the ones Stage 2 actually consumes", not chosen after
//--- looking.
double slMult, tpMult;
BarrierMultiples(slMult, tpMult);
bool decMask[];
DecisionRungMask(decMask);
double headSum = 0.0, baseSum = 0.0, headDec = 0.0, baseDec = 0.0, headDj = 0.0, baseDj = 0.0;
double trailDec = 0.0, headDecTrail = 0.0;
double oracleDec = 0.0;
string perRung = "", decList = "";
for(int k = 0; k < BARRIER_LADDER_COUNT; k++)
{
double hUp = m_excBrierHead[k], bUp = m_excBrierBase[k];
double hDn = m_excBrierHead[BARRIER_LADDER_COUNT + k], bDn = m_excBrierBase[BARRIER_LADDER_COUNT + k];
headSum += hUp + hDn;
baseSum += bUp + bDn;
double bTot = bUp + bDn;
double sk = (bTot > 0.0) ? 100.0 * (1.0 - (hUp + hDn) / bTot) : 0.0;
perRung += StringFormat(" %.2f:%+.1f%%", BARRIER_LADDER[k], sk);
//--- Same mask the scorer accumulated its paired differences over, so the skill score and its
//--- standard error describe the same rungs.
if(!decMask[k])
continue;
decList += StringFormat(" %.2f", BARRIER_LADDER[k]);
headDec += hUp + hDn;
baseDec += bUp + bDn;
trailDec += m_excBrierTrail[k] + m_excBrierTrail[BARRIER_LADDER_COUNT + k];
headDecTrail += m_excBrierHeadT[k] + m_excBrierHeadT[BARRIER_LADDER_COUNT + k];
headDj += hUp + hDn; // same tally: every scored bar is a disjoint window
baseDj += bUp + bDn;
//--- Oracle constant for these rungs, closed form: for constant c over n bars with H positives,
//--- Brier = n*c^2 - 2c*H + H, minimised at c = H/n giving H - H^2/n = H*(1 - H/n).
for(int s = 0; s < 2; s++)
{
double H = (double)m_excOosHits[s * BARRIER_LADDER_COUNT + k];
double n = (double)m_excScoredD;
if(n > 0.0)
oracleDec += H * (1.0 - H / n);
}
}
if(baseSum <= 0.0 || baseDec <= 0.0)
return;
double skill = 100.0 * (1.0 - headSum / baseSum);
double skillDec = 100.0 * (1.0 - headDec / baseDec);
double skillDj = (baseDj > 0.0) ? 100.0 * (1.0 - headDj / baseDj) : 0.0;
//--- Against the BEST POSSIBLE CONSTANT on this very block. A head that only learned a level scores
//--- positive against the frozen IS constant and <= 0 here, by construction.
double skillOracle = (oracleDec > 0.0) ? 100.0 * (1.0 - headDec / oracleDec) : 0.0;
//--- vs the TRAILING INCUMBENT, on an IDENTICAL bar set: m_excBrierHeadT accumulated the head's
//--- Brier only on the bars the warm trailing window also scored (2026-08-11; this replaced
//--- pro-rating headDec by coverage, which assumed head skill is uniform across the OOS walk
//--- while the trail-scored subset systematically excludes each era's warm-up bars).
double skillTrail = (trailDec > 0.0 && m_excTrailScored > 0)
? 100.0 * (1.0 - headDecTrail / trailDec) : -100.0;
double monoPct = (m_excScored > 0) ? 100.0 * m_excMonoViol / m_excScored : 0.0;
//--- ALL FOUR must hold. That threshold's shape - one number, no interval, no multiplicity
//--- control, evaluated over a grid - is the shape of the four best-of-N traps already
//--- documented in this project, and it would have passed Stage 2 on an artifact that the label
//--- smoothing manufactured (see ExcursionTargets).
bool passDec = (skillDec >= EXCURSION_SKILL_USEFUL_PCT);
bool passOracle = (skillOracle >= EXCURSION_SKILL_USEFUL_PCT);
//--- THE SKILL SCORES NOW CARRY A STANDARD ERROR, and the count thresholds they replace were
//--- never a power calculation. Raising the split or shortening the horizon to clear it would be
//--- fitting the experiment to the answer.
double djMean = 0.0, djSe = 0.0, djT = 0.0;
if(m_excScoredD > 1)
{
djMean = m_excDiffSum / m_excScoredD;
double djVar = (m_excDiffSumSq / m_excScoredD) - (djMean * djMean);
if(djVar < 0.0)
djVar = 0.0;
djSe = MathSqrt(djVar / m_excScoredD);
djT = (djSe > 0.0) ? djMean / djSe : 0.0;
}
double trMean = 0.0, trSe = 0.0, trT = 0.0;
if(m_excTrailScored > 1)
{
trMean = m_excTrailDiffSum / m_excTrailScored;
double trVar = (m_excTrailDiffSumSq / m_excTrailScored) - (trMean * trMean);
if(trVar < 0.0)
trVar = 0.0;
trSe = MathSqrt(trVar / m_excTrailScored);
trT = (trSe > 0.0) ? trMean / trSe : 0.0;
}
//--- BOTH still required: the SE says the effect is real, EXCURSION_SKILL_USEFUL_PCT says it is big
//--- enough to be worth replacing a constant that cannot fail. A tiny effect measured precisely is
//--- still not worth a network.
bool passDj = (skillDj >= EXCURSION_SKILL_USEFUL_PCT
&& m_excScoredD >= EXCURSION_MIN_DISJOINT_SANITY
&& djT >= EXCURSION_MIN_SIGMA);
//--- CAN THIS CONFIGURATION EVER REACH EVEN THE SANITY FLOOR? Disjoint windows are scored bars over
//--- the horizon, and the scored bars are the OOS slice, so the count has a CEILING no number of
//--- eras moves. Says "not in this configuration" rather than "wait longer" - see ReportDetectability.
int djSpacing = (int)MathMax(m_barrierHorizonBars, 1);
int djCeiling = (m_excScored > 0) ? (int)(m_excScored / djSpacing) : 0;
bool djUnreachable = (djCeiling < EXCURSION_MIN_DISJOINT_SANITY);
//--- THE INCUMBENT TEST. A rolling rung frequency needs no model, no 760 inputs and no training;
//--- if the head cannot beat it there is nothing here worth deploying a network for, however
//--- well it beats a frozen constant.
bool passTrail = (skillTrail >= EXCURSION_SKILL_USEFUL_PCT
&& m_excTrailScored >= EXCURSION_MIN_DISJOINT_SANITY
&& trT >= EXCURSION_MIN_SIGMA);
bool passMono = (monoPct <= EXCURSION_MAX_MONO_VIOL_PCT);
string verdict;
if(passDec && passDj && passOracle && passMono && passTrail)
verdict = " <-- PASSES ALL FOUR. Stage 2 is justified: drive SL/TP and sizing off"
" ExcursionQuantile. Still RISK CONTROL ONLY - expectancy is -costs at zero directional"
" edge whatever the stop distance, and under prop DD limits LOWER variance also lowers"
" P(reach target before limit), so 'better drawdown' here is a choice about WHICH"
" failure mode, not an improvement. Race it against a trailing-quantile incumbent"
" before shipping.";
else
{
verdict = " <-- NOT JUSTIFIED. Failing:";
if(!passDec)
verdict += " [decision rungs]";
if(!passDj)
verdict += (m_excScoredD < EXCURSION_MIN_DISJOINT_SANITY)
? (djUnreachable
? StringFormat(" [disjoint sample CANNOT REACH %d HERE - %d of a ceiling of %d,"
" being %d scored bars over a %d-bar horizon. More eras cannot"
" raise it; only more OOS bars or a shorter horizon can]",
EXCURSION_MIN_DISJOINT_SANITY, m_excScoredD, djCeiling,
m_excScored, djSpacing)
: " [disjoint sample too small]")
: StringFormat(" [disjoint skill %+.1f%% at %.2f sigma - needs %+.1f%% AND %.1f"
" sigma]", skillDj, djT, EXCURSION_SKILL_USEFUL_PCT,
EXCURSION_MIN_SIGMA);
if(!passOracle)
verdict += " [beaten by the best constant on this block - level, not per-bar]";
if(!passMono)
verdict += " [survival curve not monotone]";
if(!passTrail)
verdict += (m_excTrailScored < EXCURSION_MIN_DISJOINT_SANITY)
? " [trailing incumbent not warm enough to race]"
: StringFormat(" [vs trailing quantile %+.1f%% at %.2f sigma - needs %+.1f%% AND"
" %.1f sigma; below that no net is needed]", skillTrail, trT,
EXCURSION_SKILL_USEFUL_PCT, EXCURSION_MIN_SIGMA);
verdict += ". Stage 2 must not be built on this.";
}
//--- THROTTLED (2026-08-19): a settled verdict (risk control, not edge - see project memory)
//--- that printed ~780 chars every era per member. Cadence via TrainLogDue; VerboseMode = every era.
if(TrainLogDue())
Print(ID + StringFormat(": excursion head - DECISION rungs%s (live geometry stop %.2f target %.2f):"
" skill %+.1f%% vs IS constant, %+.1f%% at %.2f sigma on %d DISJOINT"
" windows (every %d bars), %+.1f%% vs the BEST constant on this block,"
" %+.1f%% at %.2f sigma vs a TRAILING quantile on %d bars |"
" non-monotone curves"
" %.1f%% | all-rung aggregate %+.1f%% on %d bars (fitted on %d) | per-rung"
" ATR:skill%s |%s",
decList, slMult, tpMult, skillDec, skillDj, djT, m_excScoredD,
(int)MathMax(m_barrierHorizonBars, 1), skillOracle, skillTrail, trT,
(int)m_excTrailScored, monoPct, skill,
m_excScored, m_excBaseTotal, perRung, verdict));
}
//+------------------------------------------------------------------+