Warrior_EA/Expert/AIBase/Excursion.mqh
AnimateDread 53ccc03453 fix: the trailing-incumbent count gate was unpassable by construction
passTrail demanded m_excTrailScored >= EXCURSION_MIN_SCORED (500), but since
e2c9593 the trail race only scores DISJOINT bars: m_excTrailScored is bounded
by m_excScoredD (~OOS/horizon ~= 256 on SP500 H1) minus the post-ring-clear
warm-up (~8), so every chart failed "[trailing incumbent not warm enough to
race]" at 247-248 of a possible ~256 forever - observed live 2026-08-11 on
all four charts. The counter's statistical population is the same disjoint
sample passDj gates on, so it now takes the same minimum
(EXCURSION_MIN_DISJOINT, 200), reachable with margin after warm-up.

Compile: 0 errors, 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:50:39 -04:00

640 lines
36 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. |
//| |
//| Why this exists at all, and what it can and cannot buy: |
//| |
//| Direction is closed. Measured 2026-08-07 on three instruments |
//| with a working positive control: normalised asymmetry |
//| (up-dn)/(up+dn) fails on all three (SP500 p=0.3433, USDCAD |
//| p=0.5075, EURUSD p=0.2736) while RANGE (up+dn) clears at ~4x its |
//| null on all three. Confirmed independently 2026-08-11 by the |
//| classifier's own best-of-999 era-cap test: edge +0.9pp = 1.48 |
//| sigma at family-wise p=1.0000. |
//| |
//| SIZE is a different question and it IS predictable. Note what the |
//| excursion caches are denominated in - m_excUpCache holds |
//| (maxHigh - fill)/ATR, i.e. excursion RELATIVE TO CURRENT ATR - so |
//| "RANGE clears at 4x" is not a restatement of "ATR is |
//| autocorrelated". It says the ratio of future travel to today's |
//| ATR is itself predictable, which is exactly the part a fixed |
//| multiple (stop 3.31*ATR, target 1.64*ATR) throws away. That test |
//| is the one this file's own source memo warns to apply to any |
//| ratio-like target: ask what it is denominated in. It passes. |
//| |
//| WHAT IT CANNOT DO: create expectancy. Knowing the next leg spans |
//| 3 ATR is worth nothing without knowing which side it spans first. |
//| Corroborated by the random-entry exit test, which moved the |
//| payoff ratio 0.92 -> 5.72 with expectancy FLAT. Anything built on |
//| this head is RISK CONTROL - per-bar stop distance, position |
//| sizing, drawdown bounding under prop limits - and a claim that it |
//| improves win rate is a misreading. |
//| |
//| SURVIVAL PARAMETERISATION, not regression. The head emits |
//| 2 x BARRIER_LADDER_COUNT SIGMOID outputs: P(price reaches rung k |
//| upward within the horizon) and the same downward. Chosen over |
//| regressing the ATR multiple directly because it needs NOTHING new |
//| from CNet - sigmoid outputs and the per-neuron delta the |
//| `total != 3` branch of backProp already applies (a quantile head |
//| would need a linear activation and a pinball gradient, i.e. edits |
//| to Network.mqh, Network.cl and the DirectML path, on a class four |
//| topologies share). The targets are free: m_ladderUpAt already |
//| records first-touch age per rung, with 0 meaning "never reached". |
//| |
//| Any quantile is then read off the predicted survival curve by |
//| interpolation (ExcursionQuantile), which is precisely the per-bar |
//| generalisation of what DeriveBarrierGeometry does globally. |
//| |
//| STAGE 1 - MEASUREMENT ONLY. Nothing here places an order or moves |
//| a stop yet. The head trains beside the classifier and reports a |
//| SKILL SCORE against the only baseline that matters: the constant |
//| per-rung base rate, which is what a fixed ATR multiple already |
//| implicitly assumes. Positive skill means a per-bar stop knows |
//| something a global multiple cannot; zero or negative means ATR |
//| already carries everything and Stage 2 must not be built. Wiring |
//| it into SL/TP and sizing BEFORE that number exists would be |
//| building risk machinery on an unverified predictor. |
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| Build the head's topology: input window -> one hidden dense -> |
//| 2 x ladder sigmoid outputs. |
//| |
//| Deliberately SHALLOW and narrow. The classifier is the place |
//| capacity is being spent on a question that has no answer; this |
//| one is asking a question with a known, strong, low-dimensional |
//| answer (volatility clustering), and every extra parameter here is |
//| era time taken from a net that already needs 300 s/era. It is |
//| also the conservative choice for the measurement: if a small head |
//| shows skill, the signal is real and robust rather than something |
//| a large model dug out of noise. |
//+------------------------------------------------------------------+
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.
//--- These outputs are INDEPENDENT binary events - reaching 2 ATR does not preclude reaching 3 ATR,
//--- it implies it - so each wants its own sigmoid delta, which is what the other branch applies.
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_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). Using those here was a
//--- measurement bug, and a self-inflicted one: smoothing caps what the head can output at 0.9, and
//--- these rungs have base rates near 1.0 at the near end (almost every bar travels 0.5 ATR within a
//--- 64-bar horizon). Against a base rate of 0.99 the arithmetic is forced before the net learns
//--- anything at all:
//---
//--- constant at 0.99 -> Brier 0.99*(0.01)^2 + 0.01*(0.99)^2 = 0.0099
//--- head at 0.90 -> Brier 0.99*(0.10)^2 + 0.01*(0.90)^2 = 0.0180 => skill -82%
//---
//--- which is what the 2026-08-11 run showed at rung 0.50 (PAI -61.8%, CONV -146%) - a property of
//--- the target encoding, not of predictability. Smoothing earns its place on the 3-class head where
//--- it stops one logit running away in a softmax competition; there is no competition here and the
//--- head is scored on calibration, so it must be free to say 0.99 when the answer is 0.99.
//--- Safe against the runaway smoothing exists to prevent: this is an MSE-on-sigmoid gradient
//--- (calcOutputGradients), whose (target - output) term vanishes as the output approaches the
//--- target, rather than the unbounded-logit cross-entropy the classifier uses.
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. The head is 19k weights learning a low-dimensional, strongly-autocorrelated target;
//--- consecutive bars carry almost the same excursion information, so training on every primary bar
//--- buys resolution the target does not have and pays a full dispatch chain for it. One bar in
//--- EXCURSION_TRAIN_STRIDE keeps thousands of samples an era and cuts the head's training dispatches
//--- by the same factor. Counted on ATTEMPTS, not on accepted samples, so a stretch of unlabelled
//--- bars cannot quietly change the spacing.
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. That is deliberate and
//--- it is what makes the comparison fair: BOTH predictors are then fitted in-sample and evaluated
//--- out-of-sample, which is exactly the position a globally-derived fixed ATR multiple is in. Using
//--- OOS base rates as the baseline would hand the constant a look at the test set and understate
//--- the head; using them for the head alone would do the reverse.
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. |
//| |
//| Brier rather than log-loss on purpose: it is bounded, it does not |
//| explode on a confident miss, and the quantity a stop distance |
//| cares about is calibration of the probability itself. |
//+------------------------------------------------------------------+
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.
//---
//--- Adjacent bars share m_barrierHorizonBars-1 of their horizon, so ~16k consecutive bars carry
//--- ~16k/H independent observations: the full-sample tally was never worth more than the disjoint
//--- one, it just looked like it by quoting an n that was ~64x too large. Scoring only every H-th bar
//--- therefore costs nothing statistically and removes 63 of every 64 forward passes on this net.
//--- Measured 2026-08-11: the head took LSTM's era from ~300 s to 1087 s, ~40x my estimate, because
//--- the cost is per-DISPATCH (the 760-wide layer exceeds the CPU DLL's inline threshold and every
//--- backend pays a submit per layer) rather than per-FLOP - the net is 19k weights, ~2.4 GFLOP an
//--- era, which is seconds of arithmetic.
//---
//--- The trailing ring still advances on EVERY bar below: it needs the outcome sequence to stay a
//--- correct rolling estimate, and reading it costs array lookups, not a forward pass.
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. Nothing constrains the head's 8 independent sigmoids to respect that, and ExcursionQuantile
//--- walks the vector assuming it does - it returns the FIRST crossing, so a non-monotone curve is
//--- misread precisely on the bars where the head is least sure. 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;
}
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;
//--- ORACLE CONTROL. Accumulate the OOS positives per rung so the report can compute the BEST
//--- POSSIBLE CONSTANT for this block and score it in closed form - for a constant c,
//--- Brier = n*c^2 - 2c*H + H, so H and n are all it needs and no second pass is required.
//--- 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". Skill that survives against the
//--- IS constant but vanishes against the oracle is pure base-rate drift and carries no bar-
//--- resolution information at all. It peeks at the test block by construction, so it is a
//--- control and never a headline.
if(y > 0.5)
m_excOosHits[k]++;
m_excBrierHead[k] += (p - y) * (p - y);
m_excBrierBase[k] += (b - y) * (b - y);
//--- 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;
m_excBrierTrail[k] += (tr - y) * (tr - y);
//--- 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] += (p - y) * (p - y);
}
}
if(m_excTrailN >= EXCURSION_TRAIL_MIN_N)
m_excTrailScored++;
ExcursionTrailPush();
m_excScoredD++; // every bar reaching here IS a disjoint one now
m_excScored++;
}
//+------------------------------------------------------------------+
//| Advance the trailing-climatology ring by one bar. |
//| |
//| The lag is the point: a bar's rung outcomes are only KNOWN one |
//| horizon after it, so the newest `horizon` entries are held back |
//| unresolved. Pass 3 walks oldest-to-newest, so "pushed more than |
//| horizon bars ago" is exactly "resolved by now" - the estimate |
//| never sees an outcome the live EA could not have had. Without |
//| that hold-back the baseline would be reading the future, which |
//| would make the incumbent look better than it can actually be and |
//| hand the head an unbeatable opponent for the wrong reason. |
//+------------------------------------------------------------------+
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++;
}
//+------------------------------------------------------------------+
//| 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_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"). That reasoning assumed the walk is chronologically continuous
//--- - it is not: every era's pass 3 re-walks the SAME OOS window oldest-to-newest, so at the walk's
//--- restart the ring still held the outcome masks of the NEWEST OOS bars from the previous era's
//--- walk - the chronological FUTURE of the bars about to be scored. For the first ~window+horizon
//--- pushes of every era the "trailing" incumbent was partly a LEADING one: exactly the self-made-
//--- artifact class 06d4785 hunts, even though the bias direction is conservative for the gate (an
//--- informed incumbent is a harder hurdle). The cost of clearing is honest and already accounted:
//--- the first EXCURSION_TRAIL_MIN_N resolved bars of each era simply do not score the trail race
//--- (m_excTrailN gating), and m_excBrierHeadT accumulates the head on that same reduced bar set.
if(ArraySize(m_excTrailRing) > 0)
ArrayInitialize(m_excTrailRing, 0);
m_excTrailHead = 0;
m_excTrailCount = 0;
m_excTrailN = 0;
m_excTrailScored = 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. |
//| |
//| STAGE 2 ENTRY POINT. Nothing calls this yet and nothing should |
//| until the skill score is positive - it is defined here so the |
//| survival parameterisation has one documented reading, rather than |
//| being re-derived at each future call site. |
//+------------------------------------------------------------------+
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. |
//| |
//| Stated as a verdict rather than left as sixteen numbers for the |
//| same reason ReportExcursionInformation states one: the dangerous |
//| misreading of a positive skill score is "the model can predict |
//| profitable trades", and it cannot - this is a claim about how far |
//| price moves, made by a head with no directional output at all. |
//+------------------------------------------------------------------+
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. ExcursionQuantile reads the curve at the LIVE geometry - target 1.62*ATR, stop
//--- 3.31*ATR on the 2026-08-11 SP500 fit - so only the rungs bracketing those two distances can
//--- justify replacing the fixed multiple. Skill at 5 ATR is skill about a distance no order is ever
//--- placed at, and quoting the best rung out of eight is a best-of-N over a grid.
double slMult, tpMult;
BarrierMultiples(slMult, tpMult);
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);
//--- A rung is a decision rung if it brackets either live distance, i.e. the interpolation in
//--- ExcursionQuantile would read it.
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);
if(!bracketsTp && !bracketsSl)
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. Each one alone has a documented way of being passed by nothing:
//--- decision rungs - a point estimate at 5 ATR is skill about a distance nothing trades
//--- disjoint sample - overlapping windows understate every standard error by ~sqrt(horizon)
//--- vs oracle - beating a frozen IS constant is free if the OOS base rate merely drifted
//--- monotone curve - ExcursionQuantile reads the first crossing, so a tangled curve is misread
//--- This replaces a bare `skill >= 2%` point threshold. 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 passDj = (skillDj >= EXCURSION_SKILL_USEFUL_PCT && m_excScoredD >= EXCURSION_MIN_DISJOINT);
bool passOracle = (skillOracle >= EXCURSION_SKILL_USEFUL_PCT);
//--- 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. Also read this line on its own: a trailing estimate that itself beats
//--- the global constant is a cheap risk-control win available WITHOUT any of this machinery.
//--- The count gate is EXCURSION_MIN_DISJOINT, NOT EXCURSION_MIN_SCORED (2026-08-11): since
//--- e2c9593 the trail race only scores DISJOINT bars, so m_excTrailScored is bounded by
//--- m_excScoredD (~OOS/horizon, ~256 here) minus the post-ring-clear warm-up (~TRAIL_MIN_N/
//--- horizon, ~8) - it can never reach the 500 that MIN_SCORED demands of the all-bars counter,
//--- which made this gate unpassable by construction (observed 2026-08-11: every chart failing
//--- "not warm enough" at 247-248 of a possible ~256). Same statistical population as passDj,
//--- so it takes the same minimum.
bool passTrail = (skillTrail >= EXCURSION_SKILL_USEFUL_PCT && m_excTrailScored >= EXCURSION_MIN_DISJOINT);
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)
? " [disjoint sample too small]" : " [disjoint windows]";
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)
? " [trailing incumbent not warm enough to race]"
: " [beaten by a trailing quantile - no net needed]";
verdict += ". Stage 2 must not be built on this.";
}
Print(ID + StringFormat(": excursion head - DECISION rungs%s (live geometry stop %.2f target %.2f):"
" skill %+.1f%% vs IS constant, %+.1f%% on %d DISJOINT windows (every %d"
" bars), %+.1f%% vs the BEST constant on this block, %+.1f%% 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, m_excScoredD,
(int)MathMax(m_barrierHorizonBars, 1), skillOracle, skillTrail,
(int)m_excTrailScored, monoPct, skill,
m_excScored, m_excBaseTotal, perRung, verdict));
}
//+------------------------------------------------------------------+