forked from animatedread/Warrior_EA
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>
640 lines
36 KiB
MQL5
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));
|
|
}
|
|
//+------------------------------------------------------------------+
|