//+------------------------------------------------------------------+ //| 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)); } //+------------------------------------------------------------------+